Skip to content

Commit 21dd0b2

Browse files
authored
fix: bulk_create with upsert now updates update_timestamp fields (#207)
Ports ash-project/ash_postgres#697 to ash_sqlite. update_timestamp attributes (e.g. updated_at) were never included in the ON CONFLICT DO UPDATE SET clause because they have writable?: false. Now fields with update_defaults are always included when an upsert modifies fields. Can be disabled via context: %{data_layer: %{touch_update_defaults?: false}}
1 parent 9d47357 commit 21dd0b2

5 files changed

Lines changed: 159 additions & 17 deletions

File tree

documentation/dsls/DSL-AshSqlite.DataLayer.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ end
3535

3636
| Name | Type | Default | Docs |
3737
|------|------|---------|------|
38-
| [`repo`](#sqlite-repo){: #sqlite-repo .spark-required} | `atom` | | The repo that will be used to fetch your data. See the `AshSqlite.Repo` documentation for more |
38+
| [`repo`](#sqlite-repo){: #sqlite-repo .spark-required} | `module \| (any, any -> any)` | | The repo that will be used to fetch your data. See the `AshSqlite.Repo` documentation for more. Can also be a function that takes a resource and a type `:read \| :mutate` and returns the repo. |
3939
| [`migrate?`](#sqlite-migrate?){: #sqlite-migrate? } | `boolean` | `true` | Whether or not to include this resource in the generated migrations with `mix ash.generate_migrations` |
4040
| [`migration_types`](#sqlite-migration_types){: #sqlite-migration_types } | `keyword` | `[]` | A keyword list of attribute names to the ecto migration type that should be used for that attribute. Only necessary if you need to override the defaults. |
4141
| [`migration_defaults`](#sqlite-migration_defaults){: #sqlite-migration_defaults } | `keyword` | `[]` | A keyword list of attribute names to the ecto migration default that should be used for that attribute. The string you use will be placed verbatim in the migration. Use fragments like `fragment(\\"now()\\")`, or for `nil`, use `\\"nil\\"`. |

lib/data_layer.ex

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,8 +804,27 @@ defmodule AshSqlite.DataLayer do
804804

805805
fields_to_upsert =
806806
case fields_to_upsert do
807-
[] -> keys
808-
fields_to_upsert -> fields_to_upsert
807+
[] ->
808+
keys
809+
810+
fields_to_upsert ->
811+
# Include fields with update_defaults (e.g. update_timestamp)
812+
# even if they aren't in the changeset attributes or upsert_fields.
813+
# These fields should always be refreshed when an upsert modifies fields.
814+
# Can be disabled via context: %{data_layer: %{touch_update_defaults?: false}}
815+
touch_update_defaults? =
816+
Enum.at(changesets, 0).context[:data_layer][:touch_update_defaults?] != false
817+
818+
if touch_update_defaults? do
819+
update_default_fields =
820+
update_defaults
821+
|> Keyword.keys()
822+
|> Enum.reject(&(&1 in fields_to_upsert or &1 in keys))
823+
824+
fields_to_upsert ++ update_default_fields
825+
else
826+
fields_to_upsert
827+
end
809828
end
810829

811830
fields_to_upsert

lib/migration_generator/migration_generator.ex

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -467,11 +467,13 @@ defmodule AshSqlite.MigrationGenerator do
467467
defp load_migration!({version, _, file}) when is_binary(file) do
468468
loaded_modules = file |> compile_file() |> Enum.map(&elem(&1, 0))
469469

470-
if mod = Enum.find(loaded_modules, &migration?/1) do
471-
{version, mod}
472-
else
473-
raise Ecto.MigrationError,
474-
"file #{Path.relative_to_cwd(file)} does not define an Ecto.Migration"
470+
case Enum.find(loaded_modules, &migration?/1) do
471+
nil ->
472+
raise Ecto.MigrationError,
473+
"file #{Path.relative_to_cwd(file)} does not define an Ecto.Migration"
474+
475+
mod ->
476+
{version, mod}
475477
end
476478
end
477479

@@ -834,13 +836,15 @@ defmodule AshSqlite.MigrationGenerator do
834836
config = repo.config()
835837
app = Keyword.fetch!(config, :otp_app)
836838

837-
if path = opts.migration_path || config[:tenant_migrations_path] do
838-
path
839-
else
840-
priv =
841-
config[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}"
839+
case opts.migration_path || config[:tenant_migrations_path] do
840+
nil ->
841+
priv =
842+
config[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}"
843+
844+
Application.app_dir(app, Path.join(priv, "migrations"))
842845

843-
Application.app_dir(app, Path.join(priv, "migrations"))
846+
path ->
847+
path
844848
end
845849
end
846850

@@ -1634,7 +1638,8 @@ defmodule AshSqlite.MigrationGenerator do
16341638
identity.name == old_identity.name &&
16351639
Enum.sort(old_identity.keys) == Enum.sort(identity.keys) &&
16361640
old_identity.base_filter == identity.base_filter &&
1637-
Map.get(old_identity, :nils_distinct?, true) == Map.get(identity, :nils_distinct?, true)
1641+
Map.get(old_identity, :nils_distinct?, true) ==
1642+
Map.get(identity, :nils_distinct?, true)
16381643
end)
16391644
end)
16401645
end
@@ -1677,7 +1682,8 @@ defmodule AshSqlite.MigrationGenerator do
16771682
old_identity.name == identity.name &&
16781683
Enum.sort(old_identity.keys) == Enum.sort(identity.keys) &&
16791684
old_identity.base_filter == identity.base_filter &&
1680-
Map.get(old_identity, :nils_distinct?, true) == Map.get(identity, :nils_distinct?, true)
1685+
Map.get(old_identity, :nils_distinct?, true) ==
1686+
Map.get(identity, :nils_distinct?, true)
16811687
end)
16821688
end)
16831689
end

lib/migration_generator/operation.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,8 @@ defmodule AshSqlite.MigrationGenerator.Operation do
484484
import Helper
485485

486486
def up(%{
487-
identity: %{name: name, keys: keys, base_filter: base_filter, index_name: index_name} = identity,
487+
identity:
488+
%{name: name, keys: keys, base_filter: base_filter, index_name: index_name} = identity,
488489
table: table,
489490
multitenancy: multitenancy
490491
}) do

test/bulk_create_test.exs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,122 @@ defmodule AshSqlite.BulkCreateTest do
6868
end)
6969
end
7070

71+
test "bulk creates with upsert updates update_timestamp" do
72+
past = DateTime.to_iso8601(DateTime.add(DateTime.utc_now(), -60, :second))
73+
74+
assert [{:ok, %{title: "fred", uniq_one: "one", uniq_two: "two"} = initial}] =
75+
Ash.bulk_create!(
76+
[%{title: "fred", uniq_one: "one", uniq_two: "two", price: 10}],
77+
Post,
78+
:create,
79+
return_stream?: true,
80+
return_records?: true
81+
)
82+
|> Enum.to_list()
83+
84+
# Backdate updated_at via raw SQL so the upsert has something to compare against
85+
AshSqlite.TestRepo.query!("UPDATE posts SET updated_at = ? WHERE id = ?", [
86+
past,
87+
initial.id
88+
])
89+
90+
assert [%{updated_at: backdated}] = Ash.read!(Post)
91+
assert DateTime.compare(backdated, DateTime.from_iso8601(past) |> elem(1)) == :eq
92+
93+
assert [{:ok, %{title: "fred", price: 1000} = upserted}] =
94+
Ash.bulk_create!(
95+
[%{title: "something", uniq_one: "one", uniq_two: "two", price: 1000}],
96+
Post,
97+
:create,
98+
upsert?: true,
99+
upsert_identity: :uniq_one_and_two,
100+
upsert_fields: [:price],
101+
return_stream?: true,
102+
return_errors?: true,
103+
return_records?: true
104+
)
105+
|> Enum.to_list()
106+
107+
assert DateTime.after?(upserted.updated_at, DateTime.from_iso8601(past) |> elem(1))
108+
end
109+
110+
test "bulk creates with empty upsert does not update update_timestamp" do
111+
past = DateTime.to_iso8601(DateTime.add(DateTime.utc_now(), -60, :second))
112+
113+
assert [{:ok, %{title: "fred", uniq_one: "one", uniq_two: "two"} = initial}] =
114+
Ash.bulk_create!(
115+
[%{title: "fred", uniq_one: "one", uniq_two: "two", price: 10}],
116+
Post,
117+
:create,
118+
return_stream?: true,
119+
return_records?: true
120+
)
121+
|> Enum.to_list()
122+
123+
AshSqlite.TestRepo.query!("UPDATE posts SET updated_at = ? WHERE id = ?", [
124+
past,
125+
initial.id
126+
])
127+
128+
assert [%{updated_at: backdated}] = Ash.read!(Post)
129+
assert DateTime.compare(backdated, DateTime.from_iso8601(past) |> elem(1)) == :eq
130+
131+
assert [{:ok, %{title: "fred"} = upserted}] =
132+
Ash.bulk_create!(
133+
[%{title: "something", uniq_one: "one", uniq_two: "two", price: 1000}],
134+
Post,
135+
:create,
136+
upsert?: true,
137+
upsert_identity: :uniq_one_and_two,
138+
upsert_fields: [],
139+
return_stream?: true,
140+
return_errors?: true,
141+
return_records?: true
142+
)
143+
|> Enum.to_list()
144+
145+
assert DateTime.compare(upserted.updated_at, DateTime.from_iso8601(past) |> elem(1)) == :eq
146+
end
147+
148+
test "bulk creates with upsert does not update update_timestamp when touch_update_defaults? is false" do
149+
past = DateTime.to_iso8601(DateTime.add(DateTime.utc_now(), -60, :second))
150+
151+
assert [{:ok, %{title: "fred", uniq_one: "one", uniq_two: "two"} = initial}] =
152+
Ash.bulk_create!(
153+
[%{title: "fred", uniq_one: "one", uniq_two: "two", price: 10}],
154+
Post,
155+
:create,
156+
return_stream?: true,
157+
return_records?: true
158+
)
159+
|> Enum.to_list()
160+
161+
AshSqlite.TestRepo.query!("UPDATE posts SET updated_at = ? WHERE id = ?", [
162+
past,
163+
initial.id
164+
])
165+
166+
assert [%{updated_at: backdated}] = Ash.read!(Post)
167+
assert DateTime.compare(backdated, DateTime.from_iso8601(past) |> elem(1)) == :eq
168+
169+
assert [{:ok, %{title: "fred", price: 1000} = upserted}] =
170+
Ash.bulk_create!(
171+
[%{title: "something", uniq_one: "one", uniq_two: "two", price: 1000}],
172+
Post,
173+
:create,
174+
upsert?: true,
175+
upsert_identity: :uniq_one_and_two,
176+
upsert_fields: [:price],
177+
context: %{data_layer: %{touch_update_defaults?: false}},
178+
return_stream?: true,
179+
return_errors?: true,
180+
return_records?: true
181+
)
182+
|> Enum.to_list()
183+
184+
assert DateTime.compare(upserted.updated_at, DateTime.from_iso8601(past) |> elem(1)) == :eq
185+
end
186+
71187
test "bulk creates can create relationships" do
72188
Ash.bulk_create!(
73189
[%{title: "fred", rating: %{score: 5}}, %{title: "george", rating: %{score: 0}}],

0 commit comments

Comments
 (0)