diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdbb9a..d20bc2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/*` no longer raise `Ecto.ConstraintError` on `_pkey` (unique_constraint) during internal audit log insert from `log_changes`; constraint is now declared on the changeset. Closes OPS-4593. + ## [1.0.3] - 2026-05-28 ### Fixed diff --git a/lib/ecto_trail/ecto_trail.ex b/lib/ecto_trail/ecto_trail.ex index 74d7075..81ef3c4 100644 --- a/lib/ecto_trail/ecto_trail.ex +++ b/lib/ecto_trail/ecto_trail.ex @@ -54,6 +54,7 @@ defmodule EctoTrail do @redacted_fields_config Application.compile_env(:ecto_trail, :redacted_fields, nil) @default_max_params 65_000 @changelog_fields [:actor_id, :resource, :resource_id, :changeset, :change_type] + @table_name Application.compile_env(:ecto_trail, :table_name, "audit_log") @not_loaded_pattern "Ecto.Association.NotLoaded" defmacro __using__(_) do @@ -572,6 +573,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, [:id | @changelog_fields]) + |> Changeset.unique_constraint(:id, name: "#{@table_name}_pkey") end end diff --git a/mix.exs b/mix.exs index c895a54..2950d3e 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule EctoTrail.Mixfile do use Mix.Project - @version "1.0.3" + @version "1.0.4" def project do [ @@ -39,7 +39,7 @@ defmodule EctoTrail.Mixfile do {:excoveralls, ">= 0.5.0", only: [:dev, :test]}, {:credo, ">= 0.5.1", only: [:dev, :test]}, {:ecto_enum, "~> 1.0"}, - {:benchee, "~> 1.0", only: [:dev, :test]} + {:benchee, "~> 1.5", only: [:dev, :test]} ] end diff --git a/test/unit/ecto_trail_test.exs b/test/unit/ecto_trail_test.exs index 1a44768..594f2e0 100644 --- a/test/unit/ecto_trail_test.exs +++ b/test/unit/ecto_trail_test.exs @@ -329,4 +329,59 @@ defmodule EctoTrailTest do ) end end + + describe "audit log pkey unique constraint" do + test "update_and_log does not raise Ecto.ConstraintError when audit log pkey collides (inside transaction)" do + {:ok, schema} = TestRepo.insert(%Resource{name: "pkey-collision"}) + + table = Application.get_env(:ecto_trail, :table_name, "audit_log") + seq_name = "#{table}_id_seq" + + # Seed two high ids so we can force the internal changelog insert to hit a pkey violation. + # We pre-insert id 987654322, then setval so the next generated id during log_changes will be exactly that. + TestRepo.query!( + "INSERT INTO \"#{table}\" (id, actor_id, resource, resource_id, changeset, change_type, inserted_at) VALUES (987654321, 'seed', 'resources', '0', $1, 'insert', now())", + [~s({})] + ) + + TestRepo.query!( + "INSERT INTO \"#{table}\" (id, actor_id, resource, resource_id, changeset, change_type, inserted_at) VALUES (987654322, 'seed2', 'resources', '0', $1, 'insert', now())", + [~s({})] + ) + + TestRepo.query!("SELECT setval($1, 987654321, true)", [seq_name]) + + # This mirrors the reported call path: update_and_log inside a transaction. + result = + TestRepo.transaction(fn -> + schema + |> Changeset.change(%{name: "after-pkey-collision"}) + |> TestRepo.update_and_log("pkey-collision-actor") + end) + + # Must not raise; the audit log insert failure is swallowed (existing behavior) but no crash to caller. + assert {:ok, %Resource{name: "after-pkey-collision"}} = result + end + + test "insert_and_log does not raise Ecto.ConstraintError when audit log pkey collides" do + table = Application.get_env(:ecto_trail, :table_name, "audit_log") + seq_name = "#{table}_id_seq" + + TestRepo.query!( + "INSERT INTO \"#{table}\" (id, actor_id, resource, resource_id, changeset, change_type, inserted_at) VALUES (987654401, 'seed', 'resources', '0', $1, 'insert', now())", + [~s({})] + ) + + TestRepo.query!( + "INSERT INTO \"#{table}\" (id, actor_id, resource, resource_id, changeset, change_type, inserted_at) VALUES (987654402, 'seed2', 'resources', '0', $1, 'insert', now())", + [~s({})] + ) + + TestRepo.query!("SELECT setval($1, 987654401, true)", [seq_name]) + + result = TestRepo.insert_and_log(%Resource{name: "insert-pkey-collision"}, "pkey-collision-actor") + + assert {:ok, %Resource{name: "insert-pkey-collision"}} = result + end + end end