Skip to content

Commit 47afba5

Browse files
authored
feat: add touch_update_defaults? option to bulk_create (#2590)
Allows callers to skip updating update_default fields (like updated_at) when a bulk_create upsert results in an update. Supported in ETS and Mnesia data layers. - respect explicitly set update_default fields in upsert When touch_update_defaults? is false, update_default fields that were explicitly included in upsert_fields or set by the user on the changeset are now preserved instead of being stripped. Also wires touch_update_defaults? through Ash.create! for non-bulk upserts via a new upsert/5 data layer callback. - communicate touch_update_defaults? via changeset context instead of data layer opts
1 parent aa5ce8c commit 47afba5

File tree

8 files changed

+404
-11
lines changed

8 files changed

+404
-11
lines changed

lib/ash.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,12 @@ defmodule Ash do
365365
type: :any,
366366
doc:
367367
"An expression to check if the record should be updated when there's a conflict."
368+
],
369+
touch_update_defaults?: [
370+
type: :boolean,
371+
default: true,
372+
doc:
373+
"Whether or not to apply update defaults (like `updated_at` timestamps) on upsert. Only relevant when `upsert?: true` is set. Set to `false` to skip touching update_default fields when an upsert results in an update."
368374
]
369375
]
370376
|> Spark.Options.merge(@global_opts, "Global Options")
@@ -679,6 +685,12 @@ defmodule Ash do
679685
type: :any,
680686
doc:
681687
"An expression to check if the record should be updated when there's a conflict."
688+
],
689+
touch_update_defaults?: [
690+
type: :boolean,
691+
default: true,
692+
doc:
693+
"Whether or not to apply update defaults (like `updated_at` timestamps) on upsert. Only relevant when `upsert?: true` is set. Set to `false` to skip touching update_default fields when an upsert results in an update."
682694
]
683695
]
684696
|> Spark.Options.merge(

lib/ash/actions/create/bulk.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,6 +1298,7 @@ defmodule Ash.Actions.Create.Bulk do
12981298
nil -> action.return_skipped_upsert?
12991299
other -> other
13001300
end,
1301+
touch_update_defaults?: Keyword.get(opts, :touch_update_defaults?, true),
13011302
tenant: Ash.ToTenant.to_tenant(opts[:tenant], resource)
13021303
}
13031304
)

lib/ash/actions/create/create.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,15 @@ defmodule Ash.Actions.Create do
167167
opts =
168168
Keyword.put(opts, :upsert_identity, upsert_identity)
169169

170+
touch_update_defaults? = Keyword.get(opts, :touch_update_defaults?, true)
171+
170172
changeset =
171173
Ash.Changeset.set_context(changeset, %{
172174
private: %{
173175
upsert?: true,
174176
upsert_identity: upsert_identity,
175-
upsert_fields: upsert_fields
177+
upsert_fields: upsert_fields,
178+
touch_update_defaults?: touch_update_defaults?
176179
}
177180
})
178181

lib/ash/data_layer/data_layer.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ defmodule Ash.DataLayer do
203203
| :replace_all
204204
| {:replace, list(atom)}
205205
| {:replace_all_except, list(atom)},
206+
touch_update_defaults?: boolean,
206207
tenant: term()
207208
}
208209

lib/ash/data_layer/ets/ets.ex

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,13 +1441,17 @@ defmodule Ash.DataLayer.Ets do
14411441

14421442
@doc false
14431443
@impl true
1444-
def upsert(resource, changeset, keys, identity, opts \\ [from_bulk_create?: false]) do
1444+
def upsert(resource, changeset, keys, identity \\ nil) do
1445+
do_upsert(resource, changeset, keys, identity)
1446+
end
1447+
1448+
defp do_upsert(resource, changeset, keys, identity, from_bulk_create? \\ false) do
14451449
pkey = Ash.Resource.Info.primary_key(resource)
14461450
keys = keys || pkey
14471451

14481452
if (is_nil(identity) || !identity.nils_distinct?) &&
14491453
Enum.any?(keys, &is_nil(Ash.Changeset.get_attribute(changeset, &1))) do
1450-
create(resource, changeset, opts[:from_bulk_create?])
1454+
create(resource, changeset, from_bulk_create?)
14511455
else
14521456
key_filters =
14531457
Enum.map(keys, fn key ->
@@ -1474,7 +1478,10 @@ defmodule Ash.DataLayer.Ets do
14741478
end
14751479
end)
14761480

