Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.4] - 2026-06-13

### Fixed

- `*_and_log/*` and `log/*` now declare `unique_constraint/3` on the audit_log pkey (using configured table name) so duplicate PK inserts add a constraint error to the changeset instead of raising `Ecto.ConstraintError`. Best-effort audit logging behavior is unchanged. Closes OPS-4578.

## [1.0.3] - 2026-05-28

### Fixed
Expand Down
6 changes: 5 additions & 1 deletion lib/ecto_trail/ecto_trail.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ defmodule EctoTrail do
@default_max_params 65_000
@changelog_fields [:actor_id, :resource, :resource_id, :changeset, :change_type]
@not_loaded_pattern "Ecto.Association.NotLoaded"
@table_name Application.compile_env(:ecto_trail, :table_name, "audit_log")
@pkey_constraint_name "#{@table_name}_pkey"

defmacro __using__(_) do
quote do
Expand Down Expand Up @@ -572,6 +574,8 @@ defmodule EctoTrail do
defp map_custom_ecto_type(value), do: value

defp changelog_changeset(attrs) do
Changeset.cast(%Changelog{}, attrs, @changelog_fields)
%Changelog{}
|> Changeset.cast(attrs, @changelog_fields)
|> Changeset.unique_constraint(:id, name: @pkey_constraint_name)
end
end
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule EctoTrail.Mixfile do
use Mix.Project

@version "1.0.3"
@version "1.0.4"

def project do
[
Expand Down
44 changes: 44 additions & 0 deletions test/unit/ecto_trail_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,50 @@ defmodule EctoTrailTest do
assert [%{name: "name"}] = TestRepo.all(Resource)
assert [] == TestRepo.all(Changelog)
end

test "does not raise constraint error and succeeds when audit_log pkey collides (best-effort audit)", %{
schema: schema
} do
# Force a collision on the next audit_log PK insert by pre-inserting a row at a high id
# and resetting the sequence so the generated insert attempts to reuse it.
table = Application.get_env(:ecto_trail, :table_name, "audit_log")
seq_name = "#{table}_id_seq"

alias EctoTrail.Changelog

high_id = 2_000_000
now = DateTime.utc_now() |> DateTime.truncate(:second)

sentinel = %Changelog{
id: high_id,
actor_id: "seed",
resource: "resources",
resource_id: "0",
changeset: %{},
change_type: :insert,
inserted_at: now
}

TestRepo.insert!(sentinel)

# Set sequence so the *next* value produced is `high_id`; the audit insert in update_and_log will collide on pkey.
Ecto.Adapters.SQL.query!(TestRepo, "SELECT setval($1, $2, true)", [seq_name, high_id - 1])

result =
schema
|> Changeset.change(%{name: "collision-test"})
|> TestRepo.update_and_log("constraint-actor")

# The update must succeed; the audit log attempt will hit unique violation on pkey,
# be turned into a changeset error (thanks to unique_constraint/3), logged, and swallowed per best-effort contract.
assert {:ok, %Resource{name: "collision-test"}} = result

# Sentinel row is still the only one for that seed; colliding insert did not create a second row.
assert TestRepo.aggregate(
from(c in Changelog, where: c.resource_id == "0" and c.actor_id == "seed"),
:count
) == 1
end
end

describe "upsert_and_log/3" do
Expand Down