From dee39ec224e030e072ce9be7855ff67d7c5638ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=A4rtschi?= Date: Fri, 20 Feb 2026 09:17:03 +0100 Subject: [PATCH 1/7] fix: bulk_create with upsert now updates update_timestamp fields on conflict update_timestamp attributes (e.g. updated_at) were never included in the ON CONFLICT DO UPDATE SET clause because they have writable?: false and thus never appear in changeset.attributes. This ensures fields with update_defaults are always included when an upsert modifies fields. Closes ash-project/ash_postgres#696 --- lib/data_layer.ex | 15 ++++++- test/bulk_create_test.exs | 86 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/lib/data_layer.ex b/lib/data_layer.ex index fa84faec..4b79d9fd 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -2375,8 +2375,19 @@ defmodule AshPostgres.DataLayer do fields_to_upsert = case fields_to_upsert do - [] -> keys - fields_to_upsert -> fields_to_upsert + [] -> + keys + + fields_to_upsert -> + # Include fields with update_defaults (e.g. update_timestamp) + # even if they aren't in the changeset attributes or upsert_fields. + # These fields should always be refreshed when an upsert modifies fields. + update_default_fields = + update_defaults + |> Keyword.keys() + |> Enum.reject(&(&1 in fields_to_upsert or &1 in keys)) + + fields_to_upsert ++ update_default_fields end fields_to_upsert diff --git a/test/bulk_create_test.exs b/test/bulk_create_test.exs index 240033a1..694e9418 100644 --- a/test/bulk_create_test.exs +++ b/test/bulk_create_test.exs @@ -103,6 +103,92 @@ defmodule AshPostgres.BulkCreateTest do end) end + test "bulk creates with upsert updates update_timestamp" do + past = DateTime.add(DateTime.utc_now(), -60, :second) + + assert [ + {:ok, %{title: "fred", uniq_one: "one", uniq_two: "two"} = initial} + ] = + Ash.bulk_create!( + [ + %{ + title: "fred", + uniq_one: "one", + uniq_two: "two", + price: 10, + updated_at: past + } + ], + Post, + :create, + return_stream?: true, + return_records?: true + ) + |> Enum.to_list() + + assert DateTime.compare(initial.updated_at, past) == :eq + + assert [ + {:ok, %{title: "fred", uniq_one: "one", uniq_two: "two", price: 1000} = upserted} + ] = + Ash.bulk_create!( + [%{title: "something", uniq_one: "one", uniq_two: "two", price: 1000}], + Post, + :create, + upsert?: true, + upsert_identity: :uniq_one_and_two, + upsert_fields: [:price], + return_stream?: true, + return_errors?: true, + return_records?: true + ) + |> Enum.to_list() + + assert DateTime.after?(upserted.updated_at, initial.updated_at) + end + + test "bulk creates with empty upsert does not update update_timestamp" do + past = DateTime.add(DateTime.utc_now(), -60, :second) + + assert [ + {:ok, %{title: "fred", uniq_one: "one", uniq_two: "two"} = initial} + ] = + Ash.bulk_create!( + [ + %{ + title: "fred", + uniq_one: "one", + uniq_two: "two", + price: 10, + updated_at: past + } + ], + Post, + :create, + return_stream?: true, + return_records?: true + ) + |> Enum.to_list() + + assert [ + {:ok, %{title: "fred"} = upserted} + ] = + Ash.bulk_create!( + [%{title: "something", uniq_one: "one", uniq_two: "two", price: 1000}], + Post, + :create, + upsert?: true, + upsert_identity: :uniq_one_and_two, + upsert_fields: [], + return_stream?: true, + return_errors?: true, + return_records?: true + ) + |> Enum.to_list() + + assert DateTime.compare(upserted.updated_at, initial.updated_at) == :eq + end + test "bulk upsert skips with filter" do assert [ {:ok, %{title: "fredfoo", uniq_if_contains_foo: "1foo", price: 10}}, From 06a068067b0cfc44e3d615532e5c216a9119358d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=A4rtschi?= Date: Fri, 20 Feb 2026 09:41:03 +0100 Subject: [PATCH 2/7] feat: support touch_update_defaults? option to skip update_default fields on upsert --- lib/data_layer.ex | 18 +++++++++++----- test/bulk_create_test.exs | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 4b79d9fd..e4c36865 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -2382,12 +2382,20 @@ defmodule AshPostgres.DataLayer do # Include fields with update_defaults (e.g. update_timestamp) # even if they aren't in the changeset attributes or upsert_fields. # These fields should always be refreshed when an upsert modifies fields. - update_default_fields = - update_defaults - |> Keyword.keys() - |> Enum.reject(&(&1 in fields_to_upsert or &1 in keys)) + # Can be disabled via context: %{data_layer: %{touch_update_defaults?: false}} + touch_update_defaults? = + Enum.at(changesets, 0).context[:data_layer][:touch_update_defaults?] != false - fields_to_upsert ++ update_default_fields + if touch_update_defaults? do + update_default_fields = + update_defaults + |> Keyword.keys() + |> Enum.reject(&(&1 in fields_to_upsert or &1 in keys)) + + fields_to_upsert ++ update_default_fields + else + fields_to_upsert + end end fields_to_upsert diff --git a/test/bulk_create_test.exs b/test/bulk_create_test.exs index 694e9418..1d19a48d 100644 --- a/test/bulk_create_test.exs +++ b/test/bulk_create_test.exs @@ -189,6 +189,51 @@ defmodule AshPostgres.BulkCreateTest do assert DateTime.compare(upserted.updated_at, initial.updated_at) == :eq end + test "bulk creates with upsert does not update update_timestamp when touch_update_defaults? is false" do + past = DateTime.add(DateTime.utc_now(), -60, :second) + + assert [ + {:ok, %{title: "fred", uniq_one: "one", uniq_two: "two"} = initial} + ] = + Ash.bulk_create!( + [ + %{ + title: "fred", + uniq_one: "one", + uniq_two: "two", + price: 10, + updated_at: past + } + ], + Post, + :create, + return_stream?: true, + return_records?: true + ) + |> Enum.to_list() + + assert DateTime.compare(initial.updated_at, past) == :eq + + assert [ + {:ok, %{title: "fred", uniq_one: "one", uniq_two: "two", price: 1000} = upserted} + ] = + Ash.bulk_create!( + [%{title: "something", uniq_one: "one", uniq_two: "two", price: 1000}], + Post, + :create, + upsert?: true, + upsert_identity: :uniq_one_and_two, + upsert_fields: [:price], + context: %{data_layer: %{touch_update_defaults?: false}}, + return_stream?: true, + return_errors?: true, + return_records?: true + ) + |> Enum.to_list() + + assert DateTime.compare(upserted.updated_at, initial.updated_at) == :eq + end + test "bulk upsert skips with filter" do assert [ {:ok, %{title: "fredfoo", uniq_if_contains_foo: "1foo", price: 10}}, From ebc84980b4db8ca81d7e766d2fa809136172b47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=A4rtschi?= Date: Wed, 25 Feb 2026 12:41:51 +0100 Subject: [PATCH 3/7] refactor: read touch_update_defaults? from options instead of changeset context Read the touch_update_defaults? flag from the bulk_create options map rather than from changeset context. Depends on ash-project/ash#2590 which adds touch_update_defaults? to the options passed to data layers. Only merge once that PR is merged and released. --- lib/data_layer.ex | 5 ++--- test/bulk_create_test.exs | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/data_layer.ex b/lib/data_layer.ex index e4c36865..162e3bf1 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -2382,9 +2382,8 @@ defmodule AshPostgres.DataLayer do # Include fields with update_defaults (e.g. update_timestamp) # even if they aren't in the changeset attributes or upsert_fields. # These fields should always be refreshed when an upsert modifies fields. - # Can be disabled via context: %{data_layer: %{touch_update_defaults?: false}} - touch_update_defaults? = - Enum.at(changesets, 0).context[:data_layer][:touch_update_defaults?] != false + # Can be disabled via touch_update_defaults?: false option + touch_update_defaults? = Map.get(options, :touch_update_defaults?, true) if touch_update_defaults? do update_default_fields = diff --git a/test/bulk_create_test.exs b/test/bulk_create_test.exs index 1d19a48d..883b2af8 100644 --- a/test/bulk_create_test.exs +++ b/test/bulk_create_test.exs @@ -224,7 +224,7 @@ defmodule AshPostgres.BulkCreateTest do upsert?: true, upsert_identity: :uniq_one_and_two, upsert_fields: [:price], - context: %{data_layer: %{touch_update_defaults?: false}}, + touch_update_defaults?: false, return_stream?: true, return_errors?: true, return_records?: true From e7f99859e65d9d9937a58f06096e5a5fb5293cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=A4rtschi?= Date: Wed, 25 Feb 2026 16:04:28 +0100 Subject: [PATCH 4/7] feat: support touch_update_defaults? option in non-bulk upsert path --- lib/data_layer.ex | 12 ++++++++-- test/upsert_test.exs | 52 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 162e3bf1..474aac05 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -3206,18 +3206,25 @@ defmodule AshPostgres.DataLayer do end @impl true - def upsert(resource, changeset, keys, identity) do + def upsert(resource, changeset, keys, identity \\ nil, opts \\ []) do if AshPostgres.DataLayer.Info.manage_tenant_update?(resource) do {:error, "Cannot currently upsert a resource that owns a tenant"} else keys = keys || Ash.Resource.Info.primary_key(keys) + touch_update_defaults? = Keyword.get(opts, :touch_update_defaults?, true) update_defaults = update_defaults(resource) explicitly_changing_attributes = changeset.attributes |> Map.keys() - |> Enum.concat(Keyword.keys(update_defaults)) + |> then(fn attrs -> + if touch_update_defaults? do + Enum.concat(attrs, Keyword.keys(update_defaults)) + else + attrs + end + end) |> Kernel.--(Map.get(changeset, :defaults, [])) |> Kernel.--(keys) @@ -3232,6 +3239,7 @@ defmodule AshPostgres.DataLayer do upsert_keys: keys, action_select: changeset.action_select, upsert_fields: upsert_fields, + touch_update_defaults?: touch_update_defaults?, return_records?: true }) do {:ok, []} -> diff --git a/test/upsert_test.exs b/test/upsert_test.exs index 4337e2d7..b639090f 100644 --- a/test/upsert_test.exs +++ b/test/upsert_test.exs @@ -93,4 +93,56 @@ defmodule AshPostgres.Test.UpsertTest do assert updated_post.id == id assert Decimal.equal?(updated_post.decimal, Decimal.new(5)) end + + test "upsert with touch_update_defaults? false does not update updated_at" do + id = Ash.UUID.generate() + past = DateTime.add(DateTime.utc_now(), -60, :second) + + Post + |> Ash.Changeset.for_create(:create, %{ + id: id, + title: "title", + updated_at: past + }) + |> Ash.create!() + + assert [%{updated_at: backdated}] = Ash.read!(Post) + assert DateTime.compare(backdated, past) == :eq + + upserted = + Post + |> Ash.Changeset.for_create(:create, %{ + id: id, + title: "title2" + }) + |> Ash.create!(upsert?: true, touch_update_defaults?: false) + + assert DateTime.compare(upserted.updated_at, past) == :eq + end + + test "upsert with empty upsert_fields does not update updated_at" do + id = Ash.UUID.generate() + past = DateTime.add(DateTime.utc_now(), -60, :second) + + Post + |> Ash.Changeset.for_create(:create, %{ + id: id, + title: "title", + updated_at: past + }) + |> Ash.create!() + + assert [%{updated_at: backdated}] = Ash.read!(Post) + assert DateTime.compare(backdated, past) == :eq + + upserted = + Post + |> Ash.Changeset.for_create(:create, %{ + id: id, + title: "title2" + }) + |> Ash.create!(upsert?: true, upsert_fields: []) + + assert DateTime.compare(upserted.updated_at, past) == :eq + end end From d575769dc5f63c483f344bae05ae756f1abd1688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=A4rtschi?= Date: Thu, 26 Feb 2026 08:51:07 +0100 Subject: [PATCH 5/7] fix: restore backwards compatibility for touch_update_defaults? changeset context --- lib/data_layer.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 474aac05..09ce9f82 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -2383,7 +2383,10 @@ defmodule AshPostgres.DataLayer do # even if they aren't in the changeset attributes or upsert_fields. # These fields should always be refreshed when an upsert modifies fields. # Can be disabled via touch_update_defaults?: false option - touch_update_defaults? = Map.get(options, :touch_update_defaults?, true) + # or via context: %{data_layer: %{touch_update_defaults?: false}} + touch_update_defaults? = + Map.get(options, :touch_update_defaults?, true) && + Enum.at(changesets, 0).context[:data_layer][:touch_update_defaults?] != false if touch_update_defaults? do update_default_fields = From 6ebc0f47fa1931e8cf2dc147539a1b78aeb1dc61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=A4rtschi?= Date: Mon, 2 Mar 2026 08:51:48 +0100 Subject: [PATCH 6/7] refactor: read touch_update_defaults? from changeset context instead of upsert opts to remove upsert/5 arity --- lib/data_layer.ex | 11 +++++++---- mix.exs | 2 +- mix.lock | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 09ce9f82..d4c4f1e9 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -2382,10 +2382,11 @@ defmodule AshPostgres.DataLayer do # Include fields with update_defaults (e.g. update_timestamp) # even if they aren't in the changeset attributes or upsert_fields. # These fields should always be refreshed when an upsert modifies fields. - # Can be disabled via touch_update_defaults?: false option - # or via context: %{data_layer: %{touch_update_defaults?: false}} + # Can be disabled via touch_update_defaults?: false in the changeset + # context (either in [:private] or [:data_layer]) or via options map touch_update_defaults? = Map.get(options, :touch_update_defaults?, true) && + Enum.at(changesets, 0).context[:private][:touch_update_defaults?] != false && Enum.at(changesets, 0).context[:data_layer][:touch_update_defaults?] != false if touch_update_defaults? do @@ -3209,13 +3210,15 @@ defmodule AshPostgres.DataLayer do end @impl true - def upsert(resource, changeset, keys, identity \\ nil, opts \\ []) do + def upsert(resource, changeset, keys, identity \\ nil) do if AshPostgres.DataLayer.Info.manage_tenant_update?(resource) do {:error, "Cannot currently upsert a resource that owns a tenant"} else keys = keys || Ash.Resource.Info.primary_key(keys) - touch_update_defaults? = Keyword.get(opts, :touch_update_defaults?, true) + touch_update_defaults? = + changeset.context[:private][:touch_update_defaults?] != false + update_defaults = update_defaults(resource) explicitly_changing_attributes = diff --git a/mix.exs b/mix.exs index be86f42c..4bf15445 100644 --- a/mix.exs +++ b/mix.exs @@ -185,7 +185,7 @@ defmodule AshPostgres.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, ash_version("~> 3.15")}, + {:ash, ash_version("~> 3.19")}, {:spark, "~> 2.3 and >= 2.3.4"}, {:ash_sql, ash_sql_version("~> 0.4 and >= 0.4.3")}, {:igniter, "~> 0.6 and >= 0.6.29", optional: true}, diff --git a/mix.lock b/mix.lock index 0e457a50..f83bd51c 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{ - "ash": {:hex, :ash, "3.17.1", "ae728e44421b9d977698eba056f02cbf0f73864fc8e027dfdec3059ab44d6fa5", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c8646ce07c5bf732caea3972c848489204c6fa438a7cf2857a2bdaa869a73244"}, + "ash": {:hex, :ash, "3.19.1", "b5e933547d948e44d27adaed5737195488292fc2066e7fe60dd3ac83a0c4e54f", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "697ac3e4fc6080cb03b1e4ee9088cb8a313a5299686ba1aa91efc86ec4028b6e"}, "ash_sql": {:hex, :ash_sql, "0.4.5", "30030675ce995570fcedccd3c0671d85beff03cc0c480e7da5002842dccf0277", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "131e06e13ebcf06fc8d050267a5b29f6cc8ef6a781712e61a456f17726a64ea5"}, "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, From 9882a9e293dd4d0ab524e8955678f0a4c3d7724d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=A4rtschi?= Date: Mon, 2 Mar 2026 08:56:48 +0100 Subject: [PATCH 7/7] refactor(data_layer): revert identity changes to upser/4 --- lib/data_layer.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 53b3a5be..652ed117 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -3225,7 +3225,7 @@ defmodule AshPostgres.DataLayer do end @impl true - def upsert(resource, changeset, keys, identity \\ nil) do + def upsert(resource, changeset, keys, identity) do if AshPostgres.DataLayer.Info.manage_tenant_update?(resource) do {:error, "Cannot currently upsert a resource that owns a tenant"} else