Mutare.Ecto.Fragment (mutare_ecto v0.1.1)

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, owned end to end

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 a borrowed Elixir-semantics mutator would silently drop genuinely killable mutants (or emit provably equivalent ones). The in-fragment literal arms are owned 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 sees 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 are decided here under SQL semantics, and each is named after the type it mutates (integer_literal, …), never after fragment(...).

The rule cuts the other way at every ^ pin: a pin's interior is ordinary Elixir evaluated at runtime, never this catalog's (an SQL-rationale ^(min * 2)^(min / 2) would mutate the parameter's Elixir value). The walk treats a pin as a leaf, and islands/1 hands each interior to the condition's owner for the core sub-contract — see Mutare.Ecto.Island. 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.
  • Connectiveandor. Genuinely three-valued (a NULL operand is neither true nor false), so its equivalences differ from Elixir's.
  • NullPredicateis_nil(x)not is_nil(x), treated as one unit so not is_nil(x) flips back rather than double-negating. Its argument is never descended — the value families preserve NULL-ness, so their mutants are provably equivalent there — with one exception the unit reads itself: the coalesce drop, the one mutation that changes NULL-ness (see the children/2 is_nil clause and null_interior/1).
  • Membershipx in ^listx 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 likeilikedialect-gated on :postgres (ilike is Postgres-specific; the rest is portable). Unlike is_nil, 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.
  • Coalescecoalesce(x, default)x (also Mutare.Ecto.Scalar): the one catalog mutation that changes NULL-ness, differing exactly on the rows where x is NULL — which is why it is also the one family offered beneath is_nil: is_nil(coalesce(x, d))is_nil(x) differs on every row where x is NULL and d is not.
  • Aggregatesumavg, minmax (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 and the is_nil rule covers it.
  • Temporalago(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 > 1819/17/0; 2.53.5/1.5/0.0): n±1 plus the zero sentinel, deduped and never equal to the original.
  • StringLiteralu.name == "ok"""/"mutare", dropping whichever equals the original.
  • BooleanLiteraltruefalse: 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 told apart 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).

Traversal

The traversal is the plugin's one shared walk (Mutare.Ecto.Walk, whose author-macro rule decides which nested-call arguments are entered): this module supplies its descent rule (children/2 — the unit predicates, the {parent_form, arity, index} position) and two per-node readers over the positions it admits — the catalog (local/3, behind mutants/2) and the island collector (local_islands/1, behind islands/1) — so the two can never disagree about which nodes a condition exposes.

Summary

Functions

Every interpolation island (^expr) in the condition, as {interior, rebuild} pairs — interior is the pin's Elixir expression and rebuild.(mutated_interior) is the full condition with exactly that pin's interior replaced (the pin itself kept). The condition's owner feeds each interior to the core sub-contract — 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, a keyword-shorthand value, an interpolation). 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 likeilike swap is emitted only under :postgres.

Functions

islands(condition)

@spec islands(Macro.t()) :: [{Macro.t(), (Macro.t() -> Macro.t())}]

Every interpolation island (^expr) in the condition, as {interior, rebuild} pairs — interior is the pin's Elixir expression and rebuild.(mutated_interior) is the full condition with exactly that pin's interior replaced (the pin itself kept). The condition's owner feeds each interior to the core sub-contract — see Mutare.Ecto.Island.

The islands are a second reader of the same positions mutants/2 reads (local_islands/1 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 an is_nil argument surfaces no island, a subquery argument surfaces the pins of the clauses Mutare.Ecto.Subquery mutates under that wrapper, and the pin itself is the boundary (the sub-contract owns everything beneath it).

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, a keyword-shorthand value, an interpolation). 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 likeilike swap is emitted only under :postgres.

The catalog proper is local/3 — what one node offers, at its {parent_form, arity, index} position — read over every position the shared walk (Mutare.Ecto.Walk) admits under this catalog's own descent rule (children/2).