Skip to content

Commit

Permalink
improvement: support on_delete: :nilify for specific columns (#289)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidebriani authored May 17, 2024
1 parent ad0b1a5 commit 814e7de
Show file tree
Hide file tree
Showing 7 changed files with 156 additions and 14 deletions.
4 changes: 2 additions & 2 deletions documentation/dsls/DSL:-AshPostgres.DataLayer.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ end

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`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. |
| [`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. |
| [`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. |


Expand Down Expand Up @@ -297,7 +297,7 @@ reference :post, on_delete: :delete, on_update: :update, name: "comments_to_post
| Name | Type | Default | Docs |
|------|------|---------|------|
| [`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 |
| [`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. |
| [`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. |
| [`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. |
| [`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. |
| [`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 |
Expand Down
36 changes: 31 additions & 5 deletions documentation/topics/resources/references.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,36 @@ end
>
> 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.
## On Delete

This option describes what to do if the referenced row is deleted.

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.

The possible values for the option are `:nothing`, `:restrict`, `:delete`, `:nilify`, `{:nilify, columns}`.

With `:nothing` or `:restrict` the deletion of the referenced row is prevented.

With `:delete` the row is deleted together with the referenced row.

With `:nilify` all columns of the foreign-key constraint are nilified.

With `{:nilify, columns}` a column list can specify which columns should be set to `nil`.
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.
In addition, keep into consideration that this option is only supported from Postgres v15.0 onwards.

## On Update

This option describes what to do if the referenced row is updated.

The possible values for the option are `:nothing`, `:restrict`, `:update`, `:nilify`.

With `:nothing` or `:restrict` the update of the referenced row is prevented.

With `:update` the row is updated according to the referenced row.

With `:nilify` all columns of the foreign-key constraint are nilified.

## Nothing vs Restrict

```elixir
Expand All @@ -24,8 +54,4 @@ references do
end
```

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.

## On Delete

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.
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.
14 changes: 12 additions & 2 deletions lib/data_layer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,12 @@ defmodule AshPostgres.DataLayer do
entities: [@reference],
schema: [
polymorphic_on_delete: [
type: {:one_of, [:delete, :nilify, :nothing, :restrict]},
type:
{:or,
[
{:one_of, [:delete, :nilify, :nothing, :restrict]},
{:tagged_tuple, :nilify, {:wrap_list, :atom}}
]},
doc:
"For polymorphic resources, configures the on_delete behavior of the automatically generated foreign keys to source tables."
],
Expand Down Expand Up @@ -227,7 +232,12 @@ defmodule AshPostgres.DataLayer do
entities: [@reference],
schema: [
polymorphic_on_delete: [
type: {:one_of, [:delete, :nilify, :nothing, :restrict]},
type:
{:or,
[
{:one_of, [:delete, :nilify, :nothing, :restrict]},
{:tagged_tuple, :nilify, {:wrap_list, :atom}}
]},
doc:
"For polymorphic resources, configures the on_delete behavior of the automatically generated foreign keys to source tables."
],
Expand Down
30 changes: 26 additions & 4 deletions lib/migration_generator/migration_generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2920,9 +2920,7 @@ defmodule AshPostgres.MigrationGenerator do
defp snapshot_to_binary(snapshot) do
snapshot
|> Map.update!(:attributes, fn attributes ->
Enum.map(attributes, fn attribute ->
%{attribute | type: sanitize_type(attribute.type, attribute[:size])}
end)
Enum.map(attributes, &attribute_to_binary/1)
end)
|> Map.update!(:custom_indexes, fn indexes ->
Enum.map(indexes, fn index ->
Expand All @@ -2938,6 +2936,22 @@ defmodule AshPostgres.MigrationGenerator do
|> Jason.encode!(pretty: true)
end

defp attribute_to_binary(attribute) do
attribute
|> Map.update!(:references, fn
nil ->
nil

references ->
references
|> Map.update!(:on_delete, &(&1 && references_on_delete_to_binary(&1)))
end)
|> Map.update!(:type, fn type -> sanitize_type(type, attribute[:size]) end)
end

defp references_on_delete_to_binary(value) when is_atom(value), do: value
defp references_on_delete_to_binary({:nilify, columns}), do: [:nilify, columns]

defp sanitize_type({:array, type}, size) do
["array", sanitize_type(type, size)]
end
Expand Down Expand Up @@ -3094,7 +3108,7 @@ defmodule AshPostgres.MigrationGenerator do
|> Map.put_new(:destination_attribute_generated, false)
|> Map.put_new(:on_delete, nil)
|> Map.put_new(:on_update, nil)
|> Map.update!(:on_delete, &(&1 && maybe_to_atom(&1)))
|> Map.update!(:on_delete, &(&1 && load_references_on_delete(&1)))
|> Map.update!(:on_update, &(&1 && maybe_to_atom(&1)))
|> Map.put_new(:match_with, nil)
|> Map.put_new(:match_type, nil)
Expand Down Expand Up @@ -3178,6 +3192,14 @@ defmodule AshPostgres.MigrationGenerator do
Map.put_new(index, :index_name, "#{table}_#{name}_unique_index")
end

defp load_references_on_delete(["nilify", columns]) when is_list(columns) do
{:nilify, Enum.map(columns, &maybe_to_atom/1)}
end

defp load_references_on_delete(value) do
maybe_to_atom(value)
end

defp maybe_to_atom(value) when is_atom(value), do: value
defp maybe_to_atom(value), do: String.to_atom(value)
end
4 changes: 4 additions & 0 deletions lib/migration_generator/operation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ defmodule AshPostgres.MigrationGenerator.Operation do
end
end

def on_delete(%{on_delete: {:nilify, columns}}) when is_list(columns) do
"on_delete: {:nilify, #{inspect(columns)}}"
end

def on_delete(%{on_delete: on_delete}) when on_delete in [:delete, :nilify] do
"on_delete: :#{on_delete}_all"
end
Expand Down
7 changes: 6 additions & 1 deletion lib/reference.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ defmodule AshPostgres.Reference do
"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"
],
on_delete: [
type: {:one_of, [:delete, :nilify, :nothing, :restrict]},
type:
{:or,
[
{:one_of, [:delete, :nilify, :nothing, :restrict]},
{:tagged_tuple, :nilify, {:wrap_list, :atom}}
]},
doc: """
What should happen to records of this resource when the referenced record of the *destination* resource is deleted.
"""
Expand Down
75 changes: 75 additions & 0 deletions test/migration_generator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,81 @@ defmodule AshPostgres.MigrationGeneratorTest do
assert File.read!(file) =~
~S[references(:users, column: :id, name: "user_things2_user_id_fkey", type: :uuid, prefix: "public")]
end

test "references on_delete: {:nilify, columns} works with multitenant resources" do
defresource Tenant, "tenants" do
attributes do
uuid_primary_key(:id)
end

multitenancy do
strategy(:attribute)
attribute(:id)
end
end

defresource Group, "groups" do
attributes do
uuid_primary_key(:id)
end

multitenancy do
strategy(:attribute)
attribute(:tenant_id)
end

relationships do
belongs_to(:tenant, Tenant)
end

postgres do
references do
reference(:tenant, on_delete: :delete)
end
end
end

defresource Item, "items" do
attributes do
uuid_primary_key(:id)
end

multitenancy do
strategy(:attribute)
attribute(:tenant_id)
end

relationships do
belongs_to(:group, Group)
belongs_to(:tenant, Tenant)
end

postgres do
references do
reference(:group,
match_with: [tenant_id: :tenant_id],
on_delete: {:nilify, [:group_id]}
)

reference(:tenant, on_delete: :delete)
end
end
end

defdomain([Tenant, Group, Item])

AshPostgres.MigrationGenerator.generate(Domain,
snapshot_path: "test_snapshots_path",
migration_path: "test_migration_path",
quiet: true,
format: false
)

assert [file] = Path.wildcard("test_migration_path/**/*_migrate_resources*.exs")

assert File.read!(file) =~
~S<references(:groups, column: :id, with: [tenant_id: :tenant_id], name: "items_group_id_fkey", type: :uuid, prefix: "public", on_delete: {:nilify, [:group_id]}>
end
end

describe "check constraints" do
Expand Down

0 comments on commit 814e7de

Please sign in to comment.