diff --git a/priv/resource_snapshots/test_repo/comments/20251119130424.json b/priv/resource_snapshots/test_repo/comments/20251119130424.json new file mode 100644 index 00000000..013653bd --- /dev/null +++ b/priv/resource_snapshots/test_repo/comments/20251119130424.json @@ -0,0 +1,201 @@ +{ + "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": "likes", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "arbitrary_timestamp", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "edited_duration", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "planned_duration", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "reading_time", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "version", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "created_at", + "type": "utc_datetime_usec" + }, + { + "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": "special_name_fkey", + "on_delete": "delete", + "on_update": "update", + "primary_key?": true, + "schema": "public", + "table": "posts" + }, + "scale": null, + "size": null, + "source": "post_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, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "comments_author_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "authors" + }, + "scale": null, + "size": null, + "source": "author_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "41FCB2B9D2A9E426EB0439AFF6F3A9208865A18F0598F271C41CCD4637C417D3", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "comments" +} diff --git a/priv/resource_snapshots/test_repo/comments/20251119130424.json.license b/priv/resource_snapshots/test_repo/comments/20251119130424.json.license new file mode 100644 index 00000000..b0a44fab --- /dev/null +++ b/priv/resource_snapshots/test_repo/comments/20251119130424.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2019 ash_postgres contributors + +SPDX-License-Identifier: MIT diff --git a/priv/resource_snapshots/test_repo/posts/20251119130424.json b/priv/resource_snapshots/test_repo/posts/20251119130424.json new file mode 100644 index 00000000..7a21022b --- /dev/null +++ b/priv/resource_snapshots/test_repo/posts/20251119130424.json @@ -0,0 +1,685 @@ +{ + "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?": false, + "default": "1", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "version", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "title_column", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "not_selected_by_default", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "datetime", + "type": "timestamptz(6)" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "score", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "limited_score", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "public", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "is_special", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "category", + "type": "citext" + }, + { + "allow_nil?": true, + "default": "\"sponsored\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "price", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "\"0\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "decimal", + "type": "decimal" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status_enum", + "type": "status" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "metadata", + "type": "map" + }, + { + "allow_nil?": false, + "default": "2", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "constrained_int", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "point", + "type": ["array", "float"] + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "composite_point", + "type": "custom_point" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "string_point", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "person_detail", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "stuff", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "list_of_stuff", + "type": ["array", "map"] + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "uniq_one", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "uniq_two", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "uniq_custom_one", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "uniq_custom_two", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "uniq_on_upper", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "uniq_if_contains_foo", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "base_reading_time", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "model", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "list_containing_nils", + "type": ["array", "text"] + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "ltree_unescaped", + "type": "ltree" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "ltree_escaped", + "type": "ltree" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "created_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "timestamptz(6)" + }, + { + "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": "posts_organization_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "orgs" + }, + "scale": null, + "size": null, + "source": "organization_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, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "posts_parent_post_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "posts" + }, + "scale": null, + "size": null, + "source": "parent_post_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, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "posts_author_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "authors" + }, + "scale": null, + "size": null, + "source": "author_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, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "posts_db_point_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "points" + }, + "scale": null, + "size": null, + "source": "db_point_id", + "type": ["array", "float"] + }, + { + "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": "posts_db_string_point_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "string_points" + }, + "scale": null, + "size": null, + "source": "db_string_point_id", + "type": "text" + } + ], + "base_filter": "type = 'sponsored'", + "check_constraints": [ + { + "attribute": ["price"], + "base_filter": "type = 'sponsored'", + "check": "price > 0", + "name": "price_must_be_positive" + } + ], + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": true, + "error_fields": ["uniq_custom_one", "uniq_custom_two"], + "fields": [ + { + "type": "atom", + "value": "uniq_custom_one" + }, + { + "type": "atom", + "value": "uniq_custom_two" + } + ], + "include": null, + "message": "dude what the heck", + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": true, + "using": null, + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "FB4A8B80CEDEB246B92644A8B1F69BE7A1A8461EAA425810D94AA680FCEF0D60", + "identities": [ + { + "all_tenants?": false, + "base_filter": "type = 'sponsored'", + "index_name": "posts_uniq_if_contains_foo_index", + "keys": [ + { + "type": "atom", + "value": "uniq_if_contains_foo" + } + ], + "name": "uniq_if_contains_foo", + "nils_distinct?": true, + "where": "(uniq_if_contains_foo LIKE '%foo%')" + }, + { + "all_tenants?": false, + "base_filter": "type = 'sponsored'", + "index_name": "posts_uniq_on_upper_index", + "keys": [ + { + "type": "string", + "value": "(UPPER(uniq_on_upper))" + } + ], + "name": "uniq_on_upper", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": "type = 'sponsored'", + "index_name": "posts_uniq_one_and_two_index", + "keys": [ + { + "type": "atom", + "value": "uniq_one" + }, + { + "type": "atom", + "value": "uniq_two" + } + ], + "name": "uniq_one_and_two", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "posts" +} diff --git a/priv/resource_snapshots/test_repo/posts/20251119130424.json.license b/priv/resource_snapshots/test_repo/posts/20251119130424.json.license new file mode 100644 index 00000000..b0a44fab --- /dev/null +++ b/priv/resource_snapshots/test_repo/posts/20251119130424.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2019 ash_postgres contributors + +SPDX-License-Identifier: MIT diff --git a/priv/test_repo/migrations/20251119130424_add_reading_time_calculation_fields.exs b/priv/test_repo/migrations/20251119130424_add_reading_time_calculation_fields.exs new file mode 100644 index 00000000..1e5f080f --- /dev/null +++ b/priv/test_repo/migrations/20251119130424_add_reading_time_calculation_fields.exs @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.TestRepo.Migrations.AddReadingTimeCalculationFields do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:comments) do + add(:edited_duration, :bigint) + add(:planned_duration, :bigint) + add(:reading_time, :bigint) + add(:version, :text) + add(:status, :text) + end + + alter table(:posts) do + add(:base_reading_time, :bigint) + end + end + + def down do + alter table(:posts) do + remove(:base_reading_time) + end + + alter table(:comments) do + remove(:status) + remove(:version) + remove(:reading_time) + remove(:planned_duration) + remove(:edited_duration) + end + end +end diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 649b8e98..a96d4ee7 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -1891,7 +1891,7 @@ defmodule AshSql.AggregateTest do # (like last_read_message_id) in the subquery even if not explicitly selected. Chat - |> Ash.Query.select(:id) + |> Ash.Query.select([:id, :last_read_message_id]) |> Ash.Query.load(:unread_message_count) |> Ash.Query.limit(10) |> Ash.read!() diff --git a/test/calculation_test.exs b/test/calculation_test.exs index 464df4e0..6eca61e8 100644 --- a/test/calculation_test.exs +++ b/test/calculation_test.exs @@ -111,6 +111,33 @@ defmodule AshPostgres.CalculationTest do |> Ash.read!() end + test "runtime loading calculation with fragment referencing aggregate works correctly" do + post = + Post + |> Ash.Changeset.for_create(:create, %{title: "test post"}) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{title: "comment1", likes: 5}) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{title: "comment2", likes: 15}) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + result = + Post + |> Ash.Query.load([:comment_metric, :complex_comment_metric, :multi_agg_calc]) + |> Ash.read!() + + assert [post] = result + assert is_integer(post.comment_metric) + assert is_integer(post.complex_comment_metric) + assert is_integer(post.multi_agg_calc) + end + test "expression calculations don't load when `reuse_values?` is true" do post = Post @@ -1241,4 +1268,200 @@ defmodule AshPostgres.CalculationTest do assert [] == Ash.read!(query) end + + test "expression calculation referencing aggregates loaded via code_interface with load option" do + post = + Post + |> Ash.Changeset.for_create(:create, %{title: "test post"}) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{title: "comment1", likes: 5}) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{title: "comment2", likes: 15}) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + result = Post.get_by_id!(post.id, load: [:comment_metric]) + + assert result.comment_metric == 200 + end + + test "complex SQL fragment calculation with multiple aggregates" do + post = + Post + |> Ash.Changeset.for_create(:create, %{ + title: "test post", + base_reading_time: 500 + }) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{ + title: "comment1", + edited_duration: 100, + planned_duration: 80, + reading_time: 30, + version: :edited + }) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{ + title: "comment2", + edited_duration: 0, + planned_duration: 120, + reading_time: 45, + version: :planned + }) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + result = Post.get_by_id!(post.id, load: [:estimated_reading_time]) + + assert result.estimated_reading_time == 175 + end + + test "calculation with missing aggregate dependencies" do + post = + Post + |> Ash.Changeset.for_create(:create, %{ + title: "test post", + base_reading_time: 500 + }) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{ + title: "modified comment", + edited_duration: 100, + planned_duration: 0, + reading_time: 30, + version: :edited + }) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{ + title: "planned comment", + edited_duration: 0, + planned_duration: 80, + reading_time: 20, + version: :planned + }) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + result = Post.get_by_id!(post.id, load: [:estimated_reading_time]) + + refute match?(%Ash.NotLoaded{}, result.estimated_reading_time), + "Expected calculated value, got: #{inspect(result.estimated_reading_time)}" + end + + test "calculation with filtered aggregates and keyset pagination" do + post = + Post + |> Ash.Changeset.for_create(:create, %{ + title: "test post", + base_reading_time: 500 + }) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{ + title: "completed comment", + edited_duration: 100, + reading_time: 30, + version: :edited, + status: :published + }) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{ + title: "pending comment", + planned_duration: 80, + reading_time: 20, + version: :planned, + status: :pending + }) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + result_both = Post.get_by_id!(post.id, load: [:published_comments, :estimated_reading_time]) + + assert result_both.estimated_reading_time == 150, + "Should calculate correctly with both loaded" + + assert result_both.published_comments == 1, "Should count correctly with both loaded" + end + + test "calculation with keyset pagination works correctly (previously returned NotLoaded)" do + _posts = + Enum.map(1..5, fn i -> + post = + Post + |> Ash.Changeset.for_create(:create, %{ + title: "test post #{i}", + base_reading_time: 100 * i + }) + |> Ash.create!() + + Comment + |> Ash.Changeset.for_create(:create, %{ + title: "comment#{i}", + edited_duration: 50 * i, + planned_duration: 40 * i, + reading_time: 10 * i, + version: :edited, + status: :published + }) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + + post + end) + + first_page = + Post + |> Ash.Query.load([:published_comments, :estimated_reading_time]) + |> Ash.read!(action: :read_with_related_list_agg_filter, page: [limit: 2, count: true]) + + Enum.each(first_page.results, fn post -> + refute match?(%Ash.NotLoaded{}, post.estimated_reading_time), + "First page post #{post.id} should have loaded estimated_reading_time, got: #{inspect(post.estimated_reading_time)}" + end) + + if first_page.more? do + second_page = + Post + |> Ash.Query.load([:published_comments, :estimated_reading_time]) + |> Ash.read!( + action: :read_with_related_list_agg_filter, + page: [ + limit: 2, + after: first_page.results |> List.last() |> Map.get(:__metadata__) |> Map.get(:keyset) + ] + ) + + assert length(second_page.results) > 0, "Second page should have results" + + Enum.each(second_page.results, fn post -> + refute match?(%Ash.NotLoaded{}, post.estimated_reading_time), + "estimated_reading_time should be calculated, not NotLoaded" + + refute match?(%Ash.NotLoaded{}, post.published_comments), + "published_comments should be calculated, not NotLoaded" + + assert post.estimated_reading_time > 0, "estimated_reading_time should be positive" + assert post.published_comments == 1, "Each post has exactly 1 completed comment" + end) + end + end end diff --git a/test/complex_calculation_test.exs b/test/complex_calculation_test.exs new file mode 100644 index 00000000..83291bcb --- /dev/null +++ b/test/complex_calculation_test.exs @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.ComplexCalculationTest do + use AshPostgres.RepoCase, async: false + alias AshPostgres.Test.{Comment, Post} + require Ash.Query + + describe "complex calculations with filtered aggregates" do + test "estimated_reading_time calculation works with filtered aggregates and pagination" do + post = + Post + |> Ash.Changeset.for_create(:create, %{ + title: "Test Post", + base_reading_time: 0 + }) + |> Ash.create!() + + for i <- 1..3 do + Comment + |> Ash.Changeset.for_create(:create, %{ + post_id: post.id, + reading_time: 30 + i * 10, + status: :published + }) + |> Ash.create!() + end + + query_opts = [ + load: [:published_comments, :estimated_reading_time], + page: [limit: 5] + ] + + page_result = + Ash.Query.filter(Post, id == ^post.id) + |> Ash.read!(query_opts) + + [post] = page_result.results + + assert post.estimated_reading_time == 150 + assert post.published_comments == 3 + end + + test "estimated_reading_time works when loaded independently (control test)" do + post = + Post + |> Ash.Changeset.for_create(:create, %{ + title: "Control Test", + base_reading_time: 0 + }) + |> Ash.create!() + + for i <- 1..3 do + Comment + |> Ash.Changeset.for_create(:create, %{ + post_id: post.id, + reading_time: 30 + i * 10, + status: :published + }) + |> Ash.create!() + end + + [post] = + Ash.Query.filter(Post, id == ^post.id) + |> Ash.read!(load: [:estimated_reading_time]) + + assert post.estimated_reading_time == 150 + end + end +end diff --git a/test/support/resources/comment.ex b/test/support/resources/comment.ex index 8d95fa08..55d6f59a 100644 --- a/test/support/resources/comment.ex +++ b/test/support/resources/comment.ex @@ -48,6 +48,16 @@ defmodule AshPostgres.Test.Comment do attribute(:title, :string, public?: true) attribute(:likes, :integer, public?: true) attribute(:arbitrary_timestamp, :utc_datetime_usec, public?: true) + attribute(:edited_duration, :integer, public?: true) + attribute(:planned_duration, :integer, public?: true) + attribute(:reading_time, :integer, public?: true) + attribute(:version, :atom, constraints: [one_of: [:edited, :planned]], public?: true) + + attribute(:status, :atom, + constraints: [one_of: [:published, :draft, :pending]], + public?: true + ) + create_timestamp(:created_at, writable?: true, public?: true) end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index ca823c36..fc619eb6 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -619,6 +619,7 @@ defmodule AshPostgres.Test.Post do attribute(:uniq_custom_two, :string, public?: true) attribute(:uniq_on_upper, :string, public?: true) attribute(:uniq_if_contains_foo, :string, public?: true) + attribute(:base_reading_time, :integer, public?: true) attribute :model, :tuple do constraints( @@ -950,6 +951,34 @@ defmodule AshPostgres.Test.Post do calculate(:score_with_score, :string, expr(score <> score)) calculate(:foo_bar_from_stuff, :string, expr(stuff[:foo][:bar])) + calculate(:comment_metric, :integer, expr(fragment("(? * 100)", count_of_comments))) + + calculate( + :complex_comment_metric, + :integer, + expr( + fragment( + "COALESCE(?, 0) + COALESCE(?, 1) * COALESCE(?, 0)", + sum_of_comment_likes_test, + count_of_comments, + max_comment_likes + ) + ) + ) + + calculate( + :multi_agg_calc, + :integer, + expr( + fragment( + "(? * ?) + ?", + count_of_comments, + count_of_high_like_comments, + sum_of_comment_likes_test + ) + ) + ) + calculate( :has_follower_named_fred, :boolean, @@ -1118,6 +1147,21 @@ defmodule AshPostgres.Test.Post do public?(true) argument(:author_id, :uuid, allow_nil?: false) end + + calculate :estimated_reading_time, + :integer, + expr( + fragment( + "COALESCE(?, ?, ?) + COALESCE(?, 0)", + total_edited_time, + total_planned_time, + base_reading_time, + total_comment_time + ) + ) do + public?(true) + load([:total_edited_time, :total_planned_time, :total_comment_time, :base_reading_time]) + end end aggregates do @@ -1197,6 +1241,25 @@ defmodule AshPostgres.Test.Post do sort(title: :asc_nils_last) end + sum :total_edited_time, :comments, :edited_duration do + filter(expr(version == :edited)) + public?(true) + end + + sum :total_planned_time, :comments, :planned_duration do + filter(expr(version == :planned)) + public?(true) + end + + sum :total_comment_time, :comments, :reading_time do + public?(true) + end + + count :published_comments, :comments do + filter(expr(status == :published)) + public?(true) + end + count :count_comment_titles, :comments do field(:title) end @@ -1279,6 +1342,13 @@ defmodule AshPostgres.Test.Post do read_action(:with_modify_query) end + sum(:sum_of_comment_likes_test, :comments, :likes) + max(:max_comment_likes, :comments, :likes) + + count :count_of_high_like_comments, :comments do + filter(expr(likes > 10)) + end + count :count_of_comments_with_ratings, :comments do filter(expr(has_rating == true)) end