Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ spark_locals_without_parens = [
name: 1,
nulls_distinct: 1,
on_delete: 1,
on_delete_message: 1,
on_update: 1,
on_update_message: 1,
polymorphic?: 1,
polymorphic_on_delete: 1,
polymorphic_on_update: 1,
Expand Down
2 changes: 2 additions & 0 deletions documentation/dsls/DSL-AshPostgres.DataLayer.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ reference :post, on_delete: :delete, on_update: :update, name: "comments_to_post
|------|------|---------|------|
| [`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 \| {:nilify, atom \| list(atom)}` | | What should happen to records of this resource when the referenced record of the *destination* resource is deleted. |
| [`on_delete_message`](#postgres-references-reference-on_delete_message){: #postgres-references-reference-on_delete_message } | `String.t` | | A custom message to use when deleting a *destination* record is prevented by this foreign key, e.g. with `on_delete: :restrict` |
| [`on_update_message`](#postgres-references-reference-on_update_message){: #postgres-references-reference-on_update_message } | `String.t` | | A custom message to use when updating the referenced attribute of a *destination* record is prevented by this foreign key, e.g. with `on_update: :restrict` |
| [`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` | Whether 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
71 changes: 35 additions & 36 deletions lib/data_layer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3577,24 +3577,28 @@ defmodule AshPostgres.DataLayer do
|> set_table(changeset, type, table_error?)
|> Ecto.Changeset.cast(%{}, [])
|> force_changes(Map.take(changeset.attributes, attributes_to_change))
|> add_configured_foreign_key_constraints(record.__struct__, repo)
|> add_unique_indexes(record.__struct__, changeset, repo)
|> add_check_constraints(record.__struct__, repo)
|> add_exclusion_constraints(record.__struct__, repo)

# Ecto uses the most recently added matching constraint, so configured
# `foreign_key_names` are added last to override the relationship-derived ones.
case type do
:create ->
ecto_changeset
|> add_my_foreign_key_constraints(record.__struct__, repo)
|> add_configured_foreign_key_constraints(record.__struct__, repo)

type when type in [:upsert, :update] ->
ecto_changeset
|> add_my_foreign_key_constraints(record.__struct__, repo)
|> add_related_foreign_key_constraints(record.__struct__, repo)
|> add_related_foreign_key_constraints(record.__struct__, repo, :update)
|> add_configured_foreign_key_constraints(record.__struct__, repo)

:delete ->
ecto_changeset
|> add_related_foreign_key_constraints(record.__struct__, repo)
|> add_related_foreign_key_constraints(record.__struct__, repo, :delete)
|> add_configured_foreign_key_constraints(record.__struct__, repo)
end
end

Expand Down Expand Up @@ -4170,7 +4174,7 @@ defmodule AshPostgres.DataLayer do
end)
end

defp add_related_foreign_key_constraints(changeset, resource, repo) do
defp add_related_foreign_key_constraints(changeset, resource, repo, type) do
# TODO: this doesn't guarantee us to get all of them, because if something is related to this
# schema and there is no back-relation, then this won't catch it's foreign key constraints
resource
Expand All @@ -4190,43 +4194,38 @@ defmodule AshPostgres.DataLayer do
name: relationship_name
},
changeset ->
case AshPostgres.DataLayer.Info.reference(source, relationship_name) do
%{name: name} when not is_nil(name) ->
case repo.default_constraint_match_type(:foreign, name) do
{:regex, regex} ->
Ecto.Changeset.foreign_key_constraint(changeset, destination_attribute,
name: regex,
message: "would leave records behind",
match: :exact
)
reference = AshPostgres.DataLayer.Info.reference(source, relationship_name)

match ->
Ecto.Changeset.foreign_key_constraint(changeset, destination_attribute,
name: name,
message: "would leave records behind",
match: match
)
end
name =
case reference do
%{name: name} when not is_nil(name) ->
name

_ ->
name =
_ ->
"#{AshPostgres.DataLayer.Info.table(source)}_#{attribute_source(source, source_attribute)}_fkey"
end

case repo.default_constraint_match_type(:foreign, name) do
{:regex, regex} ->
Ecto.Changeset.foreign_key_constraint(changeset, destination_attribute,
name: regex,
message: "would leave records behind",
match: :exact
)
message =
case {type, reference} do
{:delete, %{on_delete_message: message}} when not is_nil(message) -> message
{:update, %{on_update_message: message}} when not is_nil(message) -> message
_ -> "would leave records behind"
end

match ->
Ecto.Changeset.foreign_key_constraint(changeset, destination_attribute,
name: name,
message: "would leave records behind",
match: match
)
end
case repo.default_constraint_match_type(:foreign, name) do
{:regex, regex} ->
Ecto.Changeset.foreign_key_constraint(changeset, destination_attribute,
name: regex,
message: message,
match: :exact
)

match ->
Ecto.Changeset.foreign_key_constraint(changeset, destination_attribute,
name: name,
message: message,
match: match
)
end
end)
end
Expand Down
12 changes: 12 additions & 0 deletions lib/reference.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ defmodule AshPostgres.Reference do
:relationship,
:on_delete,
:on_update,
:on_delete_message,
:on_update_message,
:name,
:match_with,
:match_type,
Expand Down Expand Up @@ -42,6 +44,16 @@ defmodule AshPostgres.Reference do
What should happen to records of this resource when the referenced record of the *destination* resource is deleted.
"""
],
on_delete_message: [
type: :string,
doc:
"A custom message to use when deleting a *destination* record is prevented by this foreign key, e.g. with `on_delete: :restrict`"
],
on_update_message: [
type: :string,
doc:
"A custom message to use when updating the referenced attribute of a *destination* record is prevented by this foreign key, e.g. with `on_update: :restrict`"
],
on_update: [
type: {:one_of, [:update, :nilify, :nothing, :restrict]},
doc: """
Expand Down
28 changes: 28 additions & 0 deletions test/references_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,32 @@ defmodule AshPostgres.ReferencesTest do
assert Keyword.get(private_vars, :constraint) == "special_name_fkey"
assert Keyword.get(private_vars, :constraint_type) == :foreign_key
end

test "foreign_key_names message takes precedence over the relationship's foreign key on create" do
assert {:error, %Ash.Error.Invalid{errors: errors}} =
AshPostgres.Test.Permalink
|> Ash.Changeset.for_create(:create, %{post_id: Ash.UUID.generate()})
|> Ash.create()

assert [
%Ash.Error.Changes.InvalidAttribute{
field: :post_id,
message: "post must exist"
}
] = errors
end

test "foreign_key_names message takes precedence over the related foreign key on destroy" do
container = Ash.create!(AshPostgres.Test.Container, %{})
Ash.create!(AshPostgres.Test.Item, %{container_id: container.id})

assert {:error, %Ash.Error.Invalid{errors: errors}} = Ash.destroy(container)

assert [
%Ash.Error.Changes.InvalidAttribute{
field: :id,
message: "container still has items"
}
] = errors
end
end
27 changes: 23 additions & 4 deletions test/sourced_foreign_key_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,37 @@ defmodule AshPostgres.SourcedForeignKeyTest do
|> Ash.create()
end

test "destroying a parent with children returns an invalid error when the fk attribute has a different source" do
test "destroying a parent with children returns the reference's on_delete_message when the fk attribute has a different source" do
parent = Ash.create!(SourcedFkParent, %{})
Ash.create!(SourcedFkChild, %{parent_ref: parent.id})

assert {:error, %Ash.Error.Invalid{errors: errors}} = Ash.destroy(parent)

assert Enum.any?(errors, fn
%Ash.Error.Changes.InvalidAttribute{message: message} ->
message =~ "would leave records behind"
%Ash.Error.Changes.InvalidAttribute{message: "parent still has children"} -> true
_ -> false
end),
"Expected custom on_delete_message, got: #{inspect(errors)}"
end

test "updating a referenced parent attribute returns the reference's on_update_message" do
parent = Ash.create!(SourcedFkParent, %{})
Ash.create!(SourcedFkChild, %{parent_ref: parent.id})

assert {:error, %Ash.Error.Invalid{errors: errors}} =
parent
|> Ash.Changeset.for_update(:update, %{id: Ash.UUID.generate()})
|> Ash.update()

assert Enum.any?(errors, fn
%Ash.Error.Changes.InvalidAttribute{
message: "parent is still referenced by children"
} ->
true

_ ->
false
end)
end),
"Expected custom on_update_message, got: #{inspect(errors)}"
end
end
4 changes: 3 additions & 1 deletion test/support/resources/container.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ defmodule AshPostgres.Test.Container do
postgres do
table "containers"
repo AshPostgres.TestRepo

foreign_key_names [{:id, "items_container_id_fkey", "container still has items"}]
end

actions do
default_accept(:*)
defaults([:read, create: :*])
defaults([:read, :destroy, create: :*])
end

attributes do
Expand Down
2 changes: 2 additions & 0 deletions test/support/resources/permalink.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ defmodule AshPostgres.Test.Permalink do
table "post_permalinks"
repo AshPostgres.TestRepo

foreign_key_names [{:post_id, "post_permalinks_post_id_fkey", "post must exist"}]

references do
reference :post, on_delete: :nothing
end
Expand Down
5 changes: 4 additions & 1 deletion test/support/resources/sourced_fk_child.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ defmodule AshPostgres.Test.SourcedFkChild do
repo AshPostgres.TestRepo

references do
reference :parent, on_delete: :restrict
reference :parent,
on_delete: :restrict,
on_delete_message: "parent still has children",
on_update_message: "parent is still referenced by children"
end
end
end
4 changes: 2 additions & 2 deletions test/support/resources/sourced_fk_parent.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ defmodule AshPostgres.Test.SourcedFkParent do
actions do
default_accept(:*)

defaults([:create, :read, :destroy])
defaults([:create, :read, :destroy, :update])
end

attributes do
uuid_primary_key(:id)
uuid_primary_key(:id, writable?: true, public?: true)
end

relationships do
Expand Down
25 changes: 24 additions & 1 deletion test/update_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,34 @@

defmodule AshPostgres.UpdateTest do
use AshPostgres.RepoCase, async: false
alias AshPostgres.Test.Post
alias AshPostgres.Test.{Permalink, Post}
require Ash.Query
import ExUnit.CaptureLog
import Ash.Expr

test "updating a referenced attribute returns would leave records behind error" do
post =
Post
|> Ash.Changeset.for_create(:create, %{})
|> Ash.create!()

Permalink |> Ash.Changeset.for_create(:create, %{post_id: post.id}) |> Ash.create!()

assert {:error, %Ash.Error.Invalid{errors: errors}} =
post
|> Ash.Changeset.for_update(:update, %{id: Ash.UUID.generate()})
|> Ash.update()

assert Enum.any?(errors, fn
%Ash.Error.Changes.InvalidAttribute{message: msg} ->
msg =~ "would leave records behind"

_ ->
false
end),
"Expected 'would leave records behind' error, got: #{inspect(errors)}"
end

test "can update a keyword type attribute" do
post =
Post
Expand Down
Loading