The routing classifier half of the selector host (Mutare.Ecto.Host): the
Mutare.CallRouting.route_arguments/2 callback that decides, per visible argument of a
:routing-registered query macro, how core should treat that position — :hosted (the plugin's
host weaves it), :expression (mutate it normally), :raw (leave it raw), :interpolated
(core mutates a scalar value, delivered through ^ interpolation), or {:keyword, …} (per-pair
shorthand routing). Mutare.Ecto.Host then consumes the :hosted decision to build and weave
the ^/dynamic target.
Why every query macro routes :routing
The classifier exists so core never splices a runtime selector into a query expression (which
would poison the single build) without losing the upstream query. A composable macro's
data positions (a binding list, an ordering, a selector, a bound) must stay raw — core must
descend nothing there — but its threaded query is an ordinary expression that must stay
reachable: a static :raw registration would stamp the piped value :raw and silently drop
every upstream mutation, so the classifier marks that one position :expression and everything
else raw. A routed node is still offered whole to mutate/2, where the plugin's own mutators
fire (Mutare.Ecto.Clause, Mutare.Ecto.BindingReorder, Mutare.Ecto.ClauseDrop, and
Mutare.Ecto.Query for a from).
The threaded query: routed by form, not shape
Which position holds the threaded query is a fact of the call's form, which core reports
as the call's pipe_mode, not of any argument's shape. Written directly (where(q, [p], …)),
Ecto's API puts the queryable first; piped (q |> where([p], …)), the query is the hidden |>
left side and no visible argument is it — every visible argument is the macro's own data.
So the classifier never guesses whether a first argument "looks like" a query: a computed
queryable (where(base_query(2), [p], …), an if, a Map.fetch!) routes :expression exactly
like a bare variable or a nested from(…), and core analyzes its interior as it would anywhere
else (base_query(2)'s 2 → 3/1/0). The one shape read in that slot is the structural
queryable — a schema alias (where(Post, …)), a table-name string, or a {"table", Schema}
pair — which stays raw: a table/schema swap is a broken query, not a mutant (core's :alias and
:string families would otherwise name a nonexistent module or table).
dynamic and the is_named_binding guard helper instead register :raw
(Mutare.Ecto.Surface.macro_registrations/0): neither is a query-threading stage, so core must
not descend into their DSL/guard arguments. A :raw registration still offers the whole
call to mutate/2 — which is how a free-standing dynamic/1,2 is mutated in place
(Mutare.Ecto.Dynamic) while is_named_binding stays entirely inert.
The decisions, by call shape
- the
fromkeyword form —from(p in S, where: p.x == v, …): the source is never routed. A structural source (Post,"t") is a broken query if swapped, and a binding source (p in S) is a pattern over its queryable that no per-argument treatment can split — so a computed source (from(p in base_query(2), …)) stays raw here too, unlike the threaded query of a composable macro (above); its interior earns its mutants where the query is built. Eachwhere/havingcondition routes by shape, the same under a binding source or a bare queryable (from("t", …), whose conditions can only reference a named binding — the host weaves them behind an empty-bindingdynamic([], …)). A non-shorthand expression condition routes:hosted; a keyword-shorthand condition (where: [x: v]) instead routes its pairs individually{:keyword, …}: each scalar value:interpolated(core's literal families mutate it,^-pinned — Ecto rejects a bare selectorcasethere), while the column-name keys, thenil-valued pairs (anIS NULL, never= nil), and compound values are left raw. So a shorthand value mutation is recorded under the core family that made it (:literal/:string/…), not:ecto. The non-condition clauses (select/order_by/… — whole-from's job) are always left raw. - the piped
from—Post |> from(as: :post, where: as(:post).x > v, limit: 5): the same decisions, placed by form. The source is the hidden|>left side, routed:rawlike the direct form's source slot (the plugin never sees its shape, and swapping a structural one is a broken query), and the one visible argument is the clause list, routed per clause exactly as above — so a bare-queryable pipe hosts itsas(:_)conditions behind an empty-bindingdynamic([], …), pins its shorthand values, and weaves its literal bounds.Mutare.Ecto.AST.FromCallplaces the clauses bypipe_modefor the host and the whole-fromrewrites alike, so the piped and direct spellings yield the same mutants. - the composable pipe/standalone form —
q |> where([p], p.x == v)/where(q, [p], …): the threaded query routes:expressionby form (above), and the conditionMutare.Ecto.Host.Conditionlocates (binding-form or binding-less) routes:hosted. A keyword-shorthandwhere(q, x: v)routes its trailing pairs{:keyword, …}as in thefromform. The plain clause macros (limit/order_by/…) thread the query and leave their data positions raw — with one exception: a literal-integer bound (limit(q, 10)/q |> offset(5), and thelimit:/offset:keys of thefromform) routes:hosted, so the:bound±1 bump is woven pin-only. The literal-only guard isMutare.Ecto.Bound's, so routing and host agree by definition. - the standalone
join/4,5—join(q, :inner, [u], p in Post, on: …): the threaded query routes:expressionand the trailing options list routes per-pair, so itson:value routes by the same shape rule as thefromform's (:hostedexpression condition, per-pair{:keyword, …}shorthand) and the remaining options (as:/prefix:/hints:) stay raw.
This relies on core's recursive per-pair routing, hosted values, and :interpolated extensions;
see Mutare.CallRouting.route_arguments/2.
Summary
Functions
Mutare.CallRouting.route_arguments/2 for a :routing-registered query macro: the
per-visible-argument treatments/3 classification, wrapped as ArgumentRoutes. Core hands in a
resolved Mutare.CallRouting.Call, so the written form (bare/qualified/aliased/piped) is already
normalized, and its pipe_mode is what places the threaded query (see the moduledoc): a direct
call's first visible argument is it, while a piped call's hidden left side is — routed
:expression (from_visible's default), so the upstream query stays mutable through the stage.
The one exception is a piped from, whose hidden left side is the never-routed source, :raw
(piped_treatment/1).
Per-visible-argument treatment for a :routing-registered query macro (from, the
where/having family, join, and the plain clause macros), given the resolved macro name,
its visible args, and the call's pipe_mode — which decides whether the first visible
argument is the threaded query (see the moduledoc): one entry per argument. Returns [] for a
name the plugin doesn't route.
Functions
@spec route_arguments( Mutare.CallRouting.Call.t(), Mutare.CallRouting.routing_context() ) :: Mutare.CallRouting.ArgumentRoutes.t()
Mutare.CallRouting.route_arguments/2 for a :routing-registered query macro: the
per-visible-argument treatments/3 classification, wrapped as ArgumentRoutes. Core hands in a
resolved Mutare.CallRouting.Call, so the written form (bare/qualified/aliased/piped) is already
normalized, and its pipe_mode is what places the threaded query (see the moduledoc): a direct
call's first visible argument is it, while a piped call's hidden left side is — routed
:expression (from_visible's default), so the upstream query stays mutable through the stage.
The one exception is a piped from, whose hidden left side is the never-routed source, :raw
(piped_treatment/1).
@spec treatments(atom(), [Macro.t()], Mutare.Mutator.pipe_mode()) :: [ Mutare.CallRouting.treatment() ]
Per-visible-argument treatment for a :routing-registered query macro (from, the
where/having family, join, and the plain clause macros), given the resolved macro name,
its visible args, and the call's pipe_mode — which decides whether the first visible
argument is the threaded query (see the moduledoc): one entry per argument. Returns [] for a
name the plugin doesn't route.