The plugin's own SQL-semantics mutation catalog for a query fragment — the boolean
condition of a where/having clause or of a free-standing dynamic/1,2. Given a condition's
AST, mutants/1 returns every single-point variant: the same condition with exactly one
operator/predicate swapped, one variant per mutatable position. Each variant is a full,
compile-safe alternative the SQL engine will actually run; the host (Mutare.Ecto.Host) weaves
them behind a ^/dynamic selector so one of them bakes into the query per run, while
Mutare.Ecto.Dynamic rebuilds a free-standing dynamic call whole (an ordinary expression
position, needing no weave).
SQL semantics throughout the catalog
The catalog reuses none of Mutare's built-in mutators: the operators look like Elixir's
but they evaluate under SQL's semantics — three-valued boolean logic for the connectives,
NULL handling for the predicates, boundary behaviour for the comparisons, the engine's
division for arithmetic — so an Elixir-semantics mutator would silently drop
genuinely killable mutants (or emit provably equivalent ones). The in-fragment literal arms are
implemented here for the same reason: a literal written into a condition is part of the SQL
the query runs, not interpolated Elixir, so core never traverses it (the clause is raw/:hosted). They follow
core's value conventions — the numeric arms take their off-by-one/zero table and
# mutare:ignore labels from Mutare.AST.numeric_alternatives/3, so the two can't drift —
but the mutations are selected here under SQL semantics. Each is named after the type it
mutates (integer_literal, …), never after fragment(...).
A ^ pin's interior is ordinary Elixir evaluated at runtime, so this catalog never mutates it
(applying the SQL mutation ^(min * 2) → ^(min / 2) would mutate the parameter's Elixir value).
The walk treats a pin as a leaf, and islands/2 returns each interior for the calling module
to pass to core — see Mutare.Ecto.Island — together with the pin's role (role/0): the
pin moves a value out of the SQL, not out of the position it fills, so a pin at a structural
position (below) still carries structure. Field references (u.age) are likewise never mutated.
The families
- Comparison —
>↔>=,<↔<=,==↔!=. The==/!=swap is never "equivalent": in SQL both forms excludeNULLrows and differ on every concrete value. - Connective —
and↔or. Genuinely three-valued (aNULLoperand is neither true nor false), so its equivalences differ from Elixir's. - NullPredicate —
is_nil(x)↔not is_nil(x), treated as one unit sonot is_nil(x)flips back rather than double-negating. Its argument is descended like any other, butis_nilobserves only whether the argument is NULL, so a mutant there is pruned when — and only when — it is known to be NULL on exactly the original's rows (see "Whatis_nilobserves" below). - Membership —
x in ^list↔x not in ^listandexists(subquery)↔not exists(subquery)(unit polarity flips, no double negation), one element drop per distinct entry of a written in-list, removing every occurrence (x in [1, 2, 3]→x in [2, 3]/…;INis set membership, so[1, 1, 2]shrinks to[2]/[1, 1], never to the equivalent[1, 2]), andlike↔ilike— dialect-gated on:postgres(ilikeis Postgres-specific; the rest is portable). Theinoperands are descended (a swap on the left or a literal in the written list changes which rows match). A subquery argument is not descended as a condition; its interior is recursed byMutare.Ecto.Subquery, each mutant rebuilt into this condition. - Arithmetic —
+↔-,*↔/, the sharedMutare.Ecto.Scalarcatalog (also delivered inselect/order_byvalues); binary forms only. - Coalesce —
coalesce(x, default)→x(alsoMutare.Ecto.Scalar): differs exactly on the rows wherexis NULL. Beneathis_niltoo:is_nil(coalesce(x, d))→is_nil(x)differs on every row wherexis NULL anddis not. - Aggregate —
sum↔avg,min↔max(the sharedMutare.Ecto.Aggregate), for ahaving: sum(p.x) > n. Applied per node by this walk, so a condition is walked once for every family. - Temporal —
ago(n, unit)↔from_now(n, unit): symmetric around now, so a comparison against them differs only for rows inside that window. Theunitis structural (below); the count keeps its literal mutants. - IntegerLiteral / FloatLiteral — a non-pinned numeric literal (
u.age > 18→19/17/0;2.5→3.5/1.5/0.0):n±1plus the zero sentinel, deduped and never equal to the original. - StringLiteral —
u.name == "ok"→""/"mutare", dropping whichever equals the original. - BooleanLiteral —
true↔false: a boolean in a fragment is worth flipping exactly when it is worth using. - AtomLiteral — any other literal atom → the
:mutaresentinel (dropped when already the sentinel);nilis excluded (NULL/absence, no clean swap).
The string/atom/boolean arms are opt-in (off under the default families:) — see the
Mutare.Ecto configuration docs.
A literal arm is also suppressed at a structural position of a known Ecto DSL form, where the
literal shapes the SQL the builder emits rather than carrying data (a mutant would be a broken
query, not a live one): the fragment template, an interval unit, a cast type, a column,
binding, or alias name — structural_position?/1 is the registry, consulted off the
{parent_form, arity, index} the walk threads down. Data literals at every other position
of those forms are still mutated. A tuple is classified by position in the same way: at a
structural position it is a compound cast spec ({:array, :string}), skipped whole; anywhere else it is
Ecto's tuple comparison ({p.views, p.id} > {1, 2}), walked like a written list — each element
a data position of the comparison (children/2).
The same registry gives a pin at such a position its role. Ecto accepts an interpolation
at most of them — field(p, ^name), type(^v, ^type), ago(^n, ^unit), as(^binding),
selected_as(^name), a fragment's identifier(^name) — and the value the pin computes is the
same column, cast type, unit or name the written literal would have been. islands/2 reports
such a pin as :structural, never as a plain :value.
What is_nil observes
is_nil(x) reads one bit of x per row — whether it is NULL — so a mutant inside x that is
NULL on exactly the original's rows cannot change the predicate: it is equivalent, and pruned.
That property is claimed only where it is known, from one table of per-form NULL rules
(nullness/1):
- a non-
nilliteral (a written negative number included) is never NULL; a + b,a - banda * bare NULL exactly when an operand is;coalesce(a, b)is NULL exactly when both operands are;sum/avg/min/maxofxis NULL exactly when no input row has a non-NULLx.
Two decisions read that table. A mutant is pruned when it and the node it replaces are both
never NULL (a literal bump) or follow the same rule over the same operands (+→-,
sum→avg). And the NULL-ness-only observation passes down through a form only while that
form has a rule: each rule makes the form's NULL-ness a function of its operands' NULL-ness,
never of their values. Beneath any other form an operand's value may decide whether the
whole is NULL, so the full catalog applies there again.
Every other form is unknown, and an unknown is never pruned:
a / b— a zero divisor yields NULL on SQLite and MySQL and raises on Postgres, sois_nil(a * b)→is_nil(a / b)is live, and so is a literal inside a divisor;and/or— three-valued (NULL and falseis false whereNULL or falseis NULL);inand a tuple comparison — NULL or not depending on which values match;- a JSON path (
is_nil(p.meta["k"])— the key picks the element), afragment(...)(is_nil(fragment("NULLIF(?, ?)", p.score, 0))— the0decides which scores read as NULL), a subquery, a nested author macro; - a
^pin — ordinary Elixir, free to computenilor a value by any route (^(opts[:min] || default)), so an island beneathis_nilis passed to core like any other.
A form missing from the table costs an equivalent mutant (is_nil(p.a > 1) → >=: a scalar
comparison is NULL exactly when an operand is, but it has no rule here), never a live one.
Traversal
The traversal is the plugin's one shared walk (Mutare.Ecto.Walk, whose author-macro rule
specifies which nested-call arguments are entered): this module supplies its descent rule
(children/2 — the unit predicates, and each child's context: its
{parent_form, arity, index} position and what the enclosing predicate observes of it) and
two per-node readers over the positions traversed — the catalog (local/3, behind
mutants/2) and the island collector (local_islands/2, behind islands/2) — so the two
can never traverse different sets of nodes in a condition.
Summary
Types
One interpolation island: the pin's interior, the pin's role, and the rebuild of the walked root around a replacement interior (the pin itself kept).
What a ^ pin's value is to the query, read off where the pin sits — the one fact about a
pin its interior cannot show. It decides how much of that interior is still structure once
the interior is passed to core; the policy per role is Mutare.Ecto.Island's.
Functions
Every Elixir island in root, as island/0 triples — interior is
a pin's Elixir expression, role what the pin's value is to the query (role/0), and
rebuild.(mutated_interior) the full root with exactly that pin's interior replaced (the
pin itself kept). The calling module passes each interior to core, under the policy its role
selects — see Mutare.Ecto.Island.
Every single-point mutant of a where/having condition as self-tagging Mutare.Ecto.Tags, or
[] when the condition has nothing the catalog mutates (a bare boolean column, an
interpolation). The condition is a predicate. A keyword filter (where: [score: 5]) is not
this catalog's syntax — the walk would read a score: 5 pair as a value tuple, data on both
sides, and rename the column — so every caller classifies a condition-position value first
(Mutare.Ecto.Host.Condition). One tag per mutatable position, each the full condition with that one
position swapped, tagged with the SQL family that produced it (so the caller can filter by
families:) and the finer label naming the operator/kind it swapped (so a qualified
# mutare:ignore[ecto:<] can suppress just that one). config carries dialects: — the
like↔ilike swap is emitted only under :postgres.
Types
One interpolation island: the pin's interior, the pin's role, and the rebuild of the walked root around a replacement interior (the pin itself kept).
@type role() :: :value | :condition | :structural
What a ^ pin's value is to the query, read off where the pin sits — the one fact about a
pin its interior cannot show. It decides how much of that interior is still structure once
the interior is passed to core; the policy per role is Mutare.Ecto.Island's.
:value— a query parameter, plain application data: a pin at any data position (a comparison operand, an in-list element, a JSON path key, afragment,spliceorconstantargument).:condition— the pin is a whole condition (a:root_pinpredicate,Mutare.Ecto.Host.Condition), which Ecto dispatches on its runtime value: a keyword filter, a boolean or a dynamic.:structural— the pin fills a structural position (the moduledoc's registry): its value is a column, binding or alias name, a cast type, an interval unit or an SQL identifier, which the builder writes into the SQL it emits.
Functions
Every Elixir island in root, as island/0 triples — interior is
a pin's Elixir expression, role what the pin's value is to the query (role/0), and
rebuild.(mutated_interior) the full root with exactly that pin's interior replaced (the
pin itself kept). The calling module passes each interior to core, under the policy its role
selects — see Mutare.Ecto.Island.
A subquery's computed source is also an island, with role :value; its rebuild replaces the
source expression directly. Mutare.Ecto.Subquery identifies those query-building positions.
The islands are a second reader of the same positions mutants/2 reads
(local_islands/2 over Mutare.Ecto.Walk.positions/3, under the same children/2), so a
caller cannot reach an island the catalog would not have walked past — by construction, not by
a parallel walk kept in step. So a subquery argument surfaces the pins of the clauses
Mutare.Ecto.Subquery mutates under that wrapper, and the pin itself is the boundary
(everything beneath it is passed to core for mutation). What the enclosing predicate observes
never narrows this reader: a pin beneath is_nil is an island too, because nothing is known
about which Elixir mutants keep a parameter's nil-ness (see "What is_nil observes").
The role is read off the same walk: a pin's is its position's — :structural at a
structural position, :value at every other. Only a pin that is the root has no position
to read, and what the root is only the caller knows, so the caller states it as root_role:
:condition for a predicate, and Mutare.Ecto.Subquery's for the other roots it walks.
@spec mutants(Macro.t(), Mutare.Ecto.Config.t()) :: [Mutare.Ecto.Tag.t()]
Every single-point mutant of a where/having condition as self-tagging Mutare.Ecto.Tags, or
[] when the condition has nothing the catalog mutates (a bare boolean column, an
interpolation). The condition is a predicate. A keyword filter (where: [score: 5]) is not
this catalog's syntax — the walk would read a score: 5 pair as a value tuple, data on both
sides, and rename the column — so every caller classifies a condition-position value first
(Mutare.Ecto.Host.Condition). One tag per mutatable position, each the full condition with that one
position swapped, tagged with the SQL family that produced it (so the caller can filter by
families:) and the finer label naming the operator/kind it swapped (so a qualified
# mutare:ignore[ecto:<] can suppress just that one). config carries dialects: — the
like↔ilike swap is emitted only under :postgres.
The catalog proper is local/3 — the mutations for one node, at its {parent_form, arity, index}
position — applied at every position the shared walk (Mutare.Ecto.Walk) traverses under this
catalog's own descent rule (children/2), and narrowed beneath an is_nil to the mutants not
known to keep the node's NULL-ness (observable/3).