Mutations on the persisting Repo writes — insert/update/delete/insert_or_update
and their ! twins. Two families, both matched by resolving the call's module to one of the
configured repo: modules (so direct, aliased, and use Ecto.Repo-defined forms all match):
:persistence— replace the write with the equivalent non-persistingEcto.Changeset.apply_action/2, surfacing untested persistence:Repo.insert(cs) → Elixir.Ecto.Changeset.apply_action( Elixir.Map.replace!(Elixir.Ecto.Changeset.change(cs), :repo, Elixir.MyApp.Repo), :insert ) Repo.insert_or_update!(cs) → Elixir.Kernel.then( Elixir.Map.replace!(Elixir.Ecto.Changeset.change(cs), :repo, Elixir.MyApp.Repo), fn changeset -> Elixir.Ecto.Changeset.apply_action!( changeset, Elixir.Kernel.if(Elixir.Ecto.get_meta(changeset.data, :state) == :loaded, do: :update, else: :insert ) ) end )The argument is normalised through
Ecto.Changeset.change/1, which is total over both shapes Repo writes accept — a bare struct (insert/delete) and a changeset (update/insert_or_update) — so the rewrite never breaks on the struct case.apply_actionfaithfully preserves the{:ok, struct}/{:error, changeset}shape (andapply_action!raisesEcto.InvalidChangesetError, exactly likeinsert!), so the mutant diverges from the real write on only the success path: it skips persistence and the DB-enforced constraints (unique_constraint,foreign_key_constraint, …) that fire only on the real call. So it survives unless a test drives a successful write and asserts a persistence consequence (a row present,id/timestamps assigned, a constraint violated) — a precise, well-defined kill condition. Excludesinsert_all/update_all(bulk, no changeset).Error-path parity is what makes that kill condition precise, so the rewrite reproduces the two pieces of metadata a real write stamps on the changeset it hands back (
Ecto.Repo.Schema'sput_repo_and_action/4), whichapply_action/2on its own does not:- the Repo — hence the
Map.replace!(…, :repo, …)stage, carrying the configuredrepo:the call resolved to (with several configured, the one this write is on). (A plain call, not%{… | repo: …}, so the one stage composes into the nested and piped forms alike.) - the action — fixed per write for
insert/update/delete, but chosen at runtime forinsert_or_update, which Ecto routes to insert or update on the changeset data's__meta__state. The mutant reads that same state, so an invalid loaded changeset still comes backaction: :update(andinsert_or_update!still raisesEcto.InvalidChangesetErrorsaying "could not perform update"). Hard-coding:insertthere made the mutant differ from the baseline on the invalid path, killing it with tests that never exercise persistence at all — see NOTES "Persistence: the rewrite restates the write's Repo and action".
With those restored, an invalid changeset yields the same
{:error, changeset}in mutant and baseline. Two residual divergences are deliberate:changeset.repo_optsstays[](the real value carries the write's options and, when the Repo enables:stacktrace, a live process stacktrace — unreproducible, and varying run to run), and Ecto's own argument guards are not restated (insert_or_updateraisesArgumentErrorfor a bare struct or a changeset in a state other than:built/:loaded, where the mutant returns a result). Both are programmer-error paths no persistence test asserts on.- the Repo — hence the
:on_conflict— swap an expliciton_conflict:atom on aninsert/insert!/insert_all(the writes that take the option) for the distinct alternative it conflicts-handles to::nothing→:raise(silent-skip → crash — a survivor means no test exercises the upsert's conflict path),:raise→:nothing(crash → silent-skip — kill with a test asserting a duplicate fails), and:replace_all→:nothing(overwrite-row → keep-old-row — kill with a test that asserts the conflicting row was actually overwritten). Every target (:raise,:nothing) is valid with noconflict_targeton all dialects, so each swap is crash-free. A swap to:raisealso drops any writtenconflict_target:pair — Ecto forbids the combination (ArgumentError, ":conflict_target option is forbidden when :on_conflict is :raise", raised in the planner before any SQL), so keeping the pair would turn the mutant into an unconditional crasher (raising on every insert, conflict or not — a trivially-killed non-mutant) instead of the intended crash-on-conflict. A swap to:nothingkeeps the pair::nothingaccepts a target, and the target still arbitrates —ON CONFLICT (email) DO NOTHINGskips only conflicts on the named constraint while a conflict on any other unique constraint raises in mutant and baseline alike, so preserving the author's arbiter preserves the semantics.:replace_allis a swap source only, never a target — the reverse needs aconflict_targeton Postgres, so a target-less swap would be a runtime crash (a trivially-killed non-mutant). Non-atomon_conflict:values (a{:replace, …}tuple, a keyword-list update, a query) are left untouched.
Pipe-aware. The :persistence rewrite is one stage chain over the value the write would
have persisted — change(), then the :repo stamp, then the apply_action call — rendered
from a single table (stages/3) in whichever form the source wrote. Piped
(cs |> Repo.insert()) the value is the |> left-hand side, so the chain ships as a
right-nested pipe stage Mutare splices onto it — cs |> (change() |> Map.replace!(…) |> apply_action(:insert)), which flattens to the intended pipe. Unpiped the same stages nest as
calls around the written argument, not as a pipe on it: |> binds tighter than most
operators, so piping an argument that is itself an operator expression would re-associate it,
where a nested call's parentheses cannot. The :on_conflict swap rebuilds the call in its
written form via Mutare.Calls, so it is pipe-position-agnostic.
Summary
Functions
RepoWrite mutations for node as :persistence/:on_conflict tags, or [].
Functions
@spec mutations(Macro.t(), Mutare.Ecto.Context.t()) :: [Mutare.Ecto.Tag.t()]
RepoWrite mutations for node as :persistence/:on_conflict tags, or [].