Changelog
v0.7.0
Added
derived_fromon projected columns. Each column returned bybuild_pipe/2now also carriesderived_from:nilfor plain projections, and for computed columns%{function: name, fields: paths}— the outermost function or aggregation and every source path it reads, followed through nested functions and earlier stages.count()reads no paths. Callers that previously matched the column maps exactly need to account for the new key.
v0.6.1
Changed
date(column)comparisons against constant dates use the column's index. Whendate(col)ordate(col, zone)is compared with a literal date, a boundDate, or aliteral_transformdate range (includingBETWEEN), the builder compares the raw column against day-start instants (midnight in the session zone, or inzone) instead of casting every row. Results are identical; comparisons against other columns, anddate()on a column that is already a date, compile as before.
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