From 7f7a7bd79f3dcb4fb28d9bdcd35ed2b842c5f8df Mon Sep 17 00:00:00 2001 From: Jinkyou Son Date: Fri, 15 May 2026 17:24:26 +0900 Subject: [PATCH 1/5] test: add integration coverage for exists/2 over embedded array attributes # Conflicts: # test/support/domain.ex --- .../20260515050043.json | 68 +++++++ .../20260515050043_migrate_resources68.exs | 22 +++ test/embedded_array_exists_test.exs | 176 ++++++++++++++++++ test/support/domain.ex | 1 + test/support/embedded_array/estimate.ex | 32 ++++ test/support/embedded_array/line_item.ex | 14 ++ test/support/embedded_array/option.ex | 20 ++ 7 files changed, 333 insertions(+) create mode 100644 priv/resource_snapshots/test_repo/embedded_array_estimates/20260515050043.json create mode 100644 priv/test_repo/migrations/20260515050043_migrate_resources68.exs create mode 100644 test/embedded_array_exists_test.exs create mode 100644 test/support/embedded_array/estimate.ex create mode 100644 test/support/embedded_array/line_item.ex create mode 100644 test/support/embedded_array/option.ex diff --git a/priv/resource_snapshots/test_repo/embedded_array_estimates/20260515050043.json b/priv/resource_snapshots/test_repo/embedded_array_estimates/20260515050043.json new file mode 100644 index 00000000..e752d309 --- /dev/null +++ b/priv/resource_snapshots/test_repo/embedded_array_estimates/20260515050043.json @@ -0,0 +1,68 @@ +{ + "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": "title", + "type": "text" + }, + { + "allow_nil?": true, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "active", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "options", + "type": "jsonb" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "7E03C1BEBCCEE486DAB75FCA9D4BA7454F6C120C032ABB5C08398C120196EFC2", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "embedded_array_estimates" +} \ No newline at end of file diff --git a/priv/test_repo/migrations/20260515050043_migrate_resources68.exs b/priv/test_repo/migrations/20260515050043_migrate_resources68.exs new file mode 100644 index 00000000..63a8e0db --- /dev/null +++ b/priv/test_repo/migrations/20260515050043_migrate_resources68.exs @@ -0,0 +1,22 @@ +defmodule AshPostgres.TestRepo.Migrations.MigrateResources68 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(:embedded_array_estimates, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:title, :text) + add(:active, :boolean, default: true) + add(:options, :jsonb) + end + end + + def down do + drop(table(:embedded_array_estimates)) + end +end diff --git a/test/embedded_array_exists_test.exs b/test/embedded_array_exists_test.exs new file mode 100644 index 00000000..eb40c950 --- /dev/null +++ b/test/embedded_array_exists_test.exs @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.EmbeddedArrayExistsTest do + @moduledoc """ + Phase 2 (SPIKE / CRITICAL) — verifies `exists/2` over `{:array, EmbeddedResource}` + attributes is translated to correct PostgreSQL SQL via `jsonb_array_elements`. + + See `../ash/specs/embedded-array-exists.md`. + """ + use AshPostgres.RepoCase, async: false + + require Ash.Query + + alias AshPostgres.Test.EmbeddedArray.Estimate + + setup do + cheap = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "cheap", + options: [ + %{ + name: "basic", + total_amt: Decimal.new("50"), + quantity: 1, + active: true, + tier: :basic, + valid_until: ~U[2026-12-31 23:59:59Z] + } + ] + }) + |> Ash.create!() + + expensive = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "expensive", + options: [ + %{ + name: "premium", + total_amt: Decimal.new("150"), + quantity: 10, + active: true, + tier: :premium, + valid_until: ~U[2027-06-30 23:59:59Z] + } + ] + }) + |> Ash.create!() + + mixed = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "mixed", + options: [ + %{name: "a", total_amt: Decimal.new("10"), tier: :basic, active: false}, + %{name: "b", total_amt: Decimal.new("200"), tier: :enterprise, active: true} + ] + }) + |> Ash.create!() + + %{cheap: cheap, expensive: expensive, mixed: mixed} + end + + describe "exists/2 over embedded array, cast coverage by attribute type" do + test ":decimal — total_amt > 100", %{cheap: cheap, expensive: expensive, mixed: mixed} do + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > 100)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert expensive.id in results + assert mixed.id in results + refute cheap.id in results + end + + test ":string — name == \"basic\"", %{cheap: cheap, expensive: expensive} do + results = + Estimate + |> Ash.Query.filter(exists(options, name == "basic")) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert cheap.id in results + refute expensive.id in results + end + + test ":integer — quantity > 5", %{cheap: cheap, expensive: expensive} do + results = + Estimate + |> Ash.Query.filter(exists(options, quantity > 5)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert expensive.id in results + refute cheap.id in results + end + + test ":boolean — active == false", %{cheap: cheap, mixed: mixed} do + results = + Estimate + |> Ash.Query.filter(exists(options, active == false)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert mixed.id in results + refute cheap.id in results + end + + test ":atom — tier == :premium", %{expensive: expensive, cheap: cheap} do + results = + Estimate + |> Ash.Query.filter(exists(options, tier == :premium)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert expensive.id in results + refute cheap.id in results + end + + test ":utc_datetime — valid_until > ~U[2027-01-01 00:00:00Z]", %{ + expensive: expensive, + cheap: cheap + } do + cutoff = ~U[2027-01-01 00:00:00Z] + + results = + Estimate + |> Ash.Query.filter(exists(options, valid_until > ^cutoff)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert expensive.id in results + refute cheap.id in results + end + end + + describe "exists/2 composition" do + test "and/or with outer predicates", %{expensive: expensive, mixed: mixed} do + results = + Estimate + |> Ash.Query.filter(active == true and exists(options, total_amt > 100)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert expensive.id in results + assert mixed.id in results + end + + test "not exists", %{cheap: cheap, expensive: expensive} do + results = + Estimate + |> Ash.Query.filter(not exists(options, total_amt > 100)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert cheap.id in results + refute expensive.id in results + end + + test "parameter interpolation via ^", %{expensive: expensive} do + threshold = Decimal.new("100") + + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > ^threshold)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert expensive.id in results + end + end +end diff --git a/test/support/domain.ex b/test/support/domain.ex index ab7db992..9f4699d7 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -76,6 +76,7 @@ defmodule AshPostgres.Test.Domain do resource(AshPostgres.Test.ProfileInterest) resource(AshPostgres.Test.Label) resource(AshPostgres.Test.LabelledContent) + resource(AshPostgres.Test.EmbeddedArray.Estimate) end authorization do diff --git a/test/support/embedded_array/estimate.ex b/test/support/embedded_array/estimate.ex new file mode 100644 index 00000000..343e23eb --- /dev/null +++ b/test/support/embedded_array/estimate.ex @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.EmbeddedArray.Estimate do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + alias AshPostgres.Test.EmbeddedArray.Option + + postgres do + table("embedded_array_estimates") + repo(AshPostgres.TestRepo) + + migration_types options: :jsonb + storage_types options: :jsonb + end + + attributes do + uuid_primary_key :id, writable?: true + attribute :title, :string, public?: true + attribute :active, :boolean, public?: true, default: true + attribute :options, {:array, Option}, public?: true + end + + actions do + default_accept :* + defaults [:read, :destroy, create: :*, update: :*] + end +end diff --git a/test/support/embedded_array/line_item.ex b/test/support/embedded_array/line_item.ex new file mode 100644 index 00000000..f587cf81 --- /dev/null +++ b/test/support/embedded_array/line_item.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.EmbeddedArray.LineItem do + @moduledoc false + use Ash.Resource, data_layer: :embedded + + attributes do + attribute :name, :string, public?: true + attribute :quantity, :integer, public?: true, default: 1 + attribute :unit_price, :decimal, public?: true + end +end diff --git a/test/support/embedded_array/option.ex b/test/support/embedded_array/option.ex new file mode 100644 index 00000000..ea611006 --- /dev/null +++ b/test/support/embedded_array/option.ex @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.EmbeddedArray.Option do + @moduledoc false + use Ash.Resource, data_layer: :embedded + + alias AshPostgres.Test.EmbeddedArray.LineItem + + attributes do + attribute :name, :string, public?: true + attribute :total_amt, :decimal, public?: true + attribute :quantity, :integer, public?: true + attribute :active, :boolean, public?: true + attribute :tier, :atom, public?: true, constraints: [one_of: [:basic, :premium, :enterprise]] + attribute :valid_until, :utc_datetime, public?: true + attribute :line_items, {:array, LineItem}, public?: true, default: [] + end +end From 748b3c910c5f71f2d24854362dd76892b86e32d3 Mon Sep 17 00:00:00 2001 From: Jinkyou Son Date: Sat, 16 May 2026 22:25:17 +0900 Subject: [PATCH 2/5] test: extend embedded-array exists/2 coverage with nested + parent cases --- test/embedded_array_exists_test.exs | 89 +++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/test/embedded_array_exists_test.exs b/test/embedded_array_exists_test.exs index eb40c950..476e223b 100644 --- a/test/embedded_array_exists_test.exs +++ b/test/embedded_array_exists_test.exs @@ -173,4 +173,93 @@ defmodule AshPostgres.EmbeddedArrayExistsTest do assert expensive.id in results end end + + describe "Phase 3 — nested exists/2 over embedded arrays" do + setup do + with_tea = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "drinks", + options: [ + %{ + name: "tea-bundle", + total_amt: Decimal.new("30"), + tier: :basic, + line_items: [ + %{name: "tea", quantity: 1, unit_price: Decimal.new("3")}, + %{name: "biscuit", quantity: 2, unit_price: Decimal.new("2")} + ] + } + ] + }) + |> Ash.create!() + + with_coffee = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "drinks", + options: [ + %{ + name: "coffee-bundle", + total_amt: Decimal.new("80"), + tier: :premium, + line_items: [ + %{name: "coffee", quantity: 1, unit_price: Decimal.new("4")}, + %{name: "muffin", quantity: 1, unit_price: Decimal.new("5")} + ] + } + ] + }) + |> Ash.create!() + + %{with_tea: with_tea, with_coffee: with_coffee} + end + + test "dotted nested path: exists(options.line_items, name == \"tea\")", + %{with_tea: tea, with_coffee: coffee} do + results = + Estimate + |> Ash.Query.filter(exists(options.line_items, name == "tea")) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert tea.id in results + refute coffee.id in results + end + + test "innermost predicate combines fields of innermost element", %{with_coffee: coffee} do + results = + Estimate + |> Ash.Query.filter(exists(options.line_items, name == "muffin" and quantity == 1)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert coffee.id in results + end + + test "explicit nested form auto-flattens to dotted form", %{with_tea: tea, with_coffee: coffee} do + results = + Estimate + |> Ash.Query.filter(exists(options, exists(line_items, name == "tea"))) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert tea.id in results + refute coffee.id in results + end + + test "parent(...) reaches the calling Estimate scope", %{with_tea: tea, with_coffee: coffee} do + # Both rows have title "drinks" — only tea has a line item named "tea". + results = + Estimate + |> Ash.Query.filter( + exists(options.line_items, name == "tea" and parent(title) == "drinks") + ) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert tea.id in results + refute coffee.id in results + end + end end From dc8843f0ae90c198546c8bb3b802d260e02b65d9 Mon Sep 17 00:00:00 2001 From: Jinkyou Son Date: Sun, 17 May 2026 17:13:23 +0900 Subject: [PATCH 3/5] test: add Phase 4 mixed-path coverage and Company fixture --- .../20260516132942.json | 44 +++++++++ .../20260516132943.json | 99 +++++++++++++++++++ .../20260516132941_migrate_resources71.exs | 29 ++++++ test/embedded_array_exists_test.exs | 71 +++++++++++++ test/support/domain.ex | 1 + test/support/embedded_array/company.ex | 32 ++++++ test/support/embedded_array/estimate.ex | 7 ++ 7 files changed, 283 insertions(+) create mode 100644 priv/resource_snapshots/test_repo/embedded_array_companies/20260516132942.json create mode 100644 priv/resource_snapshots/test_repo/embedded_array_estimates/20260516132943.json create mode 100644 priv/test_repo/migrations/20260516132941_migrate_resources71.exs create mode 100644 test/support/embedded_array/company.ex diff --git a/priv/resource_snapshots/test_repo/embedded_array_companies/20260516132942.json b/priv/resource_snapshots/test_repo/embedded_array_companies/20260516132942.json new file mode 100644 index 00000000..9de41ae0 --- /dev/null +++ b/priv/resource_snapshots/test_repo/embedded_array_companies/20260516132942.json @@ -0,0 +1,44 @@ +{ + "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" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "76AC03FB2DBC3C968CBC922C933E82F084A07AFD4FBE6C90BBE53E5E755BC37F", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "embedded_array_companies" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/embedded_array_estimates/20260516132943.json b/priv/resource_snapshots/test_repo/embedded_array_estimates/20260516132943.json new file mode 100644 index 00000000..aeb3461b --- /dev/null +++ b/priv/resource_snapshots/test_repo/embedded_array_estimates/20260516132943.json @@ -0,0 +1,99 @@ +{ + "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": "title", + "type": "text" + }, + { + "allow_nil?": true, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "active", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "options", + "type": "jsonb" + }, + { + "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, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "embedded_array_estimates_company_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "embedded_array_companies" + }, + "scale": null, + "size": null, + "source": "company_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "29C7F9D1AA3AFAEA997BBBD762FBDA7486336DCBFFBC4A065C45B6DE34DB1707", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "embedded_array_estimates" +} \ No newline at end of file diff --git a/priv/test_repo/migrations/20260516132941_migrate_resources71.exs b/priv/test_repo/migrations/20260516132941_migrate_resources71.exs new file mode 100644 index 00000000..e8acf0de --- /dev/null +++ b/priv/test_repo/migrations/20260516132941_migrate_resources71.exs @@ -0,0 +1,29 @@ +defmodule AshPostgres.TestRepo.Migrations.MigrateResources71 do + @moduledoc """ + Adds the `embedded_array_companies` table and the `company_id` column on + `embedded_array_estimates`. Other autogenerated changes for unrelated + resources (interest/profile_interest schemas, classroom.public) were + trimmed out because those tables already exist in the local test DB. + """ + + use Ecto.Migration + + def up do + alter table(:embedded_array_estimates) do + add(:company_id, :uuid) + end + + create table(:embedded_array_companies, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:name, :text) + end + end + + def down do + drop(table(:embedded_array_companies)) + + alter table(:embedded_array_estimates) do + remove(:company_id) + end + end +end diff --git a/test/embedded_array_exists_test.exs b/test/embedded_array_exists_test.exs index 476e223b..d4749dcd 100644 --- a/test/embedded_array_exists_test.exs +++ b/test/embedded_array_exists_test.exs @@ -262,4 +262,75 @@ defmodule AshPostgres.EmbeddedArrayExistsTest do refute coffee.id in results end end + + describe "Phase 4 — mixed paths (relationship → embedded array)" do + alias AshPostgres.Test.EmbeddedArray.Company + + setup do + cheap_company = + Company + |> Ash.Changeset.for_create(:create, %{name: "CheapCo"}) + |> Ash.create!() + + pricey_company = + Company + |> Ash.Changeset.for_create(:create, %{name: "PriceyCo"}) + |> Ash.create!() + + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "for cheap", + company_id: cheap_company.id, + options: [%{name: "basic", total_amt: Decimal.new("50")}] + }) + |> Ash.create!() + + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "for pricey", + company_id: pricey_company.id, + options: [%{name: "premium", total_amt: Decimal.new("150")}] + }) + |> Ash.create!() + + %{cheap_company: cheap_company, pricey_company: pricey_company} + end + + test "exists(estimates.options, total_amt > 100) on Company", %{ + cheap_company: cheap, + pricey_company: pricey + } do + results = + Company + |> Ash.Query.filter(exists(estimates.options, total_amt > 100)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert pricey.id in results + refute cheap.id in results + end + + test "string equality through mixed path", %{cheap_company: cheap, pricey_company: pricey} do + results = + Company + |> Ash.Query.filter(exists(estimates.options, name == "basic")) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert cheap.id in results + refute pricey.id in results + end + + test "parent(...) reaches the calling Company scope", %{pricey_company: pricey} do + results = + Company + |> Ash.Query.filter( + exists(estimates.options, total_amt > 100 and parent(name) == "PriceyCo") + ) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert pricey.id in results + end + end end diff --git a/test/support/domain.ex b/test/support/domain.ex index 9f4699d7..7b5f99a7 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -76,6 +76,7 @@ defmodule AshPostgres.Test.Domain do resource(AshPostgres.Test.ProfileInterest) resource(AshPostgres.Test.Label) resource(AshPostgres.Test.LabelledContent) + resource(AshPostgres.Test.EmbeddedArray.Company) resource(AshPostgres.Test.EmbeddedArray.Estimate) end diff --git a/test/support/embedded_array/company.ex b/test/support/embedded_array/company.ex new file mode 100644 index 00000000..2df6695c --- /dev/null +++ b/test/support/embedded_array/company.ex @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.EmbeddedArray.Company do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + postgres do + table("embedded_array_companies") + repo(AshPostgres.TestRepo) + end + + attributes do + uuid_primary_key :id, writable?: true + attribute :name, :string, public?: true + end + + actions do + default_accept :* + defaults [:read, :destroy, create: :*, update: :*] + end + + relationships do + has_many :estimates, AshPostgres.Test.EmbeddedArray.Estimate do + public? true + destination_attribute :company_id + end + end +end diff --git a/test/support/embedded_array/estimate.ex b/test/support/embedded_array/estimate.ex index 343e23eb..3a43b153 100644 --- a/test/support/embedded_array/estimate.ex +++ b/test/support/embedded_array/estimate.ex @@ -23,10 +23,17 @@ defmodule AshPostgres.Test.EmbeddedArray.Estimate do attribute :title, :string, public?: true attribute :active, :boolean, public?: true, default: true attribute :options, {:array, Option}, public?: true + attribute :company_id, :uuid, public?: true end actions do default_accept :* defaults [:read, :destroy, create: :*, update: :*] end + + relationships do + belongs_to :company, AshPostgres.Test.EmbeddedArray.Company do + public? true + end + end end From 1c0036c5eabc7cf279fa3c0e5a9e05b8dfae0246 Mon Sep 17 00:00:00 2001 From: Jinkyou Son Date: Sun, 17 May 2026 21:22:12 +0900 Subject: [PATCH 4/5] improvement: declare {:exists, :embedded_array} capability and add SQL edge cases --- lib/data_layer.ex | 1 + test/embedded_array_exists_test.exs | 71 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 819cc0dd..8a1e8df1 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -731,6 +731,7 @@ defmodule AshPostgres.DataLayer do def can?(_, {:aggregate, :unrelated}), do: true def can?(_, {:exists, :unrelated}), do: true + def can?(_, {:exists, :embedded_array}), do: true def can?(_, :aggregate_filter), do: true def can?(_, :aggregate_sort), do: true def can?(_, :calculate), do: true diff --git a/test/embedded_array_exists_test.exs b/test/embedded_array_exists_test.exs index d4749dcd..a6fb1331 100644 --- a/test/embedded_array_exists_test.exs +++ b/test/embedded_array_exists_test.exs @@ -333,4 +333,75 @@ defmodule AshPostgres.EmbeddedArrayExistsTest do assert pricey.id in results end end + + describe "Phase 5 — SQL edge cases" do + setup do + empty = + Estimate + |> Ash.Changeset.for_create(:create, %{title: "empty", options: []}) + |> Ash.create!() + + nil_options = + Estimate + |> Ash.Changeset.for_create(:create, %{title: "nil_options"}) + |> Ash.create!() + + populated = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "populated", + options: [%{name: "x", total_amt: Decimal.new("10")}] + }) + |> Ash.create!() + + %{empty: empty, nil_options: nil_options, populated: populated} + end + + test "exists/2 returns false for empty array", %{empty: empty} do + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > 0)) + |> Ash.read!() + |> Enum.map(& &1.id) + + refute empty.id in results + end + + test "exists/2 returns false for nil array (jsonb_array_elements(NULL) yields no rows)", + %{nil_options: nil_opts} do + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > 0)) + |> Ash.read!() + |> Enum.map(& &1.id) + + refute nil_opts.id in results + end + + test "not exists/2 includes empty and nil arrays", %{ + empty: empty, + nil_options: nil_opts, + populated: populated + } do + results = + Estimate + |> Ash.Query.filter(not exists(options, total_amt > 0)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert empty.id in results + assert nil_opts.id in results + refute populated.id in results + end + + test "same field referenced twice in predicate", %{populated: populated} do + results = + Estimate + |> Ash.Query.filter(exists(options, total_amt > 0 and total_amt < 1000)) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert populated.id in results + end + end end From 1264a84c55467fc678f17442ed468c4aa5c81b4d Mon Sep 17 00:00:00 2001 From: Jinkyou Son Date: Tue, 19 May 2026 10:16:18 +0900 Subject: [PATCH 5/5] test: cover parent/1 with relationship path inside embedded-array exists --- test/embedded_array_exists_test.exs | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/test/embedded_array_exists_test.exs b/test/embedded_array_exists_test.exs index a6fb1331..724a56bc 100644 --- a/test/embedded_array_exists_test.exs +++ b/test/embedded_array_exists_test.exs @@ -332,6 +332,58 @@ defmodule AshPostgres.EmbeddedArrayExistsTest do assert pricey.id in results end + + # Mirrors the relationship-exists pattern from Ash's parent_test + # (`parent_test.exs:164`): `parent/1` traverses a belongs_to from the + # calling resource to access a field on the related row, even when + # the exists target is an embedded array. + test "parent can refer to a belongs_to relationship's field" do + acme = + Company + |> Ash.Changeset.for_create(:create, %{name: "acme"}) + |> Ash.create!() + + other = + Company + |> Ash.Changeset.for_create(:create, %{name: "other"}) + |> Ash.create!() + + matching = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "matchy", + company_id: acme.id, + options: [%{name: "acme", total_amt: Decimal.new("10")}] + }) + |> Ash.create!() + + _non_matching_co_name = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "no-match-1", + company_id: other.id, + options: [%{name: "acme", total_amt: Decimal.new("10")}] + }) + |> Ash.create!() + + _non_matching_option_name = + Estimate + |> Ash.Changeset.for_create(:create, %{ + title: "no-match-2", + company_id: acme.id, + options: [%{name: "something-else", total_amt: Decimal.new("10")}] + }) + |> Ash.create!() + + results = + Estimate + |> Ash.Query.filter(exists(options, name == parent(company.name))) + |> Ash.read!() + |> Enum.map(& &1.id) + + assert matching.id in results + assert length(results) == 1 + end end describe "Phase 5 — SQL edge cases" do