feat: add check_constraints, both for validation and migrations

This commit is contained in:
Zach Daniel 2021-04-19 14:26:41 -04:00
parent e8cc9eb489
commit 1f6621b852
15 changed files with 788 additions and 9 deletions

View file

@ -2,8 +2,12 @@
# DONT MODIFY IT BY HAND
locals_without_parens = [
base_filter_sql: 1,
check: 1,
check_constraint: 2,
check_constraint: 3,
create?: 1,
foreign_key_names: 1,
message: 1,
migrate?: 1,
name: 1,
on_delete: 1,

View file

@ -24,6 +24,11 @@ defmodule AshPostgres do
Extension.get_entities(resource, [:postgres, :references])
end
@doc "The configured check_constraints for a resource"
def check_constraints(resource) do
Extension.get_entities(resource, [:postgres, :check_constraints])
end
@doc "The configured polymorphic_reference_on_delete for a resource"
def polymorphic_on_delete(resource) do
Extension.get_opt(resource, [:postgres, :references], :polymorphic_on_delete, nil, true)

31
lib/check_constraint.ex Normal file
View file

@ -0,0 +1,31 @@
defmodule AshPostgres.CheckConstraint do
@moduledoc """
Contains configuration for database check constraints
"""
defstruct [:attribute, :name, :message, :check]
def schema do
[
attribute: [
type: :any,
doc:
"The attribute or list of attributes to which an error will be added if the check constraint fails",
required: true
],
name: [
type: :string,
required: true,
doc: "The name of the constraint"
],
message: [
type: :string,
doc: "The message to be added if the check constraint fails"
],
check: [
type: :string,
doc:
"The contents of the check. If this is set, the migration generator will include it when generating migrations"
]
]
end
end

View file

@ -96,6 +96,79 @@ defmodule AshPostgres.DataLayer do
]
}
@check_constraint %Ash.Dsl.Entity{
name: :check_constraint,
describe: """
Add a check constraint to be validated.
If a check constraint exists on the table but not in this section, and it produces an error, a runtime error will be raised.
Provide a list of attributes instead of a single attribute to add the message to multiple attributes.
By adding the `check` option, the migration generator will include it when generating migrations.
""",
examples: [
"""
check_constraint :price, "price_must_be_positive", check: "price > 0", message: "price must be positive"
"""
],
args: [:attribute, :name],
target: AshPostgres.CheckConstraint,
schema: AshPostgres.CheckConstraint.schema()
}
@check_constraints %Ash.Dsl.Section{
name: :check_constraints,
describe: """
A section for configuring the check constraints for a given table.
This can be used to automatically create those check constraints, or just to provide message when they are raised
""",
examples: [
"""
check_constraints do
check_constraint :price, "price_must_be_positive", check: "price > 0", message: "price must be positive"
end
"""
],
entities: [@check_constraint]
}
@references %Ash.Dsl.Section{
name: :references,
describe: """
A section for configuring the references (foreign keys) in resource migrations.
This section is only relevant if you are using the migration generator with this resource.
Otherwise, it has no effect.
""",
examples: [
"""
references do
reference :post, on_delete: :delete, on_update: :update, name: "comments_to_posts_fkey"
end
"""
],
entities: [@reference],
schema: [
polymorphic_on_delete: [
type: {:one_of, [:delete, :nilify, :nothing, :restrict]},
doc:
"For polymorphic resources, configures the on_delete behavior of the automatically generated foreign keys to source tables."
],
polymorphic_on_update: [
type: {:one_of, [:update, :nilify, :nothing, :restrict]},
doc:
"For polymorphic resources, configures the on_update behavior of the automatically generated foreign keys to source tables."
],
polymorphic_name: [
type: {:one_of, [:update, :nilify, :nothing, :restrict]},
doc:
"For polymorphic resources, configures the on_update behavior of the automatically generated foreign keys to source tables."
]
]
}
@postgres %Ash.Dsl.Section{
name: :postgres,
describe: """
@ -103,7 +176,8 @@ defmodule AshPostgres.DataLayer do
""",
sections: [
@manage_tenant,
@references
@references,
@check_constraints
],
modules: [
:repo
@ -548,26 +622,23 @@ defmodule AshPostgres.DataLayer do
record
|> set_table(changeset, type)
|> Ecto.Changeset.change(changeset.attributes)
|> add_configured_foreign_key_constraints(record.__struct__)
|> add_unique_indexes(record.__struct__, changeset.tenant, changeset)
|> add_check_constraints(record.__struct__)
case type do
:create ->
ecto_changeset
|> add_unique_indexes(record.__struct__, changeset.tenant, changeset)
|> add_my_foreign_key_constraints(record.__struct__)
|> add_configured_foreign_key_constraints(record.__struct__)
type when type in [:upsert, :update] ->
ecto_changeset
|> add_unique_indexes(record.__struct__, changeset.tenant, changeset)
|> add_my_foreign_key_constraints(record.__struct__)
|> add_related_foreign_key_constraints(record.__struct__)
|> add_configured_foreign_key_constraints(record.__struct__)
:delete ->
ecto_changeset
|> add_unique_indexes(record.__struct__, changeset.tenant, changeset)
|> add_related_foreign_key_constraints(record.__struct__)
|> add_configured_foreign_key_constraints(record.__struct__)
end
end
@ -585,6 +656,21 @@ defmodule AshPostgres.DataLayer do
end
end
defp add_check_constraints(changeset, resource) do
resource
|> AshPostgres.check_constraints()
|> Enum.reduce(changeset, fn constraint, changeset ->
constraint.attribute
|> List.wrap()
|> Enum.reduce(changeset, fn attribute, changeset ->
Ecto.Changeset.check_constraint(changeset, attribute,
name: constraint.name,
message: constraint.message || "is invalid"
)
end)
end)
end
defp add_related_foreign_key_constraints(changeset, resource) 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

View file

@ -841,9 +841,26 @@ defmodule AshPostgres.MigrationGenerator do
name in keys || (multitenancy.attribute && name == multitenancy.attribute)
end
defp after?(
%Operation.AddCheckConstraint{
constraint: %{attribute: attribute_or_attributes},
table: table,
multitenancy: multitenancy
},
%Operation.AddAttribute{table: table, attribute: %{name: name}}
) do
name in List.wrap(attribute_or_attributes) ||
(multitenancy.attribute && multitenancy.attribute in List.wrap(attribute_or_attributes))
end
defp after?(%Operation.AddUniqueIndex{table: table}, %Operation.RemoveUniqueIndex{table: table}),
do: true
defp after?(%Operation.AddCheckConstraint{table: table}, %Operation.RemoveCheckConstraint{
table: table
}),
do: true
defp after?(
%Operation.AddUniqueIndex{identity: %{keys: keys}, table: table},
%Operation.AlterAttribute{table: table, new_attribute: %{name: name}}
@ -858,6 +875,26 @@ defmodule AshPostgres.MigrationGenerator do
name in keys
end
defp after?(
%Operation.AddCheckConstraint{
constraint: %{attribute: attribute_or_attributes},
table: table
},
%Operation.AlterAttribute{table: table, new_attribute: %{name: name}}
) do
name in List.wrap(attribute_or_attributes)
end
defp after?(
%Operation.AddCheckConstraint{
constraint: %{attribute: attribute_or_attributes},
table: table
},
%Operation.RenameAttribute{table: table, new_attribute: %{name: name}}
) do
name in List.wrap(attribute_or_attributes)
end
defp after?(
%Operation.RemoveUniqueIndex{identity: %{keys: keys}, table: table},
%Operation.RemoveAttribute{table: table, attribute: %{name: name}}
@ -872,6 +909,20 @@ defmodule AshPostgres.MigrationGenerator do
name in keys
end
defp after?(
%Operation.RemoveCheckConstraint{constraint: %{attribute: attributes}, table: table},
%Operation.RemoveAttribute{table: table, attribute: %{name: name}}
) do
name in List.wrap(attributes)
end
defp after?(
%Operation.RemoveCheckConstraint{constraint: %{attribute: attributes}, table: table},
%Operation.RenameAttribute{table: table, old_attribute: %{name: name}}
) do
name in List.wrap(attributes)
end
defp after?(%Operation.AlterAttribute{table: table}, %Operation.DropForeignKey{
table: table,
direction: :up
@ -960,6 +1011,10 @@ defmodule AshPostgres.MigrationGenerator do
true
end
defp after?(%Operation.AddCheckConstraint{table: table}, %Operation.CreateTable{table: table}) do
true
end
defp after?(%Operation.AlterAttribute{new_attribute: %{references: references}}, _)
when not is_nil(references),
do: true
@ -982,6 +1037,7 @@ defmodule AshPostgres.MigrationGenerator do
empty_snapshot = %{
attributes: [],
identities: [],
check_constraints: [],
table: snapshot.table,
repo: snapshot.repo,
multitenancy: %{
@ -1039,7 +1095,42 @@ defmodule AshPostgres.MigrationGenerator do
}
end)
[unique_indexes_to_remove, attribute_operations, unique_indexes_to_add, acc]
constraints_to_add =
snapshot.check_constraints
|> Enum.reject(fn constraint ->
Enum.find(old_snapshot.check_constraints, fn old_constraint ->
old_constraint.check == constraint.check && old_constraint.name == constraint.name
end)
end)
|> Enum.map(fn constraint ->
%Operation.AddCheckConstraint{
constraint: constraint,
table: snapshot.table
}
end)
constraints_to_remove =
old_snapshot.check_constraints
|> Enum.reject(fn old_constraint ->
Enum.find(snapshot.check_constraints, fn constraint ->
old_constraint.check == constraint.check && old_constraint.name == constraint.name
end)
end)
|> Enum.map(fn old_constraint ->
%Operation.RemoveCheckConstraint{
constraint: old_constraint,
table: old_snapshot.table
}
end)
[
unique_indexes_to_remove,
attribute_operations,
unique_indexes_to_add,
constraints_to_add,
constraints_to_remove,
acc
]
|> Enum.concat()
|> Enum.map(&Map.put(&1, :multitenancy, snapshot.multitenancy))
|> Enum.map(&Map.put(&1, :old_multitenancy, old_snapshot.multitenancy))
@ -1338,6 +1429,7 @@ defmodule AshPostgres.MigrationGenerator do
attributes: attributes(resource),
identities: identities(resource),
table: table || AshPostgres.table(resource),
check_constraints: check_constraints(resource),
repo: AshPostgres.repo(resource),
multitenancy: multitenancy(resource),
base_filter: AshPostgres.base_filter_sql(resource),
@ -1358,6 +1450,37 @@ defmodule AshPostgres.MigrationGenerator do
|> Enum.any?(&(&1.type == :create))
end
defp check_constraints(resource) do
resource
|> AshPostgres.check_constraints()
|> Enum.filter(& &1.check)
|> case do
[] ->
[]
constraints ->
base_filter = Ash.Resource.Info.base_filter(resource)
if base_filter && !AshPostgres.base_filter_sql(resource) do
raise """
Cannot create a check constraint for a resource with a base filter without also configuring `base_filter_sql`.
You must provide the `base_filter_sql` option, or manually create add the check constraint to your migrations.
"""
end
constraints
end
|> Enum.map(fn constraint ->
%{
name: constraint.name,
attribute: List.wrap(constraint.attribute),
check: constraint.check,
base_filter: AshPostgres.base_filter_sql(resource)
}
end)
end
defp multitenancy(resource) do
strategy = Ash.Resource.Info.multitenancy_strategy(resource)
attribute = Ash.Resource.Info.multitenancy_attribute(resource)
@ -1452,7 +1575,9 @@ defmodule AshPostgres.MigrationGenerator do
if base_filter && !AshPostgres.base_filter_sql(resource) do
raise """
Currently, ash_postgres cannot translate your base_filter #{inspect(base_filter)} into sql. You must provide the `base_filter_sql` option, or skip unique indexes with `skip_unique_indexes`"
Cannot create a unique index for a resource with a base filter without also configuring `base_filter_sql`.
You must provide the `base_filter_sql` option, or skip unique indexes with `skip_unique_indexes`"
"""
end
@ -1533,6 +1658,8 @@ defmodule AshPostgres.MigrationGenerator do
|> Map.update!(:attributes, fn attributes ->
Enum.map(attributes, &load_attribute(&1, snapshot.table))
end)
|> Map.put_new(:check_constraints, [])
|> Map.update!(:check_constraints, &load_check_constraints/1)
|> Map.update!(:repo, &String.to_atom/1)
|> Map.put_new(:multitenancy, %{
attribute: nil,
@ -1542,6 +1669,16 @@ defmodule AshPostgres.MigrationGenerator do
|> Map.update!(:multitenancy, &load_multitenancy/1)
end
defp load_check_constraints(constraints) do
Enum.map(constraints, fn constraint ->
Map.update!(constraint, :attribute, fn attribute ->
attribute
|> List.wrap()
|> Enum.map(&String.to_atom/1)
end)
end)
end
defp load_multitenancy(multitenancy) do
multitenancy
|> Map.update!(:strategy, fn strategy -> strategy && String.to_atom(strategy) end)

View file

@ -546,4 +546,55 @@ defmodule AshPostgres.MigrationGenerator.Operation do
end
end
end
defmodule AddCheckConstraint do
@moduledoc false
defstruct [:table, :constraint, :multitenancy, :old_multitenancy, no_phase: true]
def up(%{
constraint: %{
name: name,
check: check,
base_filter: base_filter
},
table: table
}) do
if base_filter do
"create constraint(:#{table}, :#{name}, check: \"#{base_filter} AND #{check}\")"
else
"create constraint(:#{table}, :#{name}, check: \"#{check}\")"
end
end
def down(%{
constraint: %{name: name},
table: table
}) do
"drop_if_exists constraint(:#{table}, :#{name})"
end
end
defmodule RemoveCheckConstraint do
@moduledoc false
defstruct [:table, :constraint, :multitenancy, :old_multitenancy, no_phase: true]
def up(%{constraint: %{name: name}, table: table}) do
"drop_if_exists constraint(:#{table}, :#{name})"
end
def down(%{
constraint: %{
name: name,
check: check,
base_filter: base_filter
},
table: table
}) do
if base_filter do
"create constraint(:#{table}, :#{name}, check: \"#{base_filter} AND #{check}\")"
else
"create constraint(:#{table}, :#{name}, check: \"#{check}\")"
end
end
end
end

View file

@ -0,0 +1,54 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"name": "id",
"primary_key?": true,
"references": null,
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "resource_id",
"primary_key?": false,
"references": {
"destination_field": "id",
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "comment_ratings_id_fkey",
"on_delete": null,
"on_update": null,
"table": "comments"
},
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "score",
"primary_key?": false,
"references": null,
"type": "bigint"
}
],
"base_filter": null,
"check_constraints": [],
"has_create_action": true,
"hash": "38D98E7F5B0891597BF0D1A1632638954CB7721E01310F989A41D1379E470DF8",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshPostgres.TestRepo",
"table": "comment_ratings"
}

View file

@ -0,0 +1,63 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"name": "id",
"primary_key?": true,
"references": null,
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "likes",
"primary_key?": false,
"references": null,
"type": "bigint"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "post_id",
"primary_key?": false,
"references": {
"destination_field": "id",
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "special_name_fkey",
"on_delete": "delete",
"on_update": "update",
"table": "posts"
},
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "title",
"primary_key?": false,
"references": null,
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"has_create_action": true,
"hash": "AC27C8DBB84D6687136A96A5E1393042E31235B847365E5DB26DE57F5FC55604",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshPostgres.TestRepo",
"table": "comments"
}

View file

@ -0,0 +1,34 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "nil",
"generated?": true,
"name": "id",
"primary_key?": true,
"references": null,
"type": "bigint"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "title",
"primary_key?": false,
"references": null,
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"has_create_action": true,
"hash": "38D842CAAC012D0973D6A661E8EF0C429BDB535E3AB78EC4F24C951ED323FEFE",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshPostgres.TestRepo",
"table": "integer_posts"
}

View file

@ -0,0 +1,54 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"name": "id",
"primary_key?": true,
"references": null,
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "resource_id",
"primary_key?": false,
"references": {
"destination_field": "id",
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "post_ratings_id_fkey",
"on_delete": null,
"on_update": null,
"table": "posts"
},
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "score",
"primary_key?": false,
"references": null,
"type": "bigint"
}
],
"base_filter": null,
"check_constraints": [],
"has_create_action": true,
"hash": "04586179DB8456574D279F2B6BE7AAEAC78D41E556572970B373607084782402",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshPostgres.TestRepo",
"table": "post_ratings"
}

