diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 506e0baf..3fa1aa30 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -721,6 +721,7 @@ defmodule AshPostgres.DataLayer do when type in [:count, :sum, :first, :list, :avg, :max, :min, :exists, :custom], do: true + def can?(_, {:aggregate, :unrelated}), do: true def can?(_, :aggregate_filter), do: true def can?(_, :aggregate_sort), do: true def can?(_, :calculate), do: true diff --git a/mix.exs b/mix.exs index 549fbcd9..0ff8df8e 100644 --- a/mix.exs +++ b/mix.exs @@ -166,8 +166,8 @@ defmodule AshPostgres.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, ash_version("~> 3.5 and >= 3.5.13")}, - {:ash_sql, ash_sql_version("~> 0.2 and >= 0.2.72")}, + {:ash, ash_version(github: "ash-project/ash", branch: "main")}, + {:ash_sql, ash_sql_version(github: "ash-project/ash_sql", branch: "main")}, {:igniter, "~> 0.6 and >= 0.6.14", optional: true}, {:ecto_sql, "~> 3.13"}, {:ecto, "~> 3.13"}, diff --git a/mix.lock b/mix.lock index bc1b7e3e..3c6f7826 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,6 @@ %{ - "ash": {:hex, :ash, "3.5.32", "ee717c49744374be7abe8997011115a4997917535e02d36146937fb6f89c19fe", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.4 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.65 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4704f536682538ee3ae0e8f99ff3cef6af53df09ef3e7e550e202da9d5ce685c"}, - "ash_sql": {:hex, :ash_sql, "0.2.89", "ad4ad497263b586a7f3949ceea5d44620a36cb99a1ef0ff5f58f13a77d9b99ef", [:mix], [{:ash, ">= 3.5.25 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "bd957aee95bbdf6326fc7a9212f9a2ab87329b99ee3646c373a87bb3c9968566"}, + "ash": {:git, "https://github.com/ash-project/ash.git", "471274d2f75a4bda6405c5630b72f9323d572260", [branch: "main"]}, + "ash_sql": {:git, "https://github.com/ash-project/ash_sql.git", "40c9bcb905603dbcee2bc064f37c6f5ce30da7c7", [branch: "main"]}, "benchee": {:hex, :benchee, "1.4.0", "9f1f96a30ac80bab94faad644b39a9031d5632e517416a8ab0a6b0ac4df124ce", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "299cd10dd8ce51c9ea3ddb74bb150f93d25e968f93e4c1fa31698a8e4fa5d715"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, @@ -23,7 +23,7 @@ "git_ops": {:hex, :git_ops, "2.8.0", "29ac9ab68bf9645973cb2752047b987e75cbd3d9761489c615e3ba80018fa885", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "b535e4ad6b5d13e14c455e76f65825659081b5530b0827eb0232d18719530eec"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "igniter": {:hex, :igniter, "0.6.25", "e2774a4605c2bc9fc38f689232604aea0fc925c7966ae8e928fd9ea2fa9d300c", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "b1916e1e45796d5c371c7671305e81277231617eb58b1c120915aba237fbce6a"}, + "igniter": {:hex, :igniter, "0.6.26", "a6b4f6680a7e158bd13cd3b2be047102e42c046b3b240578d68d89d1a39a83fa", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "a4f8c404fc4cbc05a1b536c8125ae64909e3a02d5f972ffe6a3a2ebd75530f3c"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, @@ -37,7 +37,7 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, - "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, + "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, "reactor": {:hex, :reactor, "0.15.6", "d717f9add549b25a089a94c90197718d2d838e35d81dd776b1d81587d4cf2aaa", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "74db98165e3644d86e0f723672d91ceca4339eaa935bcad7e78bf146a46d77b9"}, "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, diff --git a/priv/resource_snapshots/test_repo/unrelated_profiles/20250731124648.json b/priv/resource_snapshots/test_repo/unrelated_profiles/20250731124648.json new file mode 100644 index 00000000..a3c884ee --- /dev/null +++ b/priv/resource_snapshots/test_repo/unrelated_profiles/20250731124648.json @@ -0,0 +1,91 @@ +{ + "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": "age", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "bio", + "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": "owner_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "B32650B5196D79814F5D5EF0481C757CDE5F7545E787EA911A13B9B9CBD38E7E", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "unrelated_profiles" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/unrelated_reports/20250731124648.json b/priv/resource_snapshots/test_repo/unrelated_reports/20250731124648.json new file mode 100644 index 00000000..327cce49 --- /dev/null +++ b/priv/resource_snapshots/test_repo/unrelated_reports/20250731124648.json @@ -0,0 +1,79 @@ +{ + "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": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "author_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "score", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "728B41F3FC4BC58102261057925992766C0A3D9ED3AD7D16B887CCF8EF6B6E19", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "unrelated_reports" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/unrelated_secure_profiles/20250731124648.json b/priv/resource_snapshots/test_repo/unrelated_secure_profiles/20250731124648.json new file mode 100644 index 00000000..9df541b0 --- /dev/null +++ b/priv/resource_snapshots/test_repo/unrelated_secure_profiles/20250731124648.json @@ -0,0 +1,91 @@ +{ + "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": "age", + "type": "bigint" + }, + { + "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": "owner_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "department", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "56BB9374A010E8E23144743DEDD10B6557BCD002338D1B06A35431AB0320F88B", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "unrelated_secure_profiles" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/unrelated_users/20250731124648.json b/priv/resource_snapshots/test_repo/unrelated_users/20250731124648.json new file mode 100644 index 00000000..4d83b926 --- /dev/null +++ b/priv/resource_snapshots/test_repo/unrelated_users/20250731124648.json @@ -0,0 +1,79 @@ +{ + "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": "age", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "\"user\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "role", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "E56D245A9DA955A309FDF1BD11C215F3056FF9BA7A4D4D3A3D5E285F0D183AC2", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "unrelated_users" +} \ No newline at end of file diff --git a/priv/test_repo/migrations/20250731124648_migrate_resources57.exs b/priv/test_repo/migrations/20250731124648_migrate_resources57.exs new file mode 100644 index 00000000..3493fd6b --- /dev/null +++ b/priv/test_repo/migrations/20250731124648_migrate_resources57.exs @@ -0,0 +1,59 @@ +defmodule AshPostgres.TestRepo.Migrations.MigrateResources57 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(:unrelated_reports, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:title, :text) + add(:author_name, :text) + add(:score, :bigint) + + add(:inserted_at, :utc_datetime, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + ) + end + + create table(:unrelated_users, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:name, :text) + add(:age, :bigint) + add(:email, :text) + add(:role, :text, default: "user") + end + + create table(:unrelated_profiles, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:name, :text) + add(:age, :bigint) + add(:bio, :text) + add(:active, :boolean, default: true) + add(:owner_id, :uuid) + end + + create table(:unrelated_secure_profiles, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:name, :text) + add(:age, :bigint) + add(:active, :boolean, default: true) + add(:owner_id, :uuid) + add(:department, :text) + end + end + + def down do + drop(table(:unrelated_secure_profiles)) + + drop(table(:unrelated_profiles)) + + drop(table(:unrelated_users)) + + drop(table(:unrelated_reports)) + end +end diff --git a/test/support/domain.ex b/test/support/domain.ex index 58074271..58802a18 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -44,6 +44,10 @@ defmodule AshPostgres.Test.Domain do resource(AshPostgres.Test.Punchline) resource(AshPostgres.Test.Tag) resource(AshPostgres.Test.PostTag) + resource(AshPostgres.Test.UnrelatedAggregatesTest.Profile) + resource(AshPostgres.Test.UnrelatedAggregatesTest.SecureProfile) + resource(AshPostgres.Test.UnrelatedAggregatesTest.Report) + resource(AshPostgres.Test.UnrelatedAggregatesTest.User) end authorization do diff --git a/test/support/unrelated_aggregates/profile.ex b/test/support/unrelated_aggregates/profile.ex new file mode 100644 index 00000000..87a9fa9f --- /dev/null +++ b/test/support/unrelated_aggregates/profile.ex @@ -0,0 +1,36 @@ +defmodule AshPostgres.Test.UnrelatedAggregatesTest.Profile do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + + postgres do + table("unrelated_profiles") + repo(AshPostgres.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, public?: true) + attribute(:age, :integer, public?: true) + attribute(:bio, :string, public?: true) + attribute(:active, :boolean, default: true, public?: true) + attribute(:owner_id, :uuid, public?: true) + end + + actions do + defaults([:read, :destroy, create: :*, update: :*]) + end + + policies do + # Allow unrestricted access for most tests, but we'll create a SecureProfile for auth tests + policy action_type([:create, :update, :destroy]) do + authorize_if(always()) + end + + policy action_type(:read) do + authorize_if(always()) + end + end +end diff --git a/test/support/unrelated_aggregates/report.ex b/test/support/unrelated_aggregates/report.ex new file mode 100644 index 00000000..652df03c --- /dev/null +++ b/test/support/unrelated_aggregates/report.ex @@ -0,0 +1,33 @@ +defmodule AshPostgres.Test.UnrelatedAggregatesTest.Report do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + postgres do + table("unrelated_reports") + repo(AshPostgres.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:title, :string, public?: true) + attribute(:author_name, :string, public?: true) + attribute(:score, :integer, public?: true) + + attribute(:inserted_at, :utc_datetime, + public?: true, + default: &DateTime.utc_now/0, + allow_nil?: false + ) + end + + actions do + defaults([:read, :destroy, update: :*]) + + create :create do + primary?(true) + accept([:title, :author_name, :score, :inserted_at]) + end + end +end diff --git a/test/support/unrelated_aggregates/secure_profile.ex b/test/support/unrelated_aggregates/secure_profile.ex new file mode 100644 index 00000000..3f50bf73 --- /dev/null +++ b/test/support/unrelated_aggregates/secure_profile.ex @@ -0,0 +1,38 @@ +defmodule AshPostgres.Test.UnrelatedAggregatesTest.SecureProfile do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + + postgres do + table("unrelated_secure_profiles") + repo(AshPostgres.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, public?: true) + attribute(:age, :integer, public?: true) + attribute(:active, :boolean, default: true, public?: true) + attribute(:owner_id, :uuid, public?: true) + attribute(:department, :string, public?: true) + end + + actions do + defaults([:read, :destroy, create: :*, update: :*]) + end + + policies do + # Allow creation/updates for testing setup + policy action_type([:create, :update, :destroy]) do + authorize_if(always()) + end + + # Only allow users to see their own profiles, or admins to see all + policy action_type(:read) do + authorize_if(actor_attribute_equals(:role, :admin)) + authorize_if(expr(owner_id == ^actor(:id))) + end + end +end diff --git a/test/support/unrelated_aggregates/user.ex b/test/support/unrelated_aggregates/user.ex new file mode 100644 index 00000000..ce143489 --- /dev/null +++ b/test/support/unrelated_aggregates/user.ex @@ -0,0 +1,154 @@ +defmodule AshPostgres.Test.UnrelatedAggregatesTest.User do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + alias AshPostgres.Test.UnrelatedAggregatesTest.{Profile, Report, SecureProfile} + + postgres do + table("unrelated_users") + repo(AshPostgres.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, public?: true) + attribute(:age, :integer, public?: true) + attribute(:email, :string, public?: true) + attribute(:role, :atom, public?: true, default: :user) + end + + # Test basic unrelated aggregates + aggregates do + # Count of profiles with matching name + count :matching_name_profiles_count, Profile do + filter(expr(name == parent(name))) + public?(true) + end + + # Count of all active profiles (no parent filter) + count :total_active_profiles, Profile do + filter(expr(active == true)) + public?(true) + end + + # First report with matching author name + first :latest_authored_report, Report, :title do + filter(expr(author_name == parent(name))) + sort(inserted_at: :desc) + public?(true) + end + + # Sum of report scores for matching author + sum :total_report_score, Report, :score do + filter(expr(author_name == parent(name))) + public?(true) + end + + # Exists check for profiles with same name + exists :has_matching_name_profile, Profile do + filter(expr(name == parent(name))) + public?(true) + end + + # List of all profile names with same name (should be just one usually) + list :matching_profile_names, Profile, :name do + filter(expr(name == parent(name))) + public?(true) + end + + # Max age of profiles with same name + max :max_age_same_name, Profile, :age do + filter(expr(name == parent(name))) + public?(true) + end + + # Min age of profiles with same name + min :min_age_same_name, Profile, :age do + filter(expr(name == parent(name))) + public?(true) + end + + # Average age of profiles with same name + avg :avg_age_same_name, Profile, :age do + filter(expr(name == parent(name))) + public?(true) + end + + # Secure aggregate - should respect authorization policies + count :secure_profile_count, SecureProfile do + filter(expr(name == parent(name))) + public?(true) + end + end + + # Test unrelated aggregates in calculations + calculations do + calculate :matching_profiles_summary, + :string, + expr("Found " <> type(matching_name_profiles_count, :string) <> " profiles") do + public?(true) + end + + calculate :inline_profile_count, + :integer, + expr(count(Profile, filter: expr(name == parent(name)))) do + public?(true) + end + + calculate :inline_latest_report_title, + :string, + expr( + first(Report, + field: :title, + query: [ + filter: expr(author_name == parent(name)), + sort: [inserted_at: :desc] + ] + ) + ) do + public?(true) + end + + calculate :inline_total_score, + :integer, + expr( + sum(Report, + field: :score, + query: [ + filter: expr(author_name == parent(name)) + ] + ) + ) do + public?(true) + end + + calculate :complex_calculation, + :map, + expr(%{ + profile_count: count(Profile, filter: expr(name == parent(name))), + latest_report: + first(Report, + field: :title, + query: [ + filter: expr(author_name == parent(name)), + sort: [inserted_at: :desc] + ] + ), + total_score: + sum(Report, + field: :score, + query: [ + filter: expr(author_name == parent(name)) + ] + ) + }) do + public?(true) + end + end + + actions do + defaults([:read, :destroy, create: :*, update: :*]) + end +end diff --git a/test/unrelated_aggregates_test.exs b/test/unrelated_aggregates_test.exs new file mode 100644 index 00000000..09a15740 --- /dev/null +++ b/test/unrelated_aggregates_test.exs @@ -0,0 +1,507 @@ +defmodule AshPostgres.Test.UnrelatedAggregatesTest do + @moduledoc false + use AshPostgres.RepoCase, async: false + + require Ash.Query + import Ash.Expr + + alias AshPostgres.Test.UnrelatedAggregatesTest.{Profile, Report, SecureProfile, User} + + describe "basic unrelated aggregate definitions" do + test "aggregates are properly defined with related?: false" do + aggregates = Ash.Resource.Info.aggregates(User) + + count_agg = Enum.find(aggregates, &(&1.name == :matching_name_profiles_count)) + assert count_agg + assert count_agg.related? == false + assert count_agg.resource == Profile + assert count_agg.kind == :count + assert count_agg.relationship_path == [] + + first_agg = Enum.find(aggregates, &(&1.name == :latest_authored_report)) + assert first_agg + assert first_agg.related? == false + assert first_agg.resource == Report + assert first_agg.kind == :first + assert first_agg.field == :title + + sum_agg = Enum.find(aggregates, &(&1.name == :total_report_score)) + assert sum_agg + assert sum_agg.related? == false + assert sum_agg.resource == Report + assert sum_agg.kind == :sum + assert sum_agg.field == :score + end + + test "unrelated aggregates support all aggregate kinds" do + aggregates = Ash.Resource.Info.aggregates(User) + aggregate_names = Enum.map(aggregates, & &1.name) + + # Verify all kinds are supported + # count + assert :matching_name_profiles_count in aggregate_names + # first + assert :latest_authored_report in aggregate_names + # sum + assert :total_report_score in aggregate_names + # exists + assert :has_matching_name_profile in aggregate_names + # list + assert :matching_profile_names in aggregate_names + # max + assert :max_age_same_name in aggregate_names + # min + assert :min_age_same_name in aggregate_names + # avg + assert :avg_age_same_name in aggregate_names + end + + test "can define aggregates without parent filters" do + aggregates = Ash.Resource.Info.aggregates(User) + total_active_agg = Enum.find(aggregates, &(&1.name == :total_active_profiles)) + + assert total_active_agg + assert total_active_agg.related? == false + assert total_active_agg.resource == Profile + # Should have filter but no parent() reference + end + end + + describe "loading unrelated aggregates" do + setup do + # Create test data + {:ok, user1} = Ash.create(User, %{name: "John", email: "john@example.com"}) + {:ok, user2} = Ash.create(User, %{name: "Jane", email: "jane@example.com"}) + + {:ok, _profile1} = Ash.create(Profile, %{name: "John", age: 25, active: true}) + {:ok, _profile2} = Ash.create(Profile, %{name: "John", age: 30, active: true}) + {:ok, _profile3} = Ash.create(Profile, %{name: "Jane", age: 28, active: true}) + {:ok, _profile4} = Ash.create(Profile, %{name: "Bob", age: 35, active: false}) + + base_time = ~U[2024-01-01 12:00:00Z] + + {:ok, _report1} = + Ash.create(Report, %{ + title: "John's First Report", + author_name: "John", + score: 85, + inserted_at: base_time + }) + + {:ok, _report2} = + Ash.create(Report, %{ + title: "John's Latest Report", + author_name: "John", + score: 92, + inserted_at: DateTime.add(base_time, 3600, :second) + }) + + {:ok, _report3} = + Ash.create(Report, %{ + title: "Jane's Report", + author_name: "Jane", + score: 78 + }) + + %{user1: user1, user2: user2} + end + + test "can load count unrelated aggregates", %{user1: user1, user2: user2} do + # Load users with aggregates + users = + User + |> Ash.Query.load([:matching_name_profiles_count, :total_active_profiles]) + |> Ash.read!() + + john = Enum.find(users, &(&1.id == user1.id)) + jane = Enum.find(users, &(&1.id == user2.id)) + + # John should have 2 matching profiles + assert john.matching_name_profiles_count == 2 + # Both should see 3 total active profiles (John x2, Jane x1) + assert john.total_active_profiles == 3 + + # Jane should have 1 matching profile + assert jane.matching_name_profiles_count == 1 + assert jane.total_active_profiles == 3 + end + + test "can load first unrelated aggregates", %{user1: user1} do + user = + User + |> Ash.Query.filter(id == ^user1.id) + |> Ash.Query.load(:latest_authored_report) + |> Ash.read_one!() + + # Should get the latest report title + assert user.latest_authored_report == "John's Latest Report" + end + + test "can load sum unrelated aggregates", %{user1: user1, user2: user2} do + users = + User + |> Ash.Query.load(:total_report_score) + |> Ash.read!() + + john = Enum.find(users, &(&1.id == user1.id)) + jane = Enum.find(users, &(&1.id == user2.id)) + + # John's total score: 85 + 92 = 177 + assert john.total_report_score == 177 + # Jane's total score: 78 + assert jane.total_report_score == 78 + end + + test "can load exists unrelated aggregates", %{user1: user1} do + user = + User + |> Ash.Query.filter(id == ^user1.id) + |> Ash.Query.load(:has_matching_name_profile) + |> Ash.read_one!() + + assert user.has_matching_name_profile == true + end + + test "can load list unrelated aggregates", %{user1: user1} do + user = + User + |> Ash.Query.filter(id == ^user1.id) + |> Ash.Query.load(:matching_profile_names) + |> Ash.read_one!() + + # Should have two "John" entries + assert length(user.matching_profile_names) == 2 + assert Enum.all?(user.matching_profile_names, &(&1 == "John")) + end + + test "can load min/max/avg unrelated aggregates", %{user1: user1} do + user = + User + |> Ash.Query.filter(id == ^user1.id) + |> Ash.Query.load([:min_age_same_name, :max_age_same_name, :avg_age_same_name]) + |> Ash.read_one!() + + # John profiles have ages 25 and 30 + assert user.min_age_same_name == 25 + assert user.max_age_same_name == 30 + assert user.avg_age_same_name == 27.5 + end + end + + describe "unrelated aggregates in calculations" do + setup do + {:ok, user} = Ash.create(User, %{name: "Alice", email: "alice@example.com"}) + {:ok, _profile} = Ash.create(Profile, %{name: "Alice", age: 25, active: true}) + + {:ok, _report} = + Ash.create(Report, %{ + title: "Alice's Research", + author_name: "Alice", + score: 95, + inserted_at: ~U[2024-01-01 12:00:00Z] + }) + + %{user: user} + end + + test "calculations using named unrelated aggregates work", %{user: user} do + user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.load(:matching_profiles_summary) + |> Ash.read_one!() + + assert user.matching_profiles_summary == "Found 1 profiles" + end + + test "inline unrelated aggregates in calculations work", %{user: user} do + user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.load([ + :inline_profile_count, + :inline_latest_report_title, + :inline_total_score + ]) + |> Ash.read_one!() + + assert user.inline_profile_count == 1 + assert user.inline_latest_report_title == "Alice's Research" + assert user.inline_total_score == 95 + end + + test "complex calculations with multiple inline unrelated aggregates work", %{user: user} do + user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.load(:complex_calculation) + |> Ash.read_one!() + + assert user.complex_calculation == %{ + profile_count: 1, + latest_report: "Alice's Research", + total_score: 95 + } + end + end + + describe "data layer capability checking" do + test "Postgres data layer should support unrelated aggregates" do + # This will fail until we implement the capability + assert AshPostgres.DataLayer.can?(nil, {:aggregate, :unrelated}) == true + end + + test "error when data layer doesn't support unrelated aggregates" do + # Test with a mock data layer that doesn't support unrelated aggregates + # This will be relevant when we add the capability checking + end + end + + describe "authorization with unrelated aggregates" do + # These tests verify that authorization works properly for unrelated aggregates + # The main concern is that unrelated aggregates don't have relationship paths, + # so the authorization logic must handle this correctly + + test "unrelated aggregates work without relationship path authorization errors" do + # This test verifies that unrelated aggregates don't trigger the + # :lists.droplast([]) error that was happening before the fix + {:ok, user} = Ash.create(User, %{name: "AuthTest", email: "auth@example.com"}) + {:ok, _profile} = Ash.create(Profile, %{name: "AuthTest", age: 25, active: true}) + + # This should not raise authorization errors + user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.load(:matching_name_profiles_count) + |> Ash.read_one!() + + assert user.matching_name_profiles_count == 1 + end + + test "unrelated aggregates in calculations don't cause authorization errors" do + # Test that the authorization logic correctly handles unrelated aggregates + # when they're referenced in calculations + {:ok, user} = Ash.create(User, %{name: "CalcAuth", email: "calcauth@example.com"}) + {:ok, _profile} = Ash.create(Profile, %{name: "CalcAuth", age: 30, active: true}) + + # This should not raise authorization errors + user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.load(:matching_profiles_summary) + |> Ash.read_one!() + + assert user.matching_profiles_summary == "Found 1 profiles" + end + + test "multiple unrelated aggregates can be loaded together without authorization issues" do + # Test loading multiple unrelated aggregates simultaneously + {:ok, user} = Ash.create(User, %{name: "MultiAuth", email: "multi@example.com"}) + {:ok, _profile} = Ash.create(Profile, %{name: "MultiAuth", age: 28, active: true}) + + {:ok, _report} = + Ash.create(Report, %{ + title: "MultiAuth Report", + author_name: "MultiAuth", + score: 88, + inserted_at: ~U[2024-01-01 15:00:00Z] + }) + + # Loading multiple unrelated aggregates should work + user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.load([ + :matching_name_profiles_count, + :total_active_profiles, + :latest_authored_report, + :total_report_score + ]) + |> Ash.read_one!() + + assert user.matching_name_profiles_count == 1 + # Could include profiles from other tests + assert user.total_active_profiles >= 1 + assert user.latest_authored_report == "MultiAuth Report" + assert user.total_report_score == 88 + end + + test "unrelated aggregates respect target resource authorization policies" do + admin_user = Ash.create!(User, %{name: "Admin", email: "admin@test.com", role: :admin}) + regular_user1 = Ash.create!(User, %{name: "User1", email: "user1@test.com", role: :user}) + regular_user2 = Ash.create!(User, %{name: "User1", email: "user2@test.com", role: :user}) + + Ash.create!(SecureProfile, %{ + name: "User1", + age: 25, + active: true, + owner_id: regular_user1.id, + department: "Engineering" + }) + + Ash.create!(SecureProfile, %{ + name: "User1", + age: 30, + active: true, + owner_id: regular_user2.id, + department: "Marketing" + }) + + Ash.create!(SecureProfile, %{ + name: "Admin", + age: 35, + active: true, + owner_id: admin_user.id, + department: "Management" + }) + + user1_result = + User + |> Ash.Query.filter(id == ^regular_user1.id) + |> Ash.Query.load(:secure_profile_count) + |> Ash.read_one!(actor: regular_user1, authorize?: true) + + assert user1_result.secure_profile_count == 1 + + user2_result = + User + |> Ash.Query.filter(id == ^regular_user2.id) + |> Ash.Query.load(:secure_profile_count) + |> Ash.read_one!(actor: regular_user2, authorize?: true) + + assert user2_result.secure_profile_count == 1 + + admin_as_user1 = + User + |> Ash.Query.filter(id == ^regular_user1.id) + |> Ash.Query.load(:secure_profile_count) + |> Ash.read_one!(actor: admin_user, authorize?: true) + + assert admin_as_user1.secure_profile_count == 2 + + admin_result = + User + |> Ash.Query.filter(id == ^admin_user.id) + |> Ash.Query.load(:secure_profile_count) + |> Ash.read_one!(actor: admin_user, authorize?: true) + + assert admin_result.secure_profile_count == 1 + end + end + + describe "edge cases" do + test "unrelated aggregates work with empty result sets" do + users = + User + |> Ash.Query.filter(name == "NonExistent") + |> Ash.Query.load(:matching_name_profiles_count) + |> Ash.read!() + + # Should be empty, but aggregate should still work + assert users == [] + end + + test "unrelated aggregates work with filters that return no results" do + {:ok, user} = Ash.create(User, %{name: "Unique", email: "unique@example.com"}) + + # No profiles with name "Unique" exist + loaded_user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.load(:matching_name_profiles_count) + |> Ash.read_one!() + + assert loaded_user.matching_name_profiles_count == 0 + end + + test "unrelated aggregates work with complex filter expressions" do + {:ok, user} = + Ash.create(User, %{name: "ComplexTest", age: 25, email: "complex@example.com"}) + + # Create profiles with various attributes + {:ok, _profile1} = + Ash.create(Profile, %{name: "ComplexTest", age: 25, bio: "Bio contains ComplexTest"}) + + {:ok, _profile2} = + Ash.create(Profile, %{name: "ComplexTest", age: 30, bio: "Different bio"}) + + {:ok, _profile3} = + Ash.create(Profile, %{name: "Other", age: 25, bio: "ComplexTest mentioned"}) + + # Test parent() with boolean AND + loaded_user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.aggregate(:same_name_and_age, :count, Profile, + query: [filter: expr(name == parent(name) and age == parent(age))] + ) + |> Ash.read_one!() + + assert loaded_user.aggregates.same_name_and_age == 1 + + # Test parent() with OR conditions + loaded_user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.aggregate(:name_or_bio_match, :count, Profile, + query: [filter: expr(name == parent(name) or contains(bio, parent(name)))] + ) + |> Ash.read_one!() + + assert loaded_user.aggregates.name_or_bio_match == 3 + + # Test parent() with comparison operators + loaded_user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.aggregate(:older_profiles, :count, Profile, + query: [filter: expr(name == parent(name) and age > parent(age))] + ) + |> Ash.read_one!() + + assert loaded_user.aggregates.older_profiles == 1 + end + + test "parent() works with nested conditional expressions" do + {:ok, user} = Ash.create(User, %{name: "NestedTest", age: 30, email: "nested@example.com"}) + + {:ok, _profile1} = Ash.create(Profile, %{name: "NestedTest", age: 25, bio: "Young"}) + {:ok, _profile2} = Ash.create(Profile, %{name: "NestedTest", age: 35, bio: "Old"}) + {:ok, _profile3} = Ash.create(Profile, %{name: "Other", age: 30, bio: "Same age"}) + + # Test nested parentheses with parent() + loaded_user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.aggregate(:complex_condition, :count, Profile, + query: [filter: expr(name == parent(name) and (age < parent(age) or age > parent(age)))] + ) + |> Ash.read_one!() + + assert loaded_user.aggregates.complex_condition == 2 + end + + test "parent() works with string functions" do + {:ok, user} = Ash.create(User, %{name: "StringTest", email: "string@example.com"}) + + {:ok, _profile1} = + Ash.create(Profile, %{name: "StringTest", bio: "StringTest is mentioned here"}) + + {:ok, _profile2} = + Ash.create(Profile, %{name: "DifferentName", bio: "StringTest appears in bio"}) + + {:ok, _profile3} = Ash.create(Profile, %{name: "StringTest", bio: "No mention"}) + + # Test parent() with string contains function + loaded_user = + User + |> Ash.Query.filter(id == ^user.id) + |> Ash.Query.aggregate(:bio_mentions_name, :count, Profile, + query: [filter: expr(contains(bio, parent(name)))] + ) + |> Ash.read_one!() + + assert loaded_user.aggregates.bio_mentions_name == 2 + end + end +end