diff --git a/priv/resource_snapshots/test_repo/points/20250313112823.json b/priv/resource_snapshots/test_repo/points/20250313112823.json new file mode 100644 index 00000000..83a5bc87 --- /dev/null +++ b/priv/resource_snapshots/test_repo/points/20250313112823.json @@ -0,0 +1,47 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": [ + "array", + "float" + ] + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "1E2378D30CF657B673E7EE140FDD4CA067E23FB8862A8F05D35A3F680EBA06B4", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "points_id_index", + "keys": [ + { + "type": "atom", + "value": "id" + } + ], + "name": "id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "points" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/posts/20250313112823.json b/priv/resource_snapshots/test_repo/posts/20250313112823.json new file mode 100644 index 00000000..cd1bb56d --- /dev/null +++ b/priv/resource_snapshots/test_repo/posts/20250313112823.json @@ -0,0 +1,580 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "1", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "version", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "title_column", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "not_selected_by_default", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "datetime", + "type": "timestamptz(6)" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "score", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "limited_score", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "public", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "category", + "type": "citext" + }, + { + "allow_nil?": true, + "default": "\"sponsored\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "price", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "\"0\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "decimal", + "type": "decimal" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "status_enum", + "type": "status" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "metadata", + "type": "map" + }, + { + "allow_nil?": false, + "default": "2", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "constrained_int", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "point", + "type": [ + "array", + "float" + ] + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "composite_point", + "type": "custom_point" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "string_point", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "stuff", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "list_of_stuff", + "type": [ + "array", + "map" + ] + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "uniq_one", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "uniq_two", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "uniq_custom_one", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "uniq_custom_two", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "uniq_on_upper", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "uniq_if_contains_foo", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "list_containing_nils", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "ltree_unescaped", + "type": "ltree" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "ltree_escaped", + "type": "ltree" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "created_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "timestamptz(6)" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "posts_organization_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "orgs" + }, + "size": null, + "source": "organization_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "posts_parent_post_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "posts" + }, + "size": null, + "source": "parent_post_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "posts_author_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "authors" + }, + "size": null, + "source": "author_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "posts_db_point_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "points" + }, + "size": null, + "source": "db_point_id", + "type": [ + "array", + "float" + ] + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "posts_db_string_point_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "string_points" + }, + "size": null, + "source": "db_string_point_id", + "type": "text" + } + ], + "base_filter": "type = 'sponsored'", + "check_constraints": [ + { + "attribute": [ + "price" + ], + "base_filter": "type = 'sponsored'", + "check": "price > 0", + "name": "price_must_be_positive" + } + ], + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": true, + "error_fields": [ + "uniq_custom_one", + "uniq_custom_two" + ], + "fields": [ + { + "type": "atom", + "value": "uniq_custom_one" + }, + { + "type": "atom", + "value": "uniq_custom_two" + } + ], + "include": null, + "message": "dude what the heck", + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": true, + "using": null, + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "8254859F1DED84E83F9565AB104579D5DEF7B9C756EFC3A2F156CC614B87164F", + "identities": [ + { + "all_tenants?": false, + "base_filter": "type = 'sponsored'", + "index_name": "posts_uniq_if_contains_foo_index", + "keys": [ + { + "type": "atom", + "value": "uniq_if_contains_foo" + } + ], + "name": "uniq_if_contains_foo", + "nils_distinct?": true, + "where": "(uniq_if_contains_foo LIKE '%foo%')" + }, + { + "all_tenants?": false, + "base_filter": "type = 'sponsored'", + "index_name": "posts_uniq_on_upper_index", + "keys": [ + { + "type": "string", + "value": "(UPPER(uniq_on_upper))" + } + ], + "name": "uniq_on_upper", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": "type = 'sponsored'", + "index_name": "posts_uniq_one_and_two_index", + "keys": [ + { + "type": "atom", + "value": "uniq_one" + }, + { + "type": "atom", + "value": "uniq_two" + } + ], + "name": "uniq_one_and_two", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "posts" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/string_points/20250313112823.json b/priv/resource_snapshots/test_repo/string_points/20250313112823.json new file mode 100644 index 00000000..cfdf889c --- /dev/null +++ b/priv/resource_snapshots/test_repo/string_points/20250313112823.json @@ -0,0 +1,44 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "43DADF8B94BBDBD802AE3D70ED67791CC968478B7EE6AEBE5B5F8F676DB323BE", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "string_points_id_index", + "keys": [ + { + "type": "atom", + "value": "id" + } + ], + "name": "id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "string_points" +} \ No newline at end of file diff --git a/priv/test_repo/migrations/20250313112823_migrate_resources51.exs b/priv/test_repo/migrations/20250313112823_migrate_resources51.exs new file mode 100644 index 00000000..fe51caf5 --- /dev/null +++ b/priv/test_repo/migrations/20250313112823_migrate_resources51.exs @@ -0,0 +1,67 @@ +defmodule AshPostgres.TestRepo.Migrations.MigrateResources51 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 + create table(:string_points, primary_key: false) do + add(:id, :text, null: false, primary_key: true) + end + + create(unique_index(:string_points, [:id], name: "string_points_id_index")) + + create table(:points, primary_key: false) do + add(:id, {:array, :float}, null: false, primary_key: true) + end + + create(unique_index(:points, [:id], name: "points_id_index")) + + alter table(:posts) do + add(:string_point, :text) + + add( + :db_point_id, + references(:points, + column: :id, + name: "posts_db_point_id_fkey", + type: {:array, :float}, + prefix: "public" + ) + ) + + add( + :db_string_point_id, + references(:string_points, + column: :id, + name: "posts_db_string_point_id_fkey", + type: :text, + prefix: "public" + ) + ) + end + end + + def down do + drop(constraint(:posts, "posts_db_point_id_fkey")) + + drop(constraint(:posts, "posts_db_string_point_id_fkey")) + + alter table(:posts) do + remove(:db_string_point_id) + remove(:db_point_id) + remove(:string_point) + end + + drop_if_exists(unique_index(:points, [:id], name: "points_id_index")) + + drop(table(:points)) + + drop_if_exists(unique_index(:string_points, [:id], name: "string_points_id_index")) + + drop(table(:string_points)) + end +end diff --git a/test/support/domain.ex b/test/support/domain.ex index c2d867a7..8af874e4 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -32,6 +32,8 @@ defmodule AshPostgres.Test.Domain do resource(AshPostgres.Test.PostFollower) resource(AshPostgres.Test.StatefulPostFollower) resource(AshPostgres.Test.PostWithEmptyUpdate) + resource(AshPostgres.Test.DbPoint) + resource(AshPostgres.Test.DbStringPoint) end authorization do diff --git a/test/support/resources/db_point.ex b/test/support/resources/db_point.ex new file mode 100644 index 00000000..25b9ca93 --- /dev/null +++ b/test/support/resources/db_point.ex @@ -0,0 +1,34 @@ +defmodule AshPostgres.Test.DbPoint do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + postgres do + table("points") + repo(AshPostgres.TestRepo) + end + + actions do + defaults([:read, :destroy]) + + create :create do + primary?(true) + accept([:id]) + upsert?(true) + upsert_identity(:id) + end + end + + attributes do + attribute(:id, AshPostgres.Test.Point) do + public?(true) + primary_key?(true) + allow_nil?(false) + end + end + + identities do + identity(:id, [:id]) + end +end diff --git a/test/support/resources/db_string_point.ex b/test/support/resources/db_string_point.ex new file mode 100644 index 00000000..8908527b --- /dev/null +++ b/test/support/resources/db_string_point.ex @@ -0,0 +1,34 @@ +defmodule AshPostgres.Test.DbStringPoint do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + postgres do + table("string_points") + repo(AshPostgres.TestRepo) + end + + actions do + defaults([:read, :destroy]) + + create :create do + primary?(true) + accept([:id]) + upsert?(true) + upsert_identity(:id) + end + end + + attributes do + attribute(:id, AshPostgres.Test.StringPoint) do + public?(true) + primary_key?(true) + allow_nil?(false) + end + end + + identities do + identity(:id, [:id]) + end +end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 9fb97cdd..098d0ccf 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -456,6 +456,7 @@ defmodule AshPostgres.Test.Post do attribute(:point, AshPostgres.Test.Point, public?: true) attribute(:composite_point, AshPostgres.Test.CompositePoint, public?: true) + attribute(:string_point, AshPostgres.Test.StringPoint, public?: true) attribute(:stuff, :map, public?: true) attribute(:list_of_stuff, {:array, :map}, public?: true) attribute(:uniq_one, :string, public?: true) @@ -541,6 +542,18 @@ defmodule AshPostgres.Test.Post do filter(expr(parent(title) == title and parent(id) != id)) end + has_many :posts_with_matching_point, __MODULE__ do + public?(true) + no_attributes?(true) + filter(expr(parent(point) == point and parent(id) != id)) + end + + has_many :posts_with_matching_string_point, __MODULE__ do + public?(true) + no_attributes?(true) + filter(expr(parent(string_point) == string_point and parent(id) != id)) + end + has_many(:comments, AshPostgres.Test.Comment, destination_attribute: :post_id, public?: true) has_one :latest_comment, AshPostgres.Test.Comment do @@ -658,6 +671,18 @@ defmodule AshPostgres.Test.Post do end has_many(:permalinks, AshPostgres.Test.Permalink) + + belongs_to :db_point, AshPostgres.Test.DbPoint do + public?(true) + allow_nil?(true) + attribute_type(AshPostgres.Test.Point) + end + + belongs_to :db_string_point, AshPostgres.Test.DbStringPoint do + public?(true) + allow_nil?(true) + attribute_type(AshPostgres.Test.StringPoint) + end end validations do diff --git a/test/support/types/string_point.ex b/test/support/types/string_point.ex new file mode 100644 index 00000000..0550a904 --- /dev/null +++ b/test/support/types/string_point.ex @@ -0,0 +1,60 @@ +defmodule AshPostgres.Test.StringPoint do + @moduledoc false + use Ash.Type + + defstruct [:x, :y, :z] + + @type t :: %__MODULE__{ + x: float(), + y: float(), + z: float() + } + + def storage_type(_), do: :string + + def cast_input(nil, _), do: {:ok, nil} + + def cast_input(%__MODULE__{} = a, _) do + {:ok, a} + end + + def cast_input({x, y, z}, _) when is_float(x) and is_float(y) and is_float(z) do + {:ok, %__MODULE__{x: x, y: y, z: z}} + end + + def cast_input(enc, _) when is_binary(enc) do + {:ok, parse!(enc)} + end + + def cast_input(_, _), do: :error + + def cast_stored(nil, _), do: {:ok, nil} + + def cast_stored(enc, _) when is_binary(enc) do + {:ok, parse!(enc)} + end + + def cast_stored(_, _) do + :error + end + + def dump_to_native(nil, _), do: {:ok, nil} + + def dump_to_native(%__MODULE__{x: x, y: y, z: z}, _) do + enc = Enum.map_join([x, y, z], ",", &Float.to_string/1) + + {:ok, enc} + end + + def dump_to_native(_, _) do + :error + end + + defp parse!(enc) when is_binary(enc) do + [x, y, z] = + String.split(enc, ",") + |> Enum.map(&String.to_float/1) + + %__MODULE__{x: x, y: y, z: z} + end +end diff --git a/test/type_test.exs b/test/type_test.exs index 856a2da3..39a809e8 100644 --- a/test/type_test.exs +++ b/test/type_test.exs @@ -51,4 +51,53 @@ defmodule AshPostgres.Test.TypeTest do |> Ash.Query.filter(point == ^{1.0, 2.0, 3.0}) |> Ash.read!() end + + test "complex custom types can be used in relationships" do + [p | _] = + for _ <- 1..4//1 do + Post + |> Ash.Changeset.for_create(:create, %{ + point: {1.0, 2.0, 3.0}, + string_point: "1.0,2.0,3.0" + }) + |> Ash.create!() + end + + p = p |> Ash.load!([:posts_with_matching_point, :posts_with_matching_string_point]) + + assert Enum.count(p.posts_with_matching_point) == 3 + assert Enum.count(p.posts_with_matching_string_point) == 3 + + %{id: id} = + Post + |> Ash.Changeset.for_create(:create) + |> Ash.Changeset.manage_relationship(:db_point, %{id: {2.0, 3.0, 4.0}}, type: :create) + |> Ash.create!() + + [p] = + Post + |> Ash.Query.for_read(:read) + |> Ash.Query.load(:db_point) + |> Ash.Query.filter(id == ^id) + |> Ash.read!() + + assert p.db_point_id == {2.0, 3.0, 4.0} + assert p.db_point.id == {2.0, 3.0, 4.0} + + %{id: id} = + Post + |> Ash.Changeset.for_create(:create) + |> Ash.Changeset.manage_relationship(:db_string_point, %{id: "2.0,3.0,4.0"}, type: :create) + |> Ash.create!() + + [p] = + Post + |> Ash.Query.for_read(:read) + |> Ash.Query.load(:db_string_point) + |> Ash.Query.filter(id == ^id) + |> Ash.read!() + + assert p.string_point_id == %{x: 2.0, y: 3.0, z: 4.0} + assert p.string_point.id == %{x: 2.0, y: 3.0, z: 4.0} + end end