View file

@ -0,0 +1,88 @@
{
"attributes": [
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "category",
"primary_key?": false,
"references": null,
"type": "citext"
},
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"name": "id",
"primary_key?": true,
"references": null,
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "price",
"primary_key?": false,
"references": null,
"type": "bigint"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "public",
"primary_key?": false,
"references": null,
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "score",
"primary_key?": false,
"references": null,
"type": "bigint"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "title",
"primary_key?": false,
"references": null,
"type": "text"
},
{
"allow_nil?": true,
"default": "\"sponsored\"",
"generated?": false,
"name": "type",
"primary_key?": false,
"references": null,
"type": "text"
}
],
"base_filter": "type = 'sponsored'",
"check_constraints": [
{
"attribute": [
"price"
],
"base_filter": "type = 'sponsored'",
"check": "price > 0",
"name": "price_must_be_positive"
}
],
"has_create_action": true,
"hash": "AF0084C380DBCD40FCCB82B49E7B36299087EBC0F46E2664524F47C0757D8EB6",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshPostgres.TestRepo",
"table": "posts"
}

View file

@ -0,0 +1,59 @@
defmodule AshPostgres.TestRepo.Migrations.MigrateResources7 do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:posts) do
modify :score, :bigint
add :price, :bigint
end
create constraint(:posts, :price_must_be_positive, check: "type = 'sponsored' AND price > 0")
alter table(:post_ratings) do
modify :score, :bigint
end
alter table(:integer_posts) do
modify :id, :bigint
end
alter table(:comments) do
modify :likes, :bigint
end
alter table(:comment_ratings) do
modify :score, :bigint
end
end
def down do
alter table(:comment_ratings) do
modify :score, :integer
end
alter table(:comments) do
modify :likes, :integer
end
alter table(:integer_posts) do
modify :id, :integer
end
alter table(:post_ratings) do
modify :score, :integer
end
drop_if_exists constraint(:posts, :price_must_be_positive)
alter table(:posts) do
remove :price
modify :score, :integer
end
end
end

