Mutare.Ecto.Host.Routing (mutare_ecto v0.2.0)

Copy Markdown View Source

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 from keyword 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)'s 2, which where(base_query(2), …) keeps. Each where/having condition 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-binding dynamic([], …)). 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 selector case there), while the column-name keys, the nil-valued pairs (an IS 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-from mutations) 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 :raw like 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 its as(:_) conditions behind an empty-binding dynamic([], …), pins its shorthand values, and weaves its literal bounds. Mutare.Ecto.AST.FromCall places the clauses by pipe_mode for the host and the whole-from rewrites 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 :expression by form (above), and the condition Mutare.Ecto.Host.Condition locates (binding-form or binding-less) routes :hosted. A keyword-shorthand where(q, x: v) — or where(q, [p], x: v): a binding list before it does not make it a predicate — routes its trailing pairs {:keyword, …} as in the from form. 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 the limit:/offset: keys of the from form) routes :hosted, so the :bound ±1 bump is woven pin-only. Routing and hosting both use the literal-only guard defined in Mutare.Ecto.Bound.
  • the standalone join/4,5 — join(q, :inner, [u], p in Post, on: …): the threaded query routes :expression and the trailing options list routes per-pair, so its on: value routes by the same shape rule as the from form's (:hosted expression 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

route_arguments(call, context)

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).

treatments(macro, args, pipe_mode)

@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.