Mutare.Ecto.RepoWrite (mutare_ecto v0.1.0)

Copy Markdown View Source

Mutations on the persisting Repo writesinsert/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-persisting Ecto.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_action faithfully preserves the {:ok, struct} / {:error, changeset} shape (and apply_action! raises Ecto.InvalidChangesetError, exactly like insert!), 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. Excludes insert_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's put_repo_and_action/4), which apply_action/2 on its own does not:

    • the Repo — hence the Map.replace!(…, :repo, …) stage, carrying the configured repo: 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 for insert_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 back action: :update (and insert_or_update! still raises Ecto.InvalidChangesetError saying "could not perform update"). Hard-coding :insert there 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_opts stays [] (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_update raises ArgumentError for 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.

  • :on_conflict — swap an explicit on_conflict: atom on an insert/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 no conflict_target on all dialects, so each swap is crash-free. A swap to :raise also drops any written conflict_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 :nothing keeps the pair: :nothing accepts a target, and the target still arbitratesON CONFLICT (email) DO NOTHING skips 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_all is a swap source only, never a target — the reverse needs a conflict_target on Postgres, so a target-less swap would be a runtime crash (a trivially-killed non-mutant). Non-atom on_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

mutations(node, context)

@spec mutations(Macro.t(), Mutare.Ecto.Context.t()) :: [Mutare.Ecto.Tag.t()]

RepoWrite mutations for node as :persistence/:on_conflict tags, or [].