The routing classifier half of the selector host (Mutare.Ecto.Host): the
Mutare.CallRouting.route_arguments/2 callback that specifies, 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 uses the :hosted classification 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 fail 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 mark 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.
The classifier does not infer this position from the first argument's shape: 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). Only the direct form
can read it. Piped, the queryable is no visible argument, and core's Call carries the
visible ones alone, so the left side routes :expression whatever it is: Post |> where(…)
hands Post to core's :alias family where where(Post, …) holds it back (NOTES "A
structural queryable on a pipe's left is core's").
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 classifications, 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). The query is still mutated inside the function that builds it; what is lost is the call written here —base_query(2)'s2, whichwhere(base_query(2), …)keeps. 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/… — handled by whole-frommutations) are always left raw. - the piped
from—Post |> from(as: :post, where: as(:post).x > v, limit: 5): the same classifications, placed by form. The source is the hidden|>left side, routed:rawlike the direct form's source slot (its shape is not passed to the plugin, 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)— orwhere(q, [p], x: v): a binding list before it does not make it a predicate — 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. Routing and hosting both use the literal-only guard defined inMutare.Ecto.Bound. - 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.
Whether a condition-position value is a predicate (:hosted) or a keyword shorthand (per-pair)
is decided once, by Mutare.Ecto.Host.Condition, and the host reads the same decision — so
no position is ever both pinned per pair by core and woven by the host, whichever sibling
positions are hosted.
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 passes 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 determines 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 passes 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 determines 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.