All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.3.0 - 2026-09-26
Changed
- Breaking: Mutare 0.4.1 or newer is required (
{:mutare, "~> 0.4.1"}). Mutare now hands a pipe stage to the plugin as the direct call it is sugar for, so the plugin reads a query source, a changeset, or aRepowrite's value at argument 0 in both spellings, and thepipe_left/pipe_modereading of 0.3.1 is gone. - Query regions resolve through the public
Mutare.Analyze.resolve/2API. Core reroutes rebuilt calls, so clause drops no longer need plugin-side routing-metadata repair. - A dropped pipe stage collapses to what flows into it.
q |> where([u], u.active)with thewheredropped now readsq |> where([u], u.active)→qin the report, where it readwhere([u], u.active)→Elixir.Function.identity(); a stage in the middle of a chain is diffed over the pipe up to it (q |> where(…) |> limit(10)→q |> where(…)) and is still located at the stage's line. The same holds for a dropped changeset validator or hook and for thepersistencerewrite of a pipedRepowrite, which now nests itsapply_actionchain around the piped value instead of emitting a pipe stage. The mutants and their kills are unchanged. - A source binding list piped into
fromis reordered too.([a, b] in q) |> from(…)now gets thebinding_reordermutant ([b, a] in q) its direct spelling always had; through 0.2.1 the piped declaration was read-only.
Added
- A CTE query pinned inline is mutated where it is written.
with_cte("name", as: ^from(…))now gets every mutant the same query gets when bound to a variable beforehand (popular = from(…); … |> with_cte("popular", as: ^popular)): the plugin's own SQL mutants inside it and Mutare's families on its Elixir. Anas:written as SQL (fragment("…")) and the other options stay as written.
Fixed
- Nested query macros no longer suppress the surrounding fallback condition mutants, such
as comparison and aggregate swaps in a
havingcontainingsubquery(from(…)), through Mutare 0.4.1's fix for macros inside raw/hosted syntax.
0.2.1 - 2026-09-19
Fixed
- Computed
fromsources retain their upstream mutants, in both direct and piped forms. Schema aliases, table names and source tuples now stay unmutated on the left of composable query stages, just as they do in direct calls. - Piped binding declarations (
(p in Post) |> from(where: p.views > 5)) supply their bindings to hosted conditions. Whole-call rewrites, including clause drops, ordering flips and fallback condition rewrites, also run on this spelling. Source binding reorders remain unavailable because the left operand is read-only.
Changed
- Mutare 0.3.1 or newer is required (
{:mutare, "~> 0.3.1"}) for syntax-preserving pipe-stage delivery and theCall.pipe_leftAPI used by source routing and binding discovery.
0.2.0 - 2026-09-17
Added
validation_boundary, a changeset family: the strict/non-strict swap of avalidate_number/3bound —greater_than↔greater_than_or_equal_to,less_than↔less_than_or_equal_to— the changeset counterpart of the in-querycomparisonswap, on by default and equivalence-sensitive (a kill needs a changeset whose value sits exactly on the bound).equal_to/not_equal_toare deliberately not swapped;validate_length's inclusivemin/maxhave no strict counterpart, so Mutare's integer family still provides their off-by-one mutations.clause_dropreaches afromkeyword list.from(p in Post, group_by: …)can now lose itsgroup_by:, asq |> group_by(…)always could — likewisedistinct:,preload:,lock:,select_merge:,with_ties:and the set operations (union:,except:, …), under the sameclause_dropfamily. Afromis compiled as one keyword list, so only a key the rest of the list cannot need is dropped: a join (other clauses read its binding),select:(a schemaless source requires one),update:andwindows:are left alone. Expect more mutants on existingfromqueries.join_typereaches a standalone join.join(q, :left, [p], c in Comment, on: …)andq |> join(:full, …)now have their qualifier narrowed —:left→:innerand:full→:left, plus:left↔:rightand:full→:rightunderdialects: [:postgres]or[:mysql]— exactly as afrom'sleft_join:key always was, under the same# mutare:ignore[ecto:left]labels. Only a written qualifier is swapped, never a computed one.A standalone join written without a binding variable has its
on:mutated.join(q, :inner, [p], "audit", on: p.id > 1)(or a baresubquery,fragment,^sourcejoin — typically reached through itsas:name) used to receive only its stage drop; itson:now gets the same in-query mutants as a named join's.
Changed
Mutare 0.2.1 or newer is now required (
{:mutare, "~> 0.2.1"}, raised from~> 0.1). Sourceror 1.12.3 corrected two range over-counts that Mutare had compensated for, so against an older Mutare the compensation double-corrects: a mutant whose range ends at a baretrue/false/nilrenders its diff one character short (where: :mutatede), and the JSON/SARIF reporters emit the shortendColumn. Mutare 0.2.1 drops the compensation and floors Sourceror to match. Only a survivor's reported location was ever affected — never which mutants are generated, nor how they behave.The README states what each query spelling gets, instead of "both syntaxes are covered". Most families reach the same mutated queries from a
fromkeyword list and from composable stages, but not all: a join, aselectand awindowsdrop from a pipeline only, a computed query is mutated underwhere(recent(2), …)and not underfrom p in recent(2), and a schema or table name on a pipe's left (Post |> where(…)) is not held back from Mutare's own families. The new "Coverage by spelling" section lists these, along with two placement effects — only an inlinefromsubquery is entered, and a^pin's interior is mutated inside a condition only — and what a dropped stage does when a later stage depended on it. Each row is pinned by a test.Changeset stages are routed. Listing the plugin now holds back from Mutare's core families the changeset positions where a core swap is a crash rather than a mutant: a written field atom (
validate_length(cs, :name, …)— swapping it names a field Ecto raises on), the option keys ofvalidate_number(greater_than:,message:, … — Ecto rejects unsupported options), and a writtencount:mode ofvalidate_length(:mutareis no mode Ecto dispatches on). Core still mutatesvalidate_length's keys: Ecto ignores an unknown key, somin:→mutare:is a live mutant — that one bound gone, the call otherwise intact — finer than the whole-call drop. The bound values still receive core's literal mutants, and a field list (validate_required(cs, [:name, :email])) stays an ordinary expression for core's list families.An unnamed
assocjoin follows theassocrule. Theon:of anassocjoin is left to the whole-query families, never mutated in place — but the rule recognised onlyc in assoc(p, :posts), so afromjoin written as a bareassoc(p, :posts)had itson:mutated in place all the same. Both spellings are now treated alike.
Fixed
A
fromjoin written without a binding variable no longer shifts the bindings of the joins around it. Ecto bindscross_join: "audit"(or a baresubquery,fragment,assoc,^sourcejoin) anonymously and still counts it, but the binding list the plugin re-declares for a hostedwhere/having/on:left it out — so infrom p in "posts", cross_join: "audit", join: c in "comments", …a condition oncwas resolved against"audit". That held for every branch of the woven selector, the unmutated one included: where the two tables share the column the condition reads, the instrumented query ran without error and returned the wrong rows, so tests could fail (or mutants be misjudged) under Mutare that pass without it. An unnamed join is now re-declared as_([p, _, c];[p, ..., c, _]when the source is a composed query).Live mutants beneath
is_nilare no longer pruned. Every mutant inside anis_nil(...)argument except the coalesce drop used to be suppressed as equivalent, on the claim that value mutations preserve NULL-ness. They do only for some forms. A mutant there is now pruned only when it is known to be NULL on exactly the original's rows — a literal bump,+↔-,sum↔avg/min↔max, beneath+/-/*,coalesceand the aggregates — and everything else is offered:*↔/(a zero divisor is NULL on SQLite and MySQL),and↔or, a literal or swap inside afragment(...)(is_nil(fragment("NULLIF(?, ?)", p.score, 0))), a JSON path key, a divisor. A^pin beneathis_nilis now handed to Mutare's core families like any other pin (^(opts[:min] || default)can turnnil). Expect new mutants on such conditions; a plainis_nil(p.column)is unchanged.The
*↔/survivor note no longer says a zero divisor always raises: it raises on Postgres and yields NULL on SQLite and MySQL.A condition that is itself a
^pin keeps Ecto's own handling when instrumented. With Mutare's core families enabled, a root interpolation carrying mutable Elixir —where: ^[score: 5], or a computed^(if enabled?, do: [active: true], else: [])— was woven behind adynamic/2wrap like any other condition. Ecto reads a root interpolation by its value (a keyword list is a field filter, a boolean a literal condition), but insidedynamic/2the same pin is a plain parameter, so the instrumented query raisedEcto.QueryErroreven with no mutant active. Such a condition is now woven pin-only over its interior, leaving Ecto's dispatch untouched; the reported diffs are unchanged. A pin holding a bare variable (^filters) or a dynamic was never affected.A subquery in a
havingno longer breaks the instrumented build. Ecto acceptshaving: count(p.id) > subquery(…)written statically but rejects the same subquery inside a dynamichaving— and it rejects it when the query is built, not when the module compiles. Weaving such a clause therefore raised "subqueries are not allowed inhavingexpressions" on every call of the function, the unmutated baseline included. Ahaving/or_havingwhose condition carries a subquery (subquery/1,exists,all,any) now keeps its clause static: the same mutants are delivered as whole-call rebuilds.where/or_whereaccept the dynamic form and weave as before.A binding declaration the plugin could not read no longer breaks the build. Ecto accepts an interpolated binding name (
where(q, [{^name, p}], …)) and an explicit index (where(q, [{p, 0}, {c, 2}], …)); the plugin read neither, took the declaration for an absent one, and hosted the condition behinddynamic([], p.score > 10)— an unboundp, failing the single build. As afromsource (from([{p, 0}] in query, …)) the same list crashed the run outright. Both forms (and the tuple spelling[{:post, p}]) are now read and re-declared as written. A condition under a declaration still outside the grammar — a computed index, or a name that calls a function, either of which the re-declaration would evaluate a second time — gets the same mutants, delivered as rebuilds of the whole call under the declaration as written; a condition that is itself a^pin re-declares nothing, and is woven as usual.Several entries over a literal source no longer misplace its joins.
from([p, q] in Post, join: c in Comment, …)re-declared[p, q, c], placingcat a binding that does not exist; it is now tail-anchored ([p, q, ..., c]).A keyword-shorthand condition is never hosted as a predicate. Two shapes were: a shorthand written after a binding list (
where(q, [p], score: 5)), and a shorthand in afromwhose sibling is hosted (from(p in "posts", where: [score: 5], limit: 10)— also a join'son: [score: 5]). The list was wrapped indynamic/2, which rejects keyword pairs, so the instrumented build failed to compile; the weave also displaced Mutare's own^-pinned mutants of the pair values, and with the opt-inatom_literalfamily on, the column key itself was renamed. Routing and hosting now share one classification, so a shorthand's values are Mutare's to mutate, per pair, whatever else the call contains. Inside an inline subquery (exists(from c in "comments", where: [score: 5])) the pair values keep their mutants and the column key is no longer renamed.A structural name keeps its protection behind a
^pin. A literal at a structural position — the column offield/2, a cast type, an interval unit, a binding or select-alias name — is never mutated when written into the query. The same literal interpolated (field(p, ^:score),ago(^n, ^"day"),type(^v, ^:integer),selected_as(^:total), a fragment'sidentifier(^"und")) was handed to Mutare's core families as ordinary data and swapped for a sentinel: an unknown column, or a unit Ecto rejects when the query is built — a broken query under that mutant, not a test signal. An interpolated value now keeps the role of the position it fills. A literal that is the name — written directly, or as a||default or anif/case/condbranch — is left alone; the Elixir that computes a name (the condition choosing a column, a lookup key) still mutates. Conversely, the protection of a pinned keyword filter's column keys (where: ^[score: 5]) now applies only where Ecto reads a keyword list as a filter: an option list inside an ordinary interpolated value (p.score > ^lookup(n, scope: :all)) gets Mutare's usual mutants again.
0.1.1 - 2026-09-07
Fixed
- No compiler warning on Elixir 1.20. The host routing re-checked the
literal kinds
Mutare.AST.literal_value/1already guarantees, which 1.20's type inference reports as a test that always succeeds while compiling the dependency (and which failed the package's own warnings-as-errors CI). The redundant check is gone; behaviour is unchanged. - The README's install snippet names real versions (
~> 0.1for bothmutareandmutare_ecto) instead of the"~> ..."placeholders left from before publication.
0.1.0 - 2026-09-07
Initial release.
Added
- An SQL-semantics mutation catalog for the Ecto surface, independent of Mutare's Elixir-semantics built-ins: every mutation is one a real SQL engine will run, and equivalence reasoning follows SQL's three-valued logic.
- In-condition families (delivered through
^/dynamicinjection so the query still compiles once):comparison,null_predicate,connective,membership,arithmetic,coalesce,temporal,integer_literal,float_literal, the opt-instring_literal/atom_literal/boolean_literalarms,binding_reorder, andfilter_drop. - Query-shape families:
ordering,ordering_nulls,bound,join_type,combination,aggregate,clause_drop, andquery_terminal. - Repo-write and changeset families:
persistence,on_conflict,validation_drop, andhook_drop. - Both query syntaxes — the
fromkeyword form (piped too) and the composable pipe form — across direct, aliased, andimport/use-bundled call styles; schema definitions are left untouched. - Configuration per
{Mutare.Ecto, …}entry:repo:(Repo-call recognition — one module or a list),families:(:default,:all, an explicit list, or{base, except: […]}),dialects:(portable core by default;:postgres/:mysqlgate dialect-specific swaps), andas:report renaming. - Equivalence reporting: boundary/NULL-sensitive families annotate each
survivor with the specific fixture data a kill would need
(
Mutare.Ecto.equivalence_sensitive_families/0). - Structural-position safety: literals that shape the SQL (fragment templates, interval units, cast types, field/binding names) are never mutated, so these positions cannot cause the single metamutant build to fail.