15
test/constraint_test.exs Normal file
View file

@ -0,0 +1,15 @@
defmodule AshPostgres.ConstraintTest do
@moduledoc false
use AshPostgres.RepoCase, async: false
alias AshPostgres.Test.{Api, Post}
require Ash.Query
test "constraint messages are properly raised" do
assert_raise Ash.Error.Invalid, ~r/yo, bad price/, fn ->
Post
|> Ash.Changeset.new(%{title: "title", price: -1})
|> Api.create!()
end
end
end

View file

@ -559,6 +559,95 @@ defmodule AshPostgres.MigrationGeneratorTest do
end
end
describe "check constraints" do
setup do
on_exit(fn ->
File.rm_rf!("test_snapshots_path")
File.rm_rf!("test_migration_path")
end)
end
test "when added, the constraint is created" do
defposts do
attributes do
uuid_primary_key(:id)
attribute(:price, :integer)
end
postgres do
check_constraints do
check_constraint(:price, "price_must_be_positive", check: "price > 0")
end
end
end
defapi([Post])
AshPostgres.MigrationGenerator.generate(Api,
snapshot_path: "test_snapshots_path",
migration_path: "test_migration_path",
quiet: true,
format: false
)
assert file =
"test_migration_path/**/*_migrate_resources*.exs"
|> Path.wildcard()
|> Enum.sort()
|> Enum.at(0)
assert File.read!(file) =~
~S[create constraint(:posts, :price_must_be_positive, check: "price > 0")]
end
test "when removed, the constraint is dropped before modification" do
defposts do
attributes do
uuid_primary_key(:id)
attribute(:price, :integer)
end
postgres do
check_constraints do
check_constraint(:price, "price_must_be_positive", check: "price > 0")
end
end
end
defapi([Post])
AshPostgres.MigrationGenerator.generate(Api,
snapshot_path: "test_snapshots_path",
migration_path: "test_migration_path",
quiet: true,
format: false
)
defposts do
attributes do
uuid_primary_key(:id)
attribute(:price, :integer)
end
end
AshPostgres.MigrationGenerator.generate(Api,
snapshot_path: "test_snapshots_path",
migration_path: "test_migration_path",
quiet: true,
format: false
)
assert file =
"test_migration_path/**/*_migrate_resources*.exs"
|> Path.wildcard()
|> Enum.sort()
|> Enum.at(1)
assert File.read!(file) =~
~S[drop_if_exists constraint(:posts, :price_must_be_positive)]
end
end
describe "polymorphic resources" do
setup do
on_exit(fn ->

View file

@ -6,6 +6,14 @@ defmodule AshPostgres.Test.Post do
postgres do
table "posts"
repo AshPostgres.TestRepo
base_filter_sql "type = 'sponsored'"
check_constraints do
check_constraint(:price, "price_must_be_positive",
message: "yo, bad price",
check: "price > 0"
)
end
end
resource do
@ -35,6 +43,7 @@ defmodule AshPostgres.Test.Post do
attribute(:public, :boolean)
attribute(:category, :ci_string)
attribute(:type, :atom, default: :sponsored, private?: true, writable?: false)
attribute(:price, :integer)
end
relationships do