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/*` no longer raise `Ecto.ConstraintError` on `<table>_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
Expand Down
5 changes: 4 additions & 1 deletion lib/ecto_trail/ecto_trail.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions 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 Expand Up @@ -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

Expand Down
55 changes: 55 additions & 0 deletions test/unit/ecto_trail_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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