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

Copy Markdown View Source

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 23/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 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); its interior earns its mutants where the query is built. 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/… — whole-from's job) are always left raw.
  • the piped fromPost |> from(as: :post, where: as(:post).x > v, limit: 5): the same decisions, placed by form. The source is the hidden |> left side, routed :raw like 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 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) 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. The literal-only guard is Mutare.Ecto.Bound's, so routing and host agree by definition.
  • the standalone join/4,5join(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.

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

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

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