diff --git a/lib/migration_generator/migration_generator.ex b/lib/migration_generator/migration_generator.ex index f600451e..7185c2c3 100644 --- a/lib/migration_generator/migration_generator.ex +++ b/lib/migration_generator/migration_generator.ex @@ -54,14 +54,28 @@ defmodule AshPostgres.MigrationGenerator do tenant_snapshots_to_include_in_global = tenant_snapshots |> Enum.filter(& &1.multitenancy.global) - |> Enum.map(&Map.put(&1, :multitenancy, %{strategy: nil, attribute: nil, global: nil})) + |> Enum.map( + &Map.put(&1, :multitenancy, %{ + strategy: nil, + attribute: nil, + ancestor_attributes: [], + global: nil + }) + ) snapshots = snapshots ++ tenant_snapshots_to_include_in_global unmanaged_tenant_snapshots_to_include_in_global = unmanaged_tenant_snapshots |> Enum.filter(& &1.multitenancy.global) - |> Enum.map(&Map.put(&1, :multitenancy, %{strategy: nil, attribute: nil, global: nil})) + |> Enum.map( + &Map.put(&1, :multitenancy, %{ + strategy: nil, + attribute: nil, + ancestor_attributes: [], + global: nil + }) + ) unmanaged_snapshots = unmanaged_snapshots ++ unmanaged_tenant_snapshots_to_include_in_global @@ -2005,6 +2019,7 @@ defmodule AshPostgres.MigrationGenerator do empty?: true, multitenancy: %{ attribute: nil, + ancestor_attributes: [], strategy: nil, global: nil } @@ -3165,9 +3180,9 @@ defmodule AshPostgres.MigrationGenerator do multitenancy = if tenant? do - %{strategy: :context, attribute: nil, global: nil} + %{strategy: :context, attribute: nil, ancestor_attributes: [], global: nil} else - %{strategy: nil, attribute: nil, global: nil} + %{strategy: nil, attribute: nil, ancestor_attributes: [], global: nil} end {drop_ops, rename_map, schema_move_map, loaded} = @@ -3778,11 +3793,13 @@ defmodule AshPostgres.MigrationGenerator do defp multitenancy(resource) do strategy = Ash.Resource.Info.multitenancy_strategy(resource) attribute = Ash.Resource.Info.multitenancy_attribute(resource) + ancestor_attributes = Ash.Resource.Info.multitenancy_ancestor_attributes(resource) global = Ash.Resource.Info.multitenancy_global?(resource) %{ strategy: strategy, attribute: attribute, + ancestor_attributes: ancestor_attributes, global: global } end @@ -4321,6 +4338,10 @@ defmodule AshPostgres.MigrationGenerator do multitenancy |> Map.update!(:strategy, fn strategy -> strategy && maybe_to_atom(strategy) end) |> Map.update!(:attribute, fn attribute -> attribute && maybe_to_atom(attribute) end) + |> Map.put_new(:ancestor_attributes, []) + |> Map.update!(:ancestor_attributes, fn ancestor_attributes -> + Enum.map(ancestor_attributes || [], &maybe_to_atom/1) + end) end defp load_attribute(attribute, table) do diff --git a/lib/migration_generator/operation.ex b/lib/migration_generator/operation.ex index 7f49c228..6c8e0fca 100644 --- a/lib/migration_generator/operation.ex +++ b/lib/migration_generator/operation.ex @@ -83,24 +83,45 @@ defmodule AshPostgres.MigrationGenerator.Operation do def reference_type(%{type: type}, _), do: type - def with_match(reference, source_attribute \\ nil) + def tenant_columns(multitenancy) do + (Map.get(multitenancy, :ancestor_attributes) || []) ++ List.wrap(multitenancy.attribute) + end + + def with_match(reference, source_multitenancy \\ nil) def with_match( %{ primary_key?: false, destination_attribute: reference_attribute, - multitenancy: %{strategy: :attribute, attribute: destination_attribute} + multitenancy: %{strategy: :attribute} = destination_multitenancy } = reference, - source_attribute + source_multitenancy ) - when not is_nil(source_attribute) and reference_attribute != destination_attribute do - with_targets = - [{as_atom(source_attribute), as_atom(destination_attribute)}] - |> Enum.into(reference.match_with || %{}) - |> with_targets() + when not is_nil(source_multitenancy) do + # Tenant columns (ancestor_attributes ++ [attribute]) are paired positionally. + # With hierarchies of different depths, only the shared prefix is paired. + tenant_targets = + tenant_columns(source_multitenancy) + |> Enum.zip(tenant_columns(destination_multitenancy)) + |> Enum.map(fn {source, destination} -> {as_atom(source), as_atom(destination)} end) + |> Enum.reject(fn {_source, destination} -> + destination == as_atom(reference_attribute) + end) + + if tenant_targets == [] do + with_match(reference, nil) + else + tenant_sources = Enum.map(tenant_targets, &elem(&1, 0)) + + with_targets = + (reference.match_with || %{}) + |> Enum.reject(fn {source, _destination} -> as_atom(source) in tenant_sources end) + |> Enum.concat(tenant_targets) + |> with_targets() - # We can only have match: :full here, this gets validated by a Transformer - join([with_targets, "match: :full"]) + # We can only have match: :full here, this gets validated by a Transformer + join([with_targets, "match: :full"]) + end end def with_match(reference, _) do @@ -114,7 +135,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do end end - def with_targets(targets) when is_map(targets) do + def with_targets(targets) when is_map(targets) or is_list(targets) do targets_string = targets |> Enum.map_join(", ", fn {source, destination} -> "#{source}: :#{destination}" end) @@ -132,7 +153,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do def index_keys(keys, all_tenants?, multitenancy) do if multitenancy.strategy == :attribute and not all_tenants? do - Enum.uniq([multitenancy.attribute | keys]) + Enum.uniq(tenant_columns(multitenancy) ++ keys) else keys end @@ -263,7 +284,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do import Helper def up(%{ - multitenancy: %{strategy: :attribute, attribute: source_attribute}, + multitenancy: %{strategy: :attribute} = source_multitenancy, attribute: %{ references: @@ -275,7 +296,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do } = reference } = attribute }) do - with_match = with_match(reference, source_attribute) + with_match = with_match(reference, source_multitenancy) size = if attribute[:size] do @@ -750,7 +771,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do end defp reference( - %{strategy: :attribute, attribute: source_attribute}, + %{strategy: :attribute} = source_multitenancy, %{ references: %{ @@ -767,7 +788,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do destination_schema end - with_match = with_match(reference, source_attribute) + with_match = with_match(reference, source_multitenancy) join([ "references(:#{as_atom(table)}, column: #{inspect(reference_attribute)}", @@ -1263,14 +1284,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do where: where, multitenancy: multitenancy }) do - keys = - case multitenancy do - %{strategy: :attribute, attribute: attribute} when attribute != source -> - [attribute, source] - - _ -> - [source] - end + keys = reference_index_keys(multitenancy, source) opts = join([ @@ -1286,14 +1300,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do end def down(%{schema: schema, source: source, table: table, multitenancy: multitenancy}) do - keys = - case multitenancy do - %{strategy: :attribute, attribute: attribute} when attribute != source -> - [attribute, source] - - _ -> - [source] - end + keys = reference_index_keys(multitenancy, source) opts = join([ @@ -1306,6 +1313,16 @@ defmodule AshPostgres.MigrationGenerator.Operation do "drop_if_exists index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{opts})" end end + + defp reference_index_keys(multitenancy, source) do + case multitenancy do + %{strategy: :attribute} -> + Enum.reject(tenant_columns(multitenancy), &(&1 == source)) ++ [source] + + _ -> + [source] + end + end end defmodule AddPrimaryKey do diff --git a/lib/verifiers/prevent_attribute_multitenancy_and_non_full_match_type.ex b/lib/verifiers/prevent_attribute_multitenancy_and_non_full_match_type.ex index 484ba82d..6546e426 100644 --- a/lib/verifiers/prevent_attribute_multitenancy_and_non_full_match_type.ex +++ b/lib/verifiers/prevent_attribute_multitenancy_and_non_full_match_type.ex @@ -51,7 +51,8 @@ defmodule AshPostgres.Verifiers.PreventAttributeMultitenancyAndNonFullMatchType end defp targets_multitenancy_attribute?(relationship) do - relationship.destination_attribute == - Ash.Resource.Info.multitenancy_attribute(relationship.destination) + relationship.destination_attribute in Ash.Resource.Info.multitenancy_attributes( + relationship.destination + ) end end diff --git a/test/migration_generator_test.exs b/test/migration_generator_test.exs index 69d23304..ac8028ac 100644 --- a/test/migration_generator_test.exs +++ b/test/migration_generator_test.exs @@ -4815,6 +4815,186 @@ defmodule AshPostgres.MigrationGeneratorTest do end end + describe "multitenancy with ancestor attributes" do + setup do + :ok + end + + test "identity unique indexes are prefixed with the ancestor attributes and the snapshot round-trips", + %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defresource Customer, "customers" do + multitenancy do + strategy(:attribute) + attribute(:department_id) + ancestor_attributes([:organization_id]) + end + + identities do + identity(:unique_name, [:name]) + end + + attributes do + uuid_primary_key(:id) + attribute(:organization_id, :uuid, allow_nil?: false, public?: true) + attribute(:department_id, :uuid, allow_nil?: false, public?: true) + attribute(:name, :string, allow_nil?: false, public?: true) + end + end + + defdomain([Customer]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + assert [file] = + Path.wildcard("#{migration_path}/**/*_migrate_resources*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + + assert File.read!(file) =~ + ~S{create unique_index(:customers, [:organization_id, :department_id, :name], name: "customers_unique_name_index")} + + # A second run must load the ancestor attributes back from the snapshot and + # detect no changes + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + assert [^file] = + Path.wildcard("#{migration_path}/**/*_migrate_resources*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + end + + test "references pair the ancestor attributes with the destination's tenant columns", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defresource Department, "departments" do + multitenancy do + strategy(:attribute) + attribute(:organization_id) + end + + attributes do + uuid_primary_key(:id, writable?: true) + attribute(:secondary_id, :uuid, public?: true) + attribute(:organization_id, :uuid, allow_nil?: false, public?: true) + end + end + + defresource CustomerGroup, "customer_groups" do + multitenancy do + strategy(:attribute) + attribute(:department_id) + ancestor_attributes([:organization_id]) + end + + attributes do + uuid_primary_key(:id, writable?: true) + attribute(:secondary_id, :uuid, public?: true) + attribute(:organization_id, :uuid, allow_nil?: false, public?: true) + attribute(:department_id, :uuid, allow_nil?: false, public?: true) + end + end + + defresource Customer, "customers" do + multitenancy do + strategy(:attribute) + attribute(:department_id) + ancestor_attributes([:organization_id]) + end + + attributes do + uuid_primary_key(:id, writable?: true) + attribute(:organization_id, :uuid, allow_nil?: false, public?: true) + attribute(:name, :string, public?: true) + end + + relationships do + belongs_to(:department, Department, destination_attribute: :secondary_id, public?: true) + belongs_to(:group, CustomerGroup, destination_attribute: :secondary_id, public?: true) + end + end + + defdomain([Department, CustomerGroup, Customer]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + assert [file] = + Path.wildcard("#{migration_path}/**/*_migrate_resources*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + + file_content = File.read!(file) + + # Destination scoped only by the ancestor: only the shared prefix is paired + assert file_content =~ + ~S{references(:departments, column: :secondary_id, with: [organization_id: :organization_id], match: :full, name: "customers_department_id_fkey", type: :uuid, prefix: "public")} + + # Destination with the same hierarchy: both tenant columns are paired + assert file_content =~ + ~S{references(:customer_groups, column: :secondary_id, with: [organization_id: :organization_id, department_id: :department_id], match: :full, name: "customers_group_id_fkey", type: :uuid, prefix: "public")} + end + + test "a deeper ancestor chain prefixes indexes in the declared order", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defresource Task, "tasks" do + multitenancy do + strategy(:attribute) + attribute(:team_id) + ancestor_attributes([:organization_id, :department_id]) + end + + identities do + identity(:unique_name, [:name]) + end + + attributes do + uuid_primary_key(:id) + attribute(:organization_id, :uuid, allow_nil?: false, public?: true) + attribute(:department_id, :uuid, allow_nil?: false, public?: true) + attribute(:team_id, :uuid, allow_nil?: false, public?: true) + attribute(:name, :string, allow_nil?: false, public?: true) + end + end + + defdomain([Task]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + assert [file] = + Path.wildcard("#{migration_path}/**/*_migrate_resources*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + + assert File.read!(file) =~ + ~S{create unique_index(:tasks, [:organization_id, :department_id, :team_id, :name], name: "tasks_unique_name_index")} + end + end + describe "decimal precision and scale" do setup do :ok diff --git a/test/multitenancy_ancestor_attributes_test.exs b/test/multitenancy_ancestor_attributes_test.exs new file mode 100644 index 00000000..c9fea2df --- /dev/null +++ b/test/multitenancy_ancestor_attributes_test.exs @@ -0,0 +1,182 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.MultitenancyAncestorAttributesTest do + use AshPostgres.RepoCase, async: false + + require Ash.Query + + alias AshPostgres.MultitenancyTest.{DepartmentPost, DepartmentPostComment, DepartmentTenant} + + setup do + organization_id = Ash.UUID.generate() + other_organization_id = Ash.UUID.generate() + department_a = %DepartmentTenant{id: Ash.UUID.generate(), organization_id: organization_id} + department_b = %DepartmentTenant{id: Ash.UUID.generate(), organization_id: organization_id} + + # The discriminating tenant below reuses department_a's id under another + # organization: the department filter alone matches, so only the ancestor + # filter can tell them apart. + wrong_organization_department = %DepartmentTenant{ + id: department_a.id, + organization_id: other_organization_id + } + + [ + organization_id: organization_id, + department_a: department_a, + department_b: department_b, + wrong_organization_department: wrong_organization_department + ] + end + + test "create stamps the derived organization_id alongside the tenant's department_id", %{ + organization_id: organization_id, + department_a: department_a + } do + post = + DepartmentPost + |> Ash.Changeset.for_create(:create, %{name: "post"}, tenant: department_a) + |> Ash.create!() + + assert post.department_id == department_a.id + assert post.organization_id == organization_id + end + + test "a tenant with the same department id but another organization reads nothing", %{ + department_a: department_a, + wrong_organization_department: wrong_organization_department + } do + post = + DepartmentPost + |> Ash.Changeset.for_create(:create, %{name: "post"}, tenant: department_a) + |> Ash.create!() + + assert [%{id: read_id}] = DepartmentPost |> Ash.Query.set_tenant(department_a) |> Ash.read!() + assert read_id == post.id + + assert [] = + DepartmentPost |> Ash.Query.set_tenant(wrong_organization_department) |> Ash.read!() + end + + test "update can't reach a row whose ancestors don't match the tenant", %{ + department_a: department_a, + wrong_organization_department: wrong_organization_department + } do + post = + DepartmentPost + |> Ash.Changeset.for_create(:create, %{name: "before"}, tenant: department_a) + |> Ash.create!() + + assert {:error, _} = + post + |> Ash.Changeset.for_update(:update, %{name: "after"}, + tenant: wrong_organization_department + ) + |> Ash.update() + + assert [%{name: "before"}] = + DepartmentPost |> Ash.Query.set_tenant(department_a) |> Ash.read!() + end + + test "upsert conflict targets include the ancestor attributes and match the generated unique index", + %{ + department_a: department_a, + department_b: department_b + } do + # The unique index is (organization_id, department_id, name); without the + # ancestor attributes in the conflict target, ON CONFLICT wouldn't match + # any index and every upsert here would raise. + department_a_post = + DepartmentPost + |> Ash.Changeset.for_create(:upsert_by_name, %{name: "same name"}, tenant: department_a) + |> Ash.create!() + + department_b_post = + DepartmentPost + |> Ash.Changeset.for_create(:upsert_by_name, %{name: "same name"}, tenant: department_b) + |> Ash.create!() + + refute department_b_post.id == department_a_post.id + + upserted = + DepartmentPost + |> Ash.Changeset.for_create(:upsert_by_name, %{name: "same name"}, tenant: department_a) + |> Ash.create!() + + assert upserted.id == department_a_post.id + end + + test "relationship loads and aggregates exclude a comment matching the department but not the organization", + %{ + department_a: department_a, + wrong_organization_department: wrong_organization_department + } do + post = + DepartmentPost + |> Ash.Changeset.for_create(:create, %{name: "post"}, tenant: department_a) + |> Ash.create!() + + DepartmentPostComment + |> Ash.Changeset.for_create(:create, %{text: "other organization", post_id: post.id}, + tenant: wrong_organization_department + ) + |> Ash.create!() + + assert [%{comments: [], count_of_comments: 0}] = + DepartmentPost + |> Ash.Query.set_tenant(department_a) + |> Ash.Query.load([:comments, :count_of_comments]) + |> Ash.read!() + + DepartmentPostComment + |> Ash.Changeset.for_create(:create, %{text: "same tenant", post_id: post.id}, + tenant: department_a + ) + |> Ash.create!() + + assert [%{comments: [%{text: "same tenant"}], count_of_comments: 1}] = + DepartmentPost + |> Ash.Query.set_tenant(department_a) + |> Ash.Query.load([:comments, :count_of_comments]) + |> Ash.read!() + end + + test "exists over a relationship excludes a comment matching the department but not the organization", + %{ + department_a: department_a, + wrong_organization_department: wrong_organization_department + } do + post = + DepartmentPost + |> Ash.Changeset.for_create(:create, %{name: "post"}, tenant: department_a) + |> Ash.create!() + + DepartmentPostComment + |> Ash.Changeset.for_create(:create, %{text: "comment", post_id: post.id}, + tenant: wrong_organization_department + ) + |> Ash.create!() + + assert [] = + DepartmentPost + |> Ash.Query.set_tenant(department_a) + |> Ash.Query.filter(exists(comments, text == "comment")) + |> Ash.read!() + + DepartmentPostComment + |> Ash.Changeset.for_create(:create, %{text: "comment", post_id: post.id}, + tenant: department_a + ) + |> Ash.create!() + + assert [%{id: read_id}] = + DepartmentPost + |> Ash.Query.set_tenant(department_a) + |> Ash.Query.filter(exists(comments, text == "comment")) + |> Ash.read!() + + assert read_id == post.id + end +end diff --git a/test/support/multitenancy/department_tenant.ex b/test/support/multitenancy/department_tenant.ex new file mode 100644 index 00000000..3a0b7d06 --- /dev/null +++ b/test/support/multitenancy/department_tenant.ex @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.MultitenancyTest.DepartmentTenant do + @moduledoc false + defstruct [:id, :organization_id] + + defimpl Ash.ToTenant do + def to_tenant(department, _resource), do: department.id + end + + defimpl Ash.ToAncestorTenants do + def to_ancestor_tenants(department, _resource), do: [department.organization_id] + end +end diff --git a/test/support/multitenancy/domain.ex b/test/support/multitenancy/domain.ex index 44f49ff8..303893ff 100644 --- a/test/support/multitenancy/domain.ex +++ b/test/support/multitenancy/domain.ex @@ -17,6 +17,8 @@ defmodule AshPostgres.MultitenancyTest.Domain do resource(AshPostgres.MultitenancyTest.CrossTenantPostLink) resource(AshPostgres.MultitenancyTest.CompositeKeyPost) resource(AshPostgres.MultitenancyTest.NonMultitenantPostMultitenantLink) + resource(AshPostgres.MultitenancyTest.DepartmentPost) + resource(AshPostgres.MultitenancyTest.DepartmentPostComment) end authorization do diff --git a/test/support/multitenancy/resources/department_post.ex b/test/support/multitenancy/resources/department_post.ex new file mode 100644 index 00000000..2fd07f4a --- /dev/null +++ b/test/support/multitenancy/resources/department_post.ex @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.MultitenancyTest.DepartmentPost do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.MultitenancyTest.Domain, + data_layer: AshPostgres.DataLayer + + attributes do + uuid_primary_key(:id, writable?: true) + attribute(:name, :string, public?: true) + attribute(:organization_id, :uuid, public?: true) + attribute(:department_id, :uuid, public?: true) + end + + postgres do + table "department_posts" + repo AshPostgres.TestRepo + end + + actions do + default_accept(:*) + + defaults([:create, :read, :update, :destroy]) + + create :upsert_by_name do + upsert?(true) + upsert_identity(:unique_name) + upsert_fields([:name]) + end + end + + identities do + identity(:unique_name, [:name]) + end + + multitenancy do + strategy(:attribute) + attribute(:department_id) + ancestor_attributes([:organization_id]) + end + + relationships do + has_many :comments, AshPostgres.MultitenancyTest.DepartmentPostComment do + destination_attribute(:post_id) + public?(true) + end + end + + aggregates do + count(:count_of_comments, :comments) + end +end diff --git a/test/support/multitenancy/resources/department_post_comment.ex b/test/support/multitenancy/resources/department_post_comment.ex new file mode 100644 index 00000000..45e713fa --- /dev/null +++ b/test/support/multitenancy/resources/department_post_comment.ex @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.MultitenancyTest.DepartmentPostComment do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.MultitenancyTest.Domain, + data_layer: AshPostgres.DataLayer + + attributes do + uuid_primary_key(:id, writable?: true) + attribute(:text, :string, public?: true) + attribute(:organization_id, :uuid, public?: true) + attribute(:department_id, :uuid, public?: true) + end + + postgres do + table "department_post_comments" + repo AshPostgres.TestRepo + end + + actions do + default_accept(:*) + + defaults([:create, :read, :update, :destroy]) + end + + multitenancy do + strategy(:attribute) + attribute(:department_id) + ancestor_attributes([:organization_id]) + end + + relationships do + belongs_to :post, AshPostgres.MultitenancyTest.DepartmentPost do + public?(true) + end + end +end diff --git a/test_priv/resource_snapshots/test_repo/department_post_comments/20260720004228.json b/test_priv/resource_snapshots/test_repo/department_post_comments/20260720004228.json new file mode 100644 index 00000000..52a9e60b --- /dev/null +++ b/test_priv/resource_snapshots/test_repo/department_post_comments/20260720004228.json @@ -0,0 +1,106 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "text", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "organization_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "department_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "index_where": null, + "match_type": null, + "match_with": null, + "multitenancy": { + "ancestor_attributes": [ + "organization_id" + ], + "attribute": "department_id", + "global": false, + "strategy": "attribute" + }, + "name": "department_post_comments_post_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "department_posts" + }, + "scale": null, + "size": null, + "source": "post_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "BBE7D2CA277ED71A6BAE94BE975192184938175E8ACFD08EE16238AE95ECA88A", + "identities": [], + "multitenancy": { + "ancestor_attributes": [ + "organization_id" + ], + "attribute": "department_id", + "global": false, + "strategy": "attribute" + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "department_post_comments" +} diff --git a/test_priv/resource_snapshots/test_repo/department_post_comments/20260720004228.json.license b/test_priv/resource_snapshots/test_repo/department_post_comments/20260720004228.json.license new file mode 100644 index 00000000..c5bd9989 --- /dev/null +++ b/test_priv/resource_snapshots/test_repo/department_post_comments/20260720004228.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2019 ash_postgres contributors + +SPDX-License-Identifier: MIT diff --git a/test_priv/resource_snapshots/test_repo/department_posts/20260720004229.json b/test_priv/resource_snapshots/test_repo/department_posts/20260720004229.json new file mode 100644 index 00000000..3f4a36c7 --- /dev/null +++ b/test_priv/resource_snapshots/test_repo/department_posts/20260720004229.json @@ -0,0 +1,86 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "organization_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "department_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "387238C8A174D395A0F312BBDFBC3C3A13E9CE709492A29E70A35BEC36253FA9", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "department_posts_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "ancestor_attributes": [ + "organization_id" + ], + "attribute": "department_id", + "global": false, + "strategy": "attribute" + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "department_posts" +} diff --git a/test_priv/resource_snapshots/test_repo/department_posts/20260720004229.json.license b/test_priv/resource_snapshots/test_repo/department_posts/20260720004229.json.license new file mode 100644 index 00000000..c5bd9989 --- /dev/null +++ b/test_priv/resource_snapshots/test_repo/department_posts/20260720004229.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2019 ash_postgres contributors + +SPDX-License-Identifier: MIT diff --git a/test_priv/resource_snapshots_pg18/test_repo/department_post_comments/20260728023937.json b/test_priv/resource_snapshots_pg18/test_repo/department_post_comments/20260728023937.json new file mode 100644 index 00000000..f967c165 --- /dev/null +++ b/test_priv/resource_snapshots_pg18/test_repo/department_post_comments/20260728023937.json @@ -0,0 +1,106 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "text", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "organization_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "department_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "index_where": null, + "match_type": null, + "match_with": null, + "multitenancy": { + "ancestor_attributes": [ + "organization_id" + ], + "attribute": "department_id", + "global": false, + "strategy": "attribute" + }, + "name": "department_post_comments_post_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "department_posts" + }, + "scale": null, + "size": null, + "source": "post_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "1F96BF45989DDC087B7D39E750DBF85B04CCA3263D671F83B505446E102C9526", + "identities": [], + "multitenancy": { + "ancestor_attributes": [ + "organization_id" + ], + "attribute": "department_id", + "global": false, + "strategy": "attribute" + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "department_post_comments" +} diff --git a/test_priv/resource_snapshots_pg18/test_repo/department_post_comments/20260728023937.json.license b/test_priv/resource_snapshots_pg18/test_repo/department_post_comments/20260728023937.json.license new file mode 100644 index 00000000..c5bd9989 --- /dev/null +++ b/test_priv/resource_snapshots_pg18/test_repo/department_post_comments/20260728023937.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2019 ash_postgres contributors + +SPDX-License-Identifier: MIT diff --git a/test_priv/resource_snapshots_pg18/test_repo/department_posts/20260728023938.json b/test_priv/resource_snapshots_pg18/test_repo/department_posts/20260728023938.json new file mode 100644 index 00000000..a9f518e5 --- /dev/null +++ b/test_priv/resource_snapshots_pg18/test_repo/department_posts/20260728023938.json @@ -0,0 +1,86 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "organization_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "department_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "3B069C8B9F37DB625B55802A9BAFB1A5554A2208C64D3580BB7CA94CB3022E72", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "department_posts_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "ancestor_attributes": [ + "organization_id" + ], + "attribute": "department_id", + "global": false, + "strategy": "attribute" + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "department_posts" +} diff --git a/test_priv/resource_snapshots_pg18/test_repo/department_posts/20260728023938.json.license b/test_priv/resource_snapshots_pg18/test_repo/department_posts/20260728023938.json.license new file mode 100644 index 00000000..c5bd9989 --- /dev/null +++ b/test_priv/resource_snapshots_pg18/test_repo/department_posts/20260728023938.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2019 ash_postgres contributors + +SPDX-License-Identifier: MIT diff --git a/test_priv/test_repo/migrations/20260720004227_migrate_resources72.exs b/test_priv/test_repo/migrations/20260720004227_migrate_resources72.exs new file mode 100644 index 00000000..9a7b1be7 --- /dev/null +++ b/test_priv/test_repo/migrations/20260720004227_migrate_resources72.exs @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.TestRepo.Migrations.MigrateResources72 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(:department_posts, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:name, :text) + add(:organization_id, :uuid) + add(:department_id, :uuid) + end + + create( + unique_index(:department_posts, [:organization_id, :department_id, :name], + name: "department_posts_unique_name_index" + ) + ) + + create table(:department_post_comments, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:text, :text) + add(:organization_id, :uuid) + add(:department_id, :uuid) + + add( + :post_id, + references(:department_posts, + column: :id, + name: "department_post_comments_post_id_fkey", + type: :uuid, + prefix: "public" + ) + ) + end + end + + def down do + drop(constraint(:department_post_comments, "department_post_comments_post_id_fkey")) + + drop(table(:department_post_comments)) + + drop_if_exists( + unique_index(:department_posts, [:organization_id, :department_id, :name], + name: "department_posts_unique_name_index" + ) + ) + + drop(table(:department_posts)) + end +end diff --git a/test_priv/test_repo_pg18/migrations/20260728023936_migrate_resources72.exs b/test_priv/test_repo_pg18/migrations/20260728023936_migrate_resources72.exs new file mode 100644 index 00000000..f3bb40c0 --- /dev/null +++ b/test_priv/test_repo_pg18/migrations/20260728023936_migrate_resources72.exs @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.TestRepo.Migrations.MigrateResources72 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(:department_post_comments, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:text, :text) + add(:organization_id, :uuid) + add(:department_id, :uuid) + add(:post_id, :uuid) + end + + create table(:department_posts, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + end + + alter table(:department_post_comments) do + modify( + :post_id, + references(:department_posts, + column: :id, + name: "department_post_comments_post_id_fkey", + type: :uuid, + prefix: "public" + ) + ) + end + + alter table(:department_posts) do + add(:name, :text) + add(:organization_id, :uuid) + add(:department_id, :uuid) + end + + create( + unique_index(:department_posts, [:organization_id, :department_id, :name], + name: "department_posts_unique_name_index" + ) + ) + end + + def down do + drop_if_exists( + unique_index(:department_posts, [:organization_id, :department_id, :name], + name: "department_posts_unique_name_index" + ) + ) + + alter table(:department_posts) do + remove(:department_id) + remove(:organization_id) + remove(:name) + end + + drop(constraint(:department_post_comments, "department_post_comments_post_id_fkey")) + + alter table(:department_post_comments) do + modify(:post_id, :uuid) + end + + drop(table(:department_posts)) + + drop(table(:department_post_comments)) + end +end