A mutation-testing plugin for Ecto — a Mutare.Mutator that mutates the Ecto
surface (Repo calls, changeset pipelines, and the query DSL) while respecting
SQL semantics.
Enable it with a module entry, adding repo: when Repo-call mutations are needed:
# .mutare.exs
[mutators: [:all, {Mutare.Ecto, repo: MyApp.Repo}]]Listing it both registers the plugin's macro routing (via Mutare.CallRouting.call_routes/0,
discovered automatically) and enables its mutations. Query, changeset, and schema handling do
not require repo:; that option only identifies the module(s) matched by the Repo-call families.
This module is a thin front for a family of sub-mutators, dispatched by the node it
sees (Mutare.Ecto.Dispatcher): Mutare.Ecto.RepoAggregate and Mutare.Ecto.RepoWrite (Repo calls), Mutare.Ecto.Changeset
(changeset pipelines), Mutare.Ecto.Query (whole-from mutations), Mutare.Ecto.Clause and
Mutare.Ecto.QueryTerminal (standalone/pipe clause macros and first/last),
Mutare.Ecto.BindingReorder (positional binding transpositions on any binding-list macro),
Mutare.Ecto.ClauseDrop (removing a standalone/pipe clause stage — q |> where(…) → q),
Mutare.Ecto.Dynamic (in-fragment mutations of a free-standing dynamic/1,2, rewritten whole-call
in place), and Mutare.Ecto.Host (localized in-fragment where/having mutations, via the SQL
catalog in Mutare.Ecto.Fragment).
Configuration
Each entry takes:
repo:— the Repo module recognized by theRepo.aggregateand write-call families: one module, or a list (repo: [MyApp.Repo, MyApp.ReplicaRepo]) when the app has several. Optional when only query/changeset mutations are wanted; without it, Repo-call families are inert.families:— narrow the SQL catalog. Accepts:default(the unset default — every family except the opt-in:string_literal/:atom_literal/:boolean_literalarms, which are off for safety),:all(every family, including those arms), an explicit list, or a base-minus-exclusions{:default | :all, except: [families]}. Every family is independently toggleable; seefamilies/0for the full set anddefault_families/0for the default subset.# turn the string/atom/boolean literal arms back on {Mutare.Ecto, repo: R, families: :all} # the easy way to drop a default-on arm {Mutare.Ecto, repo: R, families: {:default, except: [:integer_literal]}}Even when the string/atom arms are enabled, a literal at a known DSL form's structural argument (the
fragmenttemplate, an interval unit, a cast type) is never mutated — seeMutare.Ecto.Fragment. Combined with:as(which renames the family in the report),families:both narrows a run and lets a sub-family be reported under its own name:{Mutare.Ecto, repo: R, families: [:comparison], as: :ecto_comparison}For a per-site subset rather than a run-wide one, each family is also a
# mutare:ignorevariant:# mutare:ignore[ecto:comparison]suppresses just the comparison mutants on that line, the rest still running (seevariants/0).dialects:— gate dialect-specific mutations (default[], the portable core).:postgresenableslike↔ilike;:postgres/:mysqlenable theLEFT↔RIGHTjoin swap (SQLite lacksRIGHT JOIN).as:— rename the recorded family (a core convention;:asis consumed by Mutare and never reaches the plugin). List the plugin twice with differentfamilies:/as:to split the catalog into separately-named report families, or with differentrepo:/as:to report each repo's mutants under its own name (a single entry withrepo: [A, B]covers both under one name).
Structural positions held back from core's families. Listing the plugin also suppresses a
little noise elsewhere: call_routes/0 routes the action atom of Ecto.Changeset.apply_action/2
and apply_action!/2 :raw, so no core family ever mutates it — the atom is metadata (it only
stamps an error changeset's action), never behaviour. This is not gated by families:,
which selects what the plugin produces; the route applies whenever the plugin is listed.
Equivalence-sensitive families. Some mutants carry a report note — a survivor reads
… SURVIVED — kill may require … — so it is recognised as honest signal, not a plain test gap.
Each note names the specific data a kill needs (a boundary row, a non-NULL row, NULL rows in
a column, an orphan row, …); the set and each family's reason are Mutare.Ecto.Equivalence's,
and the note is attached by finalize/2 (Mutare.Mutator.finalize/2) on every delivery path.
equivalence_sensitive_families/0 returns that set; with the :as convention you can
additionally group them under their own report name:
{Mutare.Ecto, repo: R, families: Mutare.Ecto.equivalence_sensitive_families(), as: :ecto_boundary_null}Without a families:/as: split every mutation is recorded under :ecto.
Macro routing
call_routes/0 registers the compile-time DSL so Mutare core never splices a runtime selector
into a query expression (which would poison the single build). schema/embedded_schema are
routed :raw — a mutated field name or type is a broken schema, not a mutant. Every query-building
macro (from, where/having, join, order_by, limit, …) routes through the per-argument
classifier Mutare.Ecto.Host.Routing, whose :hosted positions the selector host
Mutare.Ecto.Host weaves behind Ecto's ^/dynamic injection — a where/having condition,
or a literal limit/offset bound (pin-only — Mutare.Ecto.Bound); every routed node is still
offered whole to mutate/2 for the in-place families. A free-standing dynamic/1,2 registers
:raw (its arguments are left as written, the call itself still offered) and is mutated
whole-call by Mutare.Ecto.Dynamic.
Resolution of these macros relies on Mutare's use-expansion (so the
use Ecto.Schema-injected import Ecto.Schema, and a use MyAppWeb, :live_view-bundled
import Ecto.Query, are visible) — hence the deployment requirement required_modules/0
declares: Ecto must be loadable in the Mutare process.
Summary
Functions
The families enabled by default (the families: :default / unset set) — every family except the
opt-in :string_literal/:atom_literal/:boolean_literal arms, which are off for safety until
explicitly enabled.
The families whose survivors may be legitimately unkillable for a data reason, not a test gap
(see "Equivalence-sensitive families" above; the per-family reasons are Mutare.Ecto.Equivalence's).
Every SQL family the plugin can emit — the families: :all set, for a families: subset.
The declared deployment requirement (Mutare.Mutator.required_modules/0): the Ecto surface
the plugin routes (Ecto.Schema, Ecto.Query) must be loadable in the Mutare process. Core
checks the declaration once at startup — at Mutare.Mutator.Spec resolution (before
init/1) — so an external-source run (where Ecto and the target app's modules are not on the
code path) aborts with a Mutare.EnvironmentError instead of silently producing incomplete or
invalid routing.
The # mutare:ignore variant vocabulary: every SQL family the plugin can emit (families/0),
plus the finer operator/kind labels its swap and value families tag — a comparison's operator
(<), a literal's kind (zero), an aggregate (sum), a sort direction (asc), a NULLs placement
(nulls_first), a join kind (left), a set operation (intersect). Assembled from each producer's
own labels so the vocabulary can't drift from what is emitted.
Functions
@spec default_families() :: [atom()]
The families enabled by default (the families: :default / unset set) — every family except the
opt-in :string_literal/:atom_literal/:boolean_literal arms, which are off for safety until
explicitly enabled.
@spec equivalence_sensitive_families() :: [atom()]
The families whose survivors may be legitimately unkillable for a data reason, not a test gap
(see "Equivalence-sensitive families" above; the per-family reasons are Mutare.Ecto.Equivalence's).
@spec families() :: [atom()]
Every SQL family the plugin can emit — the families: :all set, for a families: subset.
The declared deployment requirement (Mutare.Mutator.required_modules/0): the Ecto surface
the plugin routes (Ecto.Schema, Ecto.Query) must be loadable in the Mutare process. Core
checks the declaration once at startup — at Mutare.Mutator.Spec resolution (before
init/1) — so an external-source run (where Ecto and the target app's modules are not on the
code path) aborts with a Mutare.EnvironmentError instead of silently producing incomplete or
invalid routing.
The # mutare:ignore variant vocabulary: every SQL family the plugin can emit (families/0),
plus the finer operator/kind labels its swap and value families tag — a comparison's operator
(<), a literal's kind (zero), an aggregate (sum), a sort direction (asc), a NULLs placement
(nulls_first), a join kind (left), a set operation (intersect). Assembled from each producer's
own labels so the vocabulary can't drift from what is emitted.
Every recorded mutant carries its family label and, for a swap/value family, the finer label too —
so a qualified directive suppresses either the whole family or one operator at a site, the rest
still running. The per-site analogue of the run-wide families: filter, only finer. With the
default config every mutant is recorded under :ecto, so a directive reads
# mutare:ignore[ecto:comparison] or # mutare:ignore[ecto:<]; an :as-renamed run reads
# mutare:ignore[<as>:<label>] (the vocabulary is unchanged). A bare # mutare:ignore[ecto]
still suppresses every mutant at the site.
from(u in User, where: u.age > 18 and u.height < 90) # mutare:ignore[ecto:<]
# ^ only the `<` swap is suppressed; the `>` swap,
# and the 18/90 literal swaps, all keep running