Mutare.Ecto.Fragment (mutare_ecto v0.3.0)

Copy Markdown View Source

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 exclude NULL rows and differ on every concrete value.
  • Connective — and↔or. Genuinely three-valued (a NULL operand is neither true nor false), so its equivalences differ from Elixir's.
  • NullPredicate — is_nil(x)↔not is_nil(x), treated as one unit so not is_nil(x) flips back rather than double-negating. Its argument is descended like any other, but is_nil observes 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 "What is_nil observes" below).
  • Membership — x in ^list↔x not in ^list and exists(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]/…; IN is set membership, so [1, 1, 2] shrinks to [2]/[1, 1], never to the equivalent [1, 2]), and like↔ilike — dialect-gated on :postgres (ilike is Postgres-specific; the rest is portable). The in operands 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 by Mutare.Ecto.Subquery, each mutant rebuilt into this condition.
  • Arithmetic — +↔-, *↔/, the shared Mutare.Ecto.Scalar catalog (also delivered in select/order_by values); binary forms only.
  • Coalesce — coalesce(x, default) → x (also Mutare.Ecto.Scalar): differs exactly on the rows where x is NULL. Beneath is_nil too: is_nil(coalesce(x, d)) → is_nil(x) differs on every row where x is NULL and d is not.
  • Aggregate — sum↔avg, min↔max (the shared Mutare.Ecto.Aggregate), for a having: 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. The unit is 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±1 plus 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 :mutare sentinel (dropped when already the sentinel); nil is 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-nil literal (a written negative number included) is never NULL;
  • a + b, a - b and a * b are NULL exactly when an operand is;
  • coalesce(a, b) is NULL exactly when both operands are;
  • sum/avg/min/max of x is NULL exactly when no input row has a non-NULL x.

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, so is_nil(a * b) → is_nil(a / b) is live, and so is a literal inside a divisor;
  • and/or — three-valued (NULL and false is false where NULL or false is NULL);
  • in and a tuple comparison — NULL or not depending on which values match;
  • a JSON path (is_nil(p.meta["k"]) — the key picks the element), a fragment(...) (is_nil(fragment("NULLIF(?, ?)", p.score, 0)) — the 0 decides which scores read as NULL), a subquery, a nested author macro;
  • a ^ pin — ordinary Elixir, free to compute nil or a value by any route (^(opts[:min] || default)), so an island beneath is_nil is 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

island()

@type island() :: {Macro.t(), role(), Mutare.Ecto.Walk.rebuild()}

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

role()

@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, a fragment, splice or constant argument).
  • :condition — the pin is a whole condition (a :root_pin predicate, 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

islands(root, root_role)

@spec islands(Macro.t(), role()) :: [island()]

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.

mutants(condition, config)

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