1477-
to_set = Ash.Changeset.set_on_upsert(changeset, keys)
1481+
to_set =
1482+
changeset
1483+
|> Ash.Changeset.set_on_upsert(keys)
1484+
|> apply_upsert_update_defaults(resource, changeset)
14781485

14791486
resource
14801487
|> resource_to_query(changeset.domain)
@@ -1483,7 +1490,7 @@ defmodule Ash.DataLayer.Ets do
14831490
|> run_query(resource)
14841491
|> case do
14851492
{:ok, []} ->
1486-
create(resource, changeset, opts[:from_bulk_create?])
1493+
create(resource, changeset, from_bulk_create?)
14871494

14881495
{:ok, [result]} ->
14891496
with {:ok, conflicting_upsert_values} <- Ash.Changeset.apply_attributes(changeset),
@@ -1503,7 +1510,7 @@ defmodule Ash.DataLayer.Ets do
15031510
resource,
15041511
%{changeset | action_type: :update, filter: nil},
15051512
Map.take(result, pkey),
1506-
opts[:from_bulk_create?]
1513+
from_bulk_create?
15071514
)
15081515
else
15091516
{:ok, []} ->
@@ -1519,6 +1526,49 @@ defmodule Ash.DataLayer.Ets do
15191526
end
15201527
end
15211528

1529+
defp apply_upsert_update_defaults(to_set, resource, changeset) do
1530+
touch_update_defaults? =
1531+
changeset.context[:private][:touch_update_defaults?]
1532+
1533+
update_default_attrs =
1534+
resource
1535+
|> Ash.Resource.Info.attributes()
1536+
|> Enum.filter(& &1.update_default)
1537+
1538+
if touch_update_defaults? == false || to_set == [] do
1539+
upsert_fields = changeset.context[:private][:upsert_fields]
1540+
update_default_names = MapSet.new(update_default_attrs, & &1.name)
1541+
1542+
Keyword.reject(to_set, fn {key, _} ->
1543+
MapSet.member?(update_default_names, key) &&
1544+
!explicitly_set?(key, upsert_fields, changeset)
1545+
end)
1546+
else
1547+
# Add update_defaults that aren't already in to_set
1548+
# (set_on_upsert's upsert_fields branch doesn't include them)
1549+
Enum.reduce(update_default_attrs, to_set, fn attr, acc ->
1550+
if Keyword.has_key?(acc, attr.name) do
1551+
acc
1552+
else
1553+
value =
1554+
case attr.update_default do
1555+
function when is_function(function) -> function.()
1556+
{m, f, a} when is_atom(m) and is_atom(f) and is_list(a) -> apply(m, f, a)
1557+
value -> value
1558+
end
1559+
1560+
Keyword.put(acc, attr.name, value)
1561+
end
1562+
end)
1563+
end
1564+
end
1565+
1566+
defp explicitly_set?(key, upsert_fields, _changeset) when is_list(upsert_fields),
1567+
do: key in upsert_fields
1568+
1569+
defp explicitly_set?(key, _, changeset),
1570+
do: Map.has_key?(changeset.attributes, key) && key not in Map.get(changeset, :defaults, [])
1571+
15221572
@spec upsert_conflict_check(
15231573
changeset :: Ash.Changeset.t(),
15241574
subject :: record,
@@ -1561,15 +1611,18 @@ defmodule Ash.DataLayer.Ets do
15611611
|> Enum.reduce_while({:ok, []}, fn changeset, {:ok, results} ->
15621612
changeset =
15631613
Ash.Changeset.set_context(changeset, %{
1564-
private: %{upsert_fields: options[:upsert_fields] || []}
1614+
private: %{
1615+
upsert_fields: options[:upsert_fields] || [],
1616+
touch_update_defaults?: Map.get(options, :touch_update_defaults?, true)
1617+
}
15651618
})
15661619

1567-
case upsert(
1620+
case do_upsert(
15681621
resource,
15691622
changeset,
15701623
options.upsert_keys,
15711624
options.identity,
1572-
Map.put(options, :from_bulk_create?, true)
1625+
true
15731626
) do
15741627
{:ok, result} ->
15751628
if Ash.Resource.get_metadata(result, :upsert_skipped) do

lib/ash/data_layer/mnesia/mnesia.ex

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,8 @@ defmodule Ash.DataLayer.Mnesia do
353353
Ash.Changeset.set_context(changeset, %{
354354
private:
355355
Map.merge(changeset.context[:private] || %{}, %{
356-
upsert_fields: options[:upsert_fields] || []
356+
upsert_fields: options[:upsert_fields] || [],
357+
touch_update_defaults?: Map.get(options, :touch_update_defaults?, true)
357358
})
358359
})
359360

@@ -636,7 +637,10 @@ defmodule Ash.DataLayer.Mnesia do
636637
create(resource, changeset)
637638

638639
{:ok, [result]} ->
639-
to_set = Ash.Changeset.set_on_upsert(changeset, keys)
640+
to_set =
641+
changeset
642+
|> Ash.Changeset.set_on_upsert(keys)
643+
|> apply_upsert_update_defaults(resource, result, changeset)
640644

641645
changeset =
642646
changeset
@@ -652,6 +656,40 @@ defmodule Ash.DataLayer.Mnesia do
652656
end
653657
end
654658

659+
# Mnesia's update/2 calls apply_attributes which re-applies update_defaults
660+
# via set_defaults/3. To prevent unwanted updates, we preserve existing
661+
# values from the record for update_default fields so set_defaults sees
662+
# them as already set and skips them.
663+
defp apply_upsert_update_defaults(to_set, resource, existing_record, changeset) do
664+
touch_update_defaults? =
665+
changeset.context[:private][:touch_update_defaults?]
666+
667+
if touch_update_defaults? == false || to_set == [] do
668+
upsert_fields = changeset.context[:private][:upsert_fields]
669+
670+
update_default_attrs =
671+
resource
672+
|> Ash.Resource.Info.attributes()
673+
|> Enum.filter(& &1.update_default)
674+
675+
Enum.reduce(update_default_attrs, to_set, fn attr, acc ->
676+
if explicitly_set?(attr.name, upsert_fields, changeset) do
677+
acc
678+
else
679+
Keyword.put(acc, attr.name, Map.get(existing_record, attr.name))
680+
end
681+
end)
682+
else
683+
to_set
684+
end
685+
end
686+
687+
defp explicitly_set?(key, upsert_fields, _changeset) when is_list(upsert_fields),
688+
do: key in upsert_fields
689+
690+
defp explicitly_set?(key, _, changeset),
691+
do: Map.has_key?(changeset.attributes, key) && key not in Map.get(changeset, :defaults, [])
692+
655693
@doc false
656694
@impl true
657695
def transaction(_, func, _timeout, _reason) do

test/ash/data_layer/ets_test.exs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ defmodule Ash.DataLayer.EtsTest do
4545
attribute :age, :integer, public?: true
4646
attribute :title, :string, public?: true
4747
attribute :roles, {:array, :atom}, public?: true
48+
create_timestamp :inserted_at
49+
update_timestamp :updated_at, writable?: true, public?: true
4850
end
4951
end
5052

@@ -138,6 +140,146 @@ defmodule Ash.DataLayer.EtsTest do
138140
user_table()
139141
end
140142

143+
test "upsert with empty upsert_fields does not update update_timestamp" do
144+
past = DateTime.add(DateTime.utc_now(), -60, :second)
145+
146+
%EtsTestUser{id: id} = create_user(%{name: "Mike", updated_at: past})
147+
148+
updated =
149+
create_user(%{name: "Mike Updated", id: id},
150+
upsert?: true,
151+
upsert_fields: []
152+
)
153+
154+
assert updated.id == id
155+
assert updated.name == "Mike"
156+
assert DateTime.compare(updated.updated_at, past) == :eq
157+
end
158+
159+
test "upsert does not update update_timestamp when touch_update_defaults? is false" do
160+
past = DateTime.add(DateTime.utc_now(), -60, :second)
161+
162+
%EtsTestUser{id: id} = create_user(%{name: "Mike", updated_at: past})
163+
164+
updated =
165+
create_user(%{name: "Mike Updated", id: id},
166+
upsert?: true,
167+
touch_update_defaults?: false
168+
)
169+
170+
assert updated.id == id
171+
assert updated.name == "Mike Updated"
172+
assert DateTime.compare(updated.updated_at, past) == :eq
173+
end
174+
175+
test "upsert preserves explicitly set update_default fields when touch_update_defaults? is false" do
176+
past = DateTime.add(DateTime.utc_now(), -120, :second)
177+
explicit_time = DateTime.add(DateTime.utc_now(), -30, :second)
178+
179+
%EtsTestUser{id: id} = create_user(%{name: "Mike", updated_at: past})
180+
181+
updated =
182+
create_user(%{name: "Mike Updated", id: id, updated_at: explicit_time},
183+
upsert?: true,
184+
touch_update_defaults?: false
185+
)
186+
187+
assert updated.id == id
188+
assert updated.name == "Mike Updated"
189+
assert DateTime.compare(updated.updated_at, explicit_time) == :eq
190+
end
191+
192+
test "bulk_create with upsert updates update_timestamp" do
193+
past = DateTime.add(DateTime.utc_now(), -60, :second)
194+
195+
%EtsTestUser{id: id} = create_user(%{name: "Mike", updated_at: past})
196+
197+
assert [{_, %EtsTestUser{updated_at: stored}}] = user_table()
198+
assert DateTime.compare(stored, past) == :eq
199+
200+
result =
201+
Ash.bulk_create!(
202+
[%{name: "Mike Updated", id: id}],
203+
EtsTestUser,
204+
:create,
205+
upsert?: true,
206+
upsert_fields: [:name],
207+
return_records?: true
208+
)
209+
210+
assert [%EtsTestUser{id: ^id, name: "Mike Updated", updated_at: new_updated_at}] =
211+
result.records
212+
213+
assert DateTime.after?(new_updated_at, past)
214+
end
215+
216+
test "bulk_create with empty upsert does not update update_timestamp" do
217+
past = DateTime.add(DateTime.utc_now(), -60, :second)
218+
219+
%EtsTestUser{id: id} = create_user(%{name: "Mike", updated_at: past})
220+
221+
result =
222+
Ash.bulk_create!(
223+
[%{name: "Mike Updated", id: id}],
224+
EtsTestUser,
225+
:create,
226+
upsert?: true,
227+
upsert_fields: [],
228+
return_records?: true
229+
)
230+
231+
assert [%EtsTestUser{id: ^id, updated_at: new_updated_at}] = result.records
232+
assert DateTime.compare(new_updated_at, past) == :eq
233+
end
234+
235+
test "bulk_create with upsert does not update update_timestamp when touch_update_defaults? is false" do
236+
past = DateTime.add(DateTime.utc_now(), -60, :second)
237+
238+
%EtsTestUser{id: id} = create_user(%{name: "Mike", updated_at: past})
239+
240+
assert [{_, %EtsTestUser{updated_at: stored}}] = user_table()
241+
assert DateTime.compare(stored, past) == :eq
242+
243+
result =
244+
Ash.bulk_create!(
245+
[%{name: "Mike Updated", id: id}],
246+
EtsTestUser,
247+
:create,
248+
upsert?: true,
249+
upsert_fields: [:name],
250+
touch_update_defaults?: false,
251+
return_records?: true
252+
)
253+
254+
assert [%EtsTestUser{id: ^id, name: "Mike Updated", updated_at: new_updated_at}] =
255+
result.records
256+
257+
assert DateTime.compare(new_updated_at, past) == :eq
258+
end
259+
260+
test "bulk_create with upsert preserves explicitly set update_default fields when touch_update_defaults? is false" do
261+
past = DateTime.add(DateTime.utc_now(), -120, :second)
262+
explicit_time = DateTime.add(DateTime.utc_now(), -30, :second)
263+
264+
%EtsTestUser{id: id} = create_user(%{name: "Mike", updated_at: past})
265+
266+
result =
267+
Ash.bulk_create!(
268+
[%{name: "Mike Updated", id: id, updated_at: explicit_time}],
269+
EtsTestUser,
270+
:create,
271+
upsert?: true,
272+
upsert_fields: [:name, :updated_at],
273+
touch_update_defaults?: false,
274+
return_records?: true
275+
)
276+
277+
assert [%EtsTestUser{id: ^id, name: "Mike Updated", updated_at: new_updated_at}] =
278+
result.records
279+
280+
assert DateTime.compare(new_updated_at, explicit_time) == :eq
281+
end
282+
141283
test "destroy" do
142284
mike = create_user(%{name: "Mike"})
143285
%EtsTestUser{id: joes_id} = joe = create_user(%{name: "Joe"})

0 commit comments

Comments
 (0)