Changelog
v0.6.0
Added
- Coercion and date-part functions:
date(value),date(datetime, zone),at_zone(datetime, zone),text,integer,number,year,month,day,weekday(ISO, Monday = 1),hour, anddays_between(from, to). Each carries a result type, so the opposite operand of a comparison is cast to match (date(created_at) == {{day}}bindsdayas a date) and projected columns report it. Zones are IANA names, validated for shape and inlined with Ecto'sconstant/1, so zoned expressions group correctly; plaintimestampcolumns are read in the sessionTimeZone.
Changed
- Requires Ecto
~> 3.13(forconstant/1).
v0.5.2
Added
- Output columns report their provenance. Each entry
build_pipe/2returns incolumnsnow carriesfield: the source path a plain projection or breakout reads (["customer", "region"]forcustomer.region), carried through later stages that re-project it by name, andnilfor anything computed (functions, aggregations). Callers can map an output column back to its catalog entry — types, labels, descriptions — without re-parsing the query.
v0.5.1
Fixed
- Table names may contain hyphens. A pipe source segment previously had to
be a bare unquoted SQL identifier, so a schema-per-tenant layout that names
schemas after UUIDs —
client_559de6a3-0cc8-4813-a9be-9c31a39ab47d.controls— failed to parse at the first hyphen. Table references only ever appear in the source position, where there is no arithmetic for a-to belong to, so hyphens are unambiguous there. A source must still start like an identifier, and aliases, column paths, and function names are unchanged.
v0.5.0
One release, two headline features: {{name}} parameters (with
[[optional]] groups and the literal_transform: hook) for the existing
filter API, and the full pipe language — the staged query language from
docs/pipe-language.md (source + filter/select/group/sort/limit/
offset stages, staged compilation, @slug external references, positioned
validation errors). The pipe language was originally slated for v0.6.0 and
was folded into this unpublished release instead of shipping two releases in
one day.
Added — the pipe language
-
EctoQueryParser.parse_pipe/1andEctoQueryParser.build_pipe/2— parse and compile staged pipe queries:orders | filter status == "paid" AND created_at >= "last month" | group customer.region { total = sum(amount), n = count() } | sort -total | limit 10A query is a source plus
|-separated stages (whitespace/newlines around|insignificant; a bare source is valid).parse_pipe/1returns an%EctoQueryParser.Pipe.Query{}exposing the:sourceso callers can validate the referenced table against their catalog before building;build_pipe/2accepts the text or the parsed struct plus the existingallowed_fields:/params:/literal_transform:options and the newresolve_source:. -
Stages.
filter <expr>— the existing filter grammar verbatim (operators, functions, association paths with JOIN/EXISTS, JSONB paths,{{params}},[[optional]]groups). Multiple filter stages allowed.select col [, col ...]— identifiers (association paths, singular-only) oralias = FUNC(...)with the existing function set; restricts the output shape for later stages.group [breakouts] { alias = agg(...), ... }— breakouts are identifiers, function applications (ROUND_*temporal bucketing), oralias = FUNC(...); un-aliased function breakouts get a derived, referenceable name (round_month_created_at). Aggregations:count(),count(col),count_distinct(col),sum(col),avg(col),min(col),max(col)— every aggregation aliased, aliases must not collide with each other or breakout names.group { ... }with no breakouts is a single-row summary. Output shape = breakouts then aliases.sort key [, key ...]—-keydescending; after agroup, keys refer to the grouped output including aliases. A later sort replaces an earlier one.limit N/offset N— non-negative integer literals only, at most one of each; row capping stays the caller's job.
-
Staged compilation. Stages fold onto one Ecto query; the first column-referencing stage after a projection (or after
limit/offseton a projected level) wraps the accumulated query in a subquery, sofilteraftergroupis HAVING semantics with no special case. Consecutive same-shape stages share a query level. Output field specs flow stage to stage (breakouts keep the underlying field type,sum/min/maxkeep their argument's,count*are:integer), so coercion andliteral_transform:keep working over grouped output. -
Positional output columns (atom safety). Aliases never become atoms: projections select into the fixed key set
:c0..:c63(max 64 columns per stage) andbuild_pipe/2returns{:ok, query, columns}wherecolumnsis the ordered%{name: "total", key: :c1}rename mapping (nilwhen no projection stage ran). A hostile flood of unique alias/table/slug/column names cannot grow the atom table — covered by regression tests. -
@slugexternal references (@monthly-revenue; slugs[a-z0-9][a-z0-9-_]*) resolve through the newresolve_source:option:fn slug -> {:ok, queryable, fields} | {:error, message} end. The queryable is inlined as a subquery source andfields(anallowed_fields-format spec of its output) validates and types the stages that follow. Missing resolver, resolver errors, and off-contract returns are clear build errors carrying the source's position. -
Positioned validation errors — new
EctoQueryParser.ValidationErrorexception struct withmessage,line/column/byte_offset,stage, andstage_index. Identifiers, aliases, and stage keywords carry source positions through the pipe AST, so unknown columns inselect/group/sort, alias collisions, plural associations outside filters, unresolvable@refs, and duplicatelimit/offsetstages point at the offending token. Boundary: errors arising inside afilterstage's boolean expression keep the plain{:error, binary}shape (prefixed with"in filter stage N: ") — the filter grammar's AST carries no per-token positions. -
EctoQueryParser.parameters/1accepts pipe queries (text or parsed struct), collecting{{name}}parameters across all filter stages with the samerequiredsemantics.
Added — parameters and literals
{{name}}parameters. Placeholders are valid anywhere a literal may appear (right side of comparisons, list elements, function arguments, BETWEEN bounds); names match[A-Za-z_][A-Za-z0-9_]*and whitespace inside the braces is tolerated. Values bind at build time via the newparams: %{"name" => value}option onEctoQueryParser.apply/3andBuilder.build/2, and behave exactly like inline literals: same type coercion against the field's type, always bound as prepared-statement parameters. Values without a literal syntax (Date,Decimal, ...) can be passed directly. An unbound parameter (missing key ornil) is a build error:{:error, "missing required parameter: name"}. Parameter names stay strings end to end — no atoms.[[ ... ]]optional groups. A bracketed group carries itsAND/ORconnector inside the brackets and attaches to the surrounding boolean chain at that connector's precedence level (status == "x" [[AND created_at >= {{start}}]]); a connector-less[[expr]]may stand as the whole filter (degenerating toWHERE TRUEwhen pruned). If every{{param}}in a group is bound, the group participates as if the brackets were absent; if any is unbound, the whole group is pruned before building. Groups with no parameters are always included. Groups do not nest; unmatched or nested brackets areParseErrors with positions. Pruning runs after parse and before the EXISTS rewriter, so groups compose with plural-association predicates (merging into the sameEXISTS) andNOT.EctoQueryParser.parameters/1— parameter discovery. Returns{:ok, [%{name: "status", required: true}, ...]}in order of first appearance;requiredisfalseiff every occurrence of the name sits inside optional groups. Parse failures return the usual{:error, %ParseError{}}.literal_transform:build option — afun(ecto_type, raw_string)hook called for string literals (and bound string parameter values) resolved against a typed field, before the built-in coercion — on plain fields, association-path leaves (including insideEXISTS), BETWEEN bounds, IN elements,includes, and LIKE/ILIKE patterns. Returns::default— today's behavior (coercion +type/2wrap);{:ok, term}— replace the value, bound as a plain pin with notype/2wrap (the transform owns the type);{:range, {lo, hi}}— the literal denotes an inclusive range, compiled per operator:==→>= lo AND <= hi,!=→ its negation,>=→>= lo,>→> hi,<=→<= hi,<→< lo; BETWEEN bounds resolve independently (low takeslo, high takeshi); a literal on the left flips the operator first.{:range, _}for any other operator (IN elements, LIKE,includes) is a build error with a clear message.
Changed
ROUND_*functions now inline theirDATE_TRUNCunit as a SQL literal (DATE_TRUNC('month', ...)) instead of binding it as a parameter. The unit comes from a fixed internal map (never from input), and inlining keeps the expression textually identical wherever it repeats — required for grouped SELECT/GROUP BY expressions to match under Postgres.
Fixed
- Building a list containing a non-literal (e.g. an identifier) now returns
{:error, "lists may only contain literal values, ..."}instead of raising aFunctionClauseError.
v0.4.0
Security
- Fixed an atom-exhaustion vulnerability in dotted-identifier resolution.
resolve_dotted_identifier/2and the JSON-path resolver calledString.to_atom/1on raw input before any allowlist check, so a hostile stream of unique dotted identifiers (a.b1 == 1,a.b2 == 1, …) could grow the BEAM atom table without bound and crash the node. Since this library's entire purpose is filtering by untrusted input, treat this as a mandatory upgrade. No code path converts input to atoms anymore:- Allowlist (
:allowed_fields) checks now compare strings. - Association segments and leaf fields resolve via
String.to_existing_atom/1; unknown names return the usual"unknown field: ..."/"unknown association: ..."errors. - JSON path segments stay plain strings all the way into
json_extract_path/2— they never touch the atom table. - Regression tests assert the atom count stays flat under hostile input.
- Allowlist (
Changed (BREAKING)
- Parse failures now return
{:error, %EctoQueryParser.ParseError{}}instead of{:error, binary}. The struct is anExceptioncarryingmessage,line(1-based),column(1-based),byte_offset, andrest(the unconsumed input, truncated) — enough to power editor diagnostics.Exception.message/1renders a one-liner including the position. This applies toEctoQueryParser.parse/1and propagates throughEctoQueryParser.apply/3. Code matching{:error, reason} when is_binary(reason)on parse failures must be updated; builder/validation errors (unknown field, field not allowed, unknown function, …) keep their{:error, binary}shape, since no source position is known at that stage.
Added
- Strict comparison operators
>and<(with the same literal type coercion as>=/<=). NOT— unary logical negation:NOT expr,NOT (a OR b). Precedence isNOT>AND>OR. AcceptsNOT/not, like the other keywords. Negating a plural-association predicate now producesNOT EXISTS, lifting the v0.3.0 limitation ("posts with no matching comments" works).IS NULL/IS NOT NULL— postfix on identifiers, association paths, JSON paths, and function expressions; compiles tois_nil/1/not is_nil/1.IN— list membership:age IN [18, 21],status in ["a", "b"]. List elements are type-coerced against the field's type the same way==coerces its literal.BETWEEN—field BETWEEN low AND highcompiles tofield >= low and field <= high, with both bounds coerced to the field's type. The innerANDbinds toBETWEEN, not the logical connector.- All new operators work on plain fields, association paths (respecting the JOIN vs EXISTS split for plural associations), and inside parentheses.
- Parse-failure messages are now labeled and concise instead of NimbleParsec's exhaustive expected-token dump.
v0.3.1
Fixed
many_to_manyfilters with:join_prefixcrashed withFunctionClauseErrorinEcto.Queryable.Tuple.to_query/1. The EXISTS subquery built the inner-join source as a{prefix, table}string tuple and pinned it into the join macro; at runtime that falls throughEcto.Queryable.to_query/1which only accepts{string, atom}tuples. The prefix is now passed viajoin/5's:prefixkeyword option, the same pattern used for prefixed belongs-to joins.
v0.3.0
Added
has_manyandmany_to_manyrelationship support. Plural-side filters now compile to correlatedEXISTSsubqueries instead ofLEFT JOINs, avoiding the row-duplication that previously corrupted counts andORDER BY/LIMITon schema-based has-many filters.- New schemaless
allowed_fieldstuple shapes peer with the existing{:assoc, ...}:{:belongs_to, table:, owner_key:, related_key:, fields:, prefix:}— alias for{:assoc, ...}.{:has_many, table:, owner_key:, related_key:, fields:, prefix:}— emitsEXISTS (SELECT 1 FROM table WHERE related_key = parent.owner_key …).{:many_to_many, table:, join_through:, join_owner_key:, join_related_key:, owner_key:, related_key:, fields:, prefix:, join_prefix:}— emitsEXISTSthrough the join table.
- Schema-mode association cardinality is auto-detected from
__schema__(:association, name).belongs_toandhas_onekeep producingLEFT JOIN;has_manyandmany_to_manyswitch toEXISTS. - §4-style grouping: when multiple predicates filter the same plural alias
under the same boolean connector, they collapse into one
EXISTS. AND oncomments.bodyandcomments.spamproduces a single subquery whose WHERE clause combines both predicates; OR similarly OR-s them inside oneEXISTS. Predicates on different aliases stay in separateEXISTSclauses. :prefixoption onbelongs_to,has_many, andmany_to_manytuples (also:join_prefixonmany_to_many) flows through to theLEFT JOINsource orEXISTSsubquery'sFROM/JOIN. This eliminates the need for downstreamJoinExpr.prefixpatching when using schema prefixes for multi-tenancy.
Changed
- Schema-mode
has_manyfiltering previously emitted aLEFT JOINthat silently duplicated parent rows for each match. It now emits anEXISTSsubquery and never duplicates. This is a deliberate fix; users who were applyingDISTINCTexternally to compensate can remove it. EctoQueryParser.apply/3now normalizes the queryable to an%Ecto.Query{}and names the source binding (as: :__eqp_source) if the user hasn't named it. This lets the EXISTS subquery'sparent_ascorrelation reference the outer source. A user-suppliedas:on the source is preserved.
Limitations (v1)
- Plural associations must be the first segment of a dotted path:
comments.author.nameworks, butauthor.comments.bodyreturns an error. This restriction may be lifted in a follow-up. NOT EXISTSfilters ("posts with no comments") are not yet supported — they require parser-level negation, which is a separate change.
v0.2.0
- Automatic literal type coercion in comparisons. When a literal is compared
against a typed field (e.g.,
performed_on >= "2026-05-20"whereperformed_onis a:date), the literal is now wrapped withtype/2so Ecto and the database driver cast it to the column's type. Previously this could fail in PostgreSQL with errors likeoperator does not exist: date >= text. - Coercion sources its type information from the schema (
__schema__(:type, _)), from the keyword form of:allowed_fields, and by walking association paths to the leaf field. Works for==,!=,>=,<=, andincludesin both operand orders. - Coercion is skipped when the literal's natural type already matches the field (e.g., string-vs-string, integer-vs-integer), so existing queries are not affected.
v0.1.0
- Initial release
- Query language parser with support for strings, integers, floats, booleans, and lists
- Comparison operators:
==,!=,>=,<= - Text operators:
contains,like,ilike,search - Array operator:
includes - Logical operators:
AND,OR, parenthesized grouping - String functions:
UPPER,LOWER,TRIM,LENGTH,LEFT,RIGHT,SUBSTRING,CONCAT,REPLACE,COALESCE - Math functions:
ABS,FLOOR,CEIL - Date/time functions:
NOW(),ROUND_SECONDthroughROUND_YEAR,ADD_INTERVAL,SUB_INTERVAL - Automatic left joins for dotted association paths (e.g.,
author.name) - JSONB column access for
:mapfields (e.g.,metadata.key) - Schemaless query support with association definitions in
allowed_fields - Field allowlisting via
:allowed_fieldsoption