Skip to content

Commit 814e7de

Browse files
authored
improvement: support on_delete: :nilify for specific columns (#289)
1 parent ad0b1a5 commit 814e7de

File tree

7 files changed

+156
-14
lines changed

7 files changed

+156
-14
lines changed

documentation/dsls/DSL:-AshPostgres.DataLayer.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ end
258258

259259
| Name | Type | Default | Docs |
260260
|------|------|---------|------|
261-
| [`polymorphic_on_delete`](#postgres-references-polymorphic_on_delete){: #postgres-references-polymorphic_on_delete } | `:delete \| :nilify \| :nothing \| :restrict` | | For polymorphic resources, configures the on_delete behavior of the automatically generated foreign keys to source tables. |
261+
| [`polymorphic_on_delete`](#postgres-references-polymorphic_on_delete){: #postgres-references-polymorphic_on_delete } | `:delete \| :nilify \| {:nilify, columns} \| :nothing \| :restrict` | | For polymorphic resources, configures the on_delete behavior of the automatically generated foreign keys to source tables. |
262262
| [`polymorphic_on_update`](#postgres-references-polymorphic_on_update){: #postgres-references-polymorphic_on_update } | `:update \| :nilify \| :nothing \| :restrict` | | For polymorphic resources, configures the on_update behavior of the automatically generated foreign keys to source tables. |
263263

264264

@@ -297,7 +297,7 @@ reference :post, on_delete: :delete, on_update: :update, name: "comments_to_post
297297
| Name | Type | Default | Docs |
298298
|------|------|---------|------|
299299
| [`ignore?`](#postgres-references-reference-ignore?){: #postgres-references-reference-ignore? } | `boolean` | | If set to true, no reference is created for the given relationship. This is useful if you need to define it in some custom way |
300-
| [`on_delete`](#postgres-references-reference-on_delete){: #postgres-references-reference-on_delete } | `:delete \| :nilify \| :nothing \| :restrict` | | What should happen to records of this resource when the referenced record of the *destination* resource is deleted. |
300+
| [`on_delete`](#postgres-references-reference-on_delete){: #postgres-references-reference-on_delete } | `:delete \| :nilify \| {:nilify, columns} \| :nothing \| :restrict` | | What should happen to records of this resource when the referenced record of the *destination* resource is deleted. |
301301
| [`on_update`](#postgres-references-reference-on_update){: #postgres-references-reference-on_update } | `:update \| :nilify \| :nothing \| :restrict` | | What should happen to records of this resource when the referenced destination_attribute of the *destination* record is update. |
302302
| [`deferrable`](#postgres-references-reference-deferrable){: #postgres-references-reference-deferrable } | `false \| true \| :initially` | `false` | Wether or not the constraint is deferrable. This only affects the migration generator. |
303303
| [`name`](#postgres-references-reference-name){: #postgres-references-reference-name } | `String.t` | | The name of the foreign key to generate in the database. Defaults to <table>_<source_attribute>_fkey |

documentation/topics/resources/references.md

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,36 @@ end
1414
>
1515
> No resource logic is applied with these operations! No authorization rules or validations take place, and no notifications are issued. This operation happens _directly_ in the database.
1616
17+
## On Delete
18+
19+
This option describes what to do if the referenced row is deleted.
20+
21+
The option is called `on_delete`, instead of `on_destroy`, because it is hooking into the database level deletion, _not_ a `destroy` action in your resource. See the warning above.
22+
23+
The possible values for the option are `:nothing`, `:restrict`, `:delete`, `:nilify`, `{:nilify, columns}`.
24+
25+
With `:nothing` or `:restrict` the deletion of the referenced row is prevented.
26+
27+
With `:delete` the row is deleted together with the referenced row.
28+
29+
With `:nilify` all columns of the foreign-key constraint are nilified.
30+
31+
With `{:nilify, columns}` a column list can specify which columns should be set to `nil`.
32+
If you intend to use this option to nilify a subset of the columns, note that it cannot be used together with the `match: :full` option otherwise a mix of nil and non-nil values would fail the constraint and prevent the deletion of the referenced row.
33+
In addition, keep into consideration that this option is only supported from Postgres v15.0 onwards.
34+
35+
## On Update
36+
37+
This option describes what to do if the referenced row is updated.
38+
39+
The possible values for the option are `:nothing`, `:restrict`, `:update`, `:nilify`.
40+
41+
With `:nothing` or `:restrict` the update of the referenced row is prevented.
42+
43+
With `:update` the row is updated according to the referenced row.
44+
45+
With `:nilify` all columns of the foreign-key constraint are nilified.
46+
1747
## Nothing vs Restrict
1848

1949
```elixir
@@ -24,8 +54,4 @@ references do
2454
end
2555
```
2656

27-
The difference between `:nothing` and `:restrict` is subtle and, if you are unsure, choose `:nothing` (the default behavior). `:restrict` will prevent the deletion from happening _before_ the end of the database transaction, whereas `:nothing` allows the transaction to complete before doing so. This allows for things like updating or deleting the destination row and _then_ updating updating or deleting the reference(as long as you are in a transaction). The reason that `:nothing` still ultimately prevents deletion is because postgres enforces foreign key referential integrity.
28-
29-
## On Delete
30-
31-
This option is called `on_delete`, instead of `on_destroy`, because it is hooking into the database level deletion, _not_ a `destroy` action in your resource. See the warning above.
57+
The difference between `:nothing` and `:restrict` is subtle and, if you are unsure, choose `:nothing` (the default behavior). `:restrict` will immediately check the foreign-key constraint and prevent the update or deletion from happening, whereas `:nothing` allows the check to be deferred until later in the transaction. This allows for things like updating or deleting the destination row and _then_ updating updating or deleting the reference (as long as you are in a transaction). The reason that `:nothing` still ultimately prevents the update or deletion is because postgres enforces foreign key referential integrity.

lib/data_layer.ex

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,12 @@ defmodule AshPostgres.DataLayer do
154154
entities: [@reference],
155155
schema: [
156156
polymorphic_on_delete: [
157-
type: {:one_of, [:delete, :nilify, :nothing, :restrict]},
157+
type:
158+
{:or,
159+
[
160+
{:one_of, [:delete, :nilify, :nothing, :restrict]},
161+
{:tagged_tuple, :nilify, {:wrap_list, :atom}}
162+
]},
158163
doc:
159164
"For polymorphic resources, configures the on_delete behavior of the automatically generated foreign keys to source tables."
160165
],
@@ -227,7 +232,12 @@ defmodule AshPostgres.DataLayer do
227232
entities: [@reference],
228233
schema: [
229234
polymorphic_on_delete: [
230-
type: {:one_of, [:delete, :nilify, :nothing, :restrict]},
235+
type:
236+
{:or,
237+
[
238+
{:one_of, [:delete, :nilify, :nothing, :restrict]},
239+
{:tagged_tuple, :nilify, {:wrap_list, :atom}}
240+
]},
231241
doc:
232242
"For polymorphic resources, configures the on_delete behavior of the automatically generated foreign keys to source tables."
233243
],

lib/migration_generator/migration_generator.ex

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2920,9 +2920,7 @@ defmodule AshPostgres.MigrationGenerator do
29202920
defp snapshot_to_binary(snapshot) do
29212921
snapshot
29222922
|> Map.update!(:attributes, fn attributes ->
2923-
Enum.map(attributes, fn attribute ->
2924-
%{attribute | type: sanitize_type(attribute.type, attribute[:size])}
2925-
end)
2923+
Enum.map(attributes, &attribute_to_binary/1)
29262924
end)
29272925
|> Map.update!(:custom_indexes, fn indexes ->
29282926
Enum.map(indexes, fn index ->
@@ -2938,6 +2936,22 @@ defmodule AshPostgres.MigrationGenerator do
29382936
|> Jason.encode!(pretty: true)
29392937
end
29402938

2939+
defp attribute_to_binary(attribute) do
2940+
attribute
2941+
|> Map.update!(:references, fn
2942+
nil ->
2943+
nil
2944+
2945+
references ->
2946+
references
2947+
|> Map.update!(:on_delete, &(&1 && references_on_delete_to_binary(&1)))
2948+
end)
2949+
|> Map.update!(:type, fn type -> sanitize_type(type, attribute[:size]) end)
2950+
end
2951+
2952+
defp references_on_delete_to_binary(value) when is_atom(value), do: value
2953+
defp references_on_delete_to_binary({:nilify, columns}), do: [:nilify, columns]
2954+
29412955
defp sanitize_type({:array, type}, size) do
29422956
["array", sanitize_type(type, size)]
29432957
end
@@ -3094,7 +3108,7 @@ defmodule AshPostgres.MigrationGenerator do
30943108
|> Map.put_new(:destination_attribute_generated, false)
30953109
|> Map.put_new(:on_delete, nil)
30963110
|> Map.put_new(:on_update, nil)
3097-
|> Map.update!(:on_delete, &(&1 && maybe_to_atom(&1)))
3111+
|> Map.update!(:on_delete, &(&1 && load_references_on_delete(&1)))
30983112
|> Map.update!(:on_update, &(&1 && maybe_to_atom(&1)))
30993113
|> Map.put_new(:match_with, nil)
31003114
|> Map.put_new(:match_type, nil)
@@ -3178,6 +3192,14 @@ defmodule AshPostgres.MigrationGenerator do
31783192
Map.put_new(index, :index_name, "#{table}_#{name}_unique_index")
31793193
end
31803194

3195+
defp load_references_on_delete(["nilify", columns]) when is_list(columns) do
3196+
{:nilify, Enum.map(columns, &maybe_to_atom/1)}
3197+
end
3198+
3199+
defp load_references_on_delete(value) do
3200+
maybe_to_atom(value)
3201+
end
3202+
31813203
defp maybe_to_atom(value) when is_atom(value), do: value
31823204
defp maybe_to_atom(value), do: String.to_atom(value)
31833205
end

lib/migration_generator/operation.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ defmodule AshPostgres.MigrationGenerator.Operation do
4242
end
4343
end
4444

45+
def on_delete(%{on_delete: {:nilify, columns}}) when is_list(columns) do
46+
"on_delete: {:nilify, #{inspect(columns)}}"
47+
end
48+
4549
def on_delete(%{on_delete: on_delete}) when on_delete in [:delete, :nilify] do
4650
"on_delete: :#{on_delete}_all"
4751
end

lib/reference.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ defmodule AshPostgres.Reference do
2424
"If set to true, no reference is created for the given relationship. This is useful if you need to define it in some custom way"
2525
],
2626
on_delete: [
27-
type: {:one_of, [:delete, :nilify, :nothing, :restrict]},
27+
type:
28+
{:or,
29+
[
30+
{:one_of, [:delete, :nilify, :nothing, :restrict]},
31+
{:tagged_tuple, :nilify, {:wrap_list, :atom}}
32+
]},
2833
doc: """
2934
What should happen to records of this resource when the referenced record of the *destination* resource is deleted.
3035
"""

test/migration_generator_test.exs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,6 +1246,81 @@ defmodule AshPostgres.MigrationGeneratorTest do
12461246
assert File.read!(file) =~
12471247
~S[references(:users, column: :id, name: "user_things2_user_id_fkey", type: :uuid, prefix: "public")]
12481248
end
1249+
1250+
test "references on_delete: {:nilify, columns} works with multitenant resources" do
1251+
defresource Tenant, "tenants" do
1252+
attributes do
1253+
uuid_primary_key(:id)
1254+
end
1255+
1256+
multitenancy do
1257+
strategy(:attribute)
1258+
attribute(:id)
1259+
end
1260+
end
1261+
1262+
defresource Group, "groups" do
1263+
attributes do
1264+
uuid_primary_key(:id)
1265+
end
1266+
1267+
multitenancy do
1268+
strategy(:attribute)
1269+
attribute(:tenant_id)
1270+
end
1271+
1272+
relationships do
1273+
belongs_to(:tenant, Tenant)
1274+
end
1275+
1276+
postgres do
1277+
references do
1278+
reference(:tenant, on_delete: :delete)
1279+
end
1280+
end
1281+
end
1282+
1283+
defresource Item, "items" do
1284+
attributes do
1285+
uuid_primary_key(:id)
1286+
end
1287+
1288+
multitenancy do
1289+
strategy(:attribute)
1290+
attribute(:tenant_id)
1291+
end
1292+
1293+
relationships do
1294+
belongs_to(:group, Group)
1295+
belongs_to(:tenant, Tenant)
1296+
end
1297+
1298+
postgres do
1299+
references do
1300+
reference(:group,
1301+
match_with: [tenant_id: :tenant_id],
1302+
on_delete: {:nilify, [:group_id]}
1303+
)
1304+
1305+
reference(:tenant, on_delete: :delete)
1306+
end
1307+
end
1308+
end
1309+
1310+
defdomain([Tenant, Group, Item])
1311+
1312+
AshPostgres.MigrationGenerator.generate(Domain,
1313+
snapshot_path: "test_snapshots_path",
1314+
migration_path: "test_migration_path",
1315+
quiet: true,
1316+
format: false
1317+
)
1318+
1319+
assert [file] = Path.wildcard("test_migration_path/**/*_migrate_resources*.exs")
1320+
1321+
assert File.read!(file) =~
1322+
~S<references(:groups, column: :id, with: [tenant_id: :tenant_id], name: "items_group_id_fkey", type: :uuid, prefix: "public", on_delete: {:nilify, [:group_id]}>
1323+
end
12491324
end
12501325

12511326
describe "check constraints" do

0 commit comments

Comments
 (0)