From b8f14ccfcab62e21c46b57273da07b89531ecd17 Mon Sep 17 00:00:00 2001 From: Chaz Watkins Date: Sun, 3 Aug 2025 23:29:27 -0500 Subject: [PATCH 1/5] chore: add field?: false option to resource calculations --- .formatter.exs | 1 + lib/ash/resource/calculation/calculation.ex | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index e313925ae..6625f2c68 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -126,6 +126,7 @@ spark_locals_without_parens = [ fail_if_invalid?: 1, fail_on_not_found?: 1, field: 1, + field?: 1, field_names: 1, field_policy: 1, field_policy: 2, diff --git a/lib/ash/resource/calculation/calculation.ex b/lib/ash/resource/calculation/calculation.ex index 055634df0..d6675200c 100644 --- a/lib/ash/resource/calculation/calculation.ex +++ b/lib/ash/resource/calculation/calculation.ex @@ -17,7 +17,8 @@ defmodule Ash.Resource.Calculation do public?: false, async?: false, sensitive?: false, - type: nil + type: nil, + field?: true @schema [ name: [ @@ -85,6 +86,14 @@ defmodule Ash.Resource.Calculation do doc: """ Whether or not the calculation can be referenced in sorts. """ + ], + field?: [ + type: :boolean, + default: true, + hide: true, + doc: """ + Whether or not the calculation should be included as a field in the resource's record struct. + """ ] ] @@ -132,7 +141,8 @@ defmodule Ash.Resource.Calculation do sortable?: boolean, name: atom(), public?: boolean, - type: nil | Ash.Type.t() + type: nil | Ash.Type.t(), + field?: boolean } @type ref :: {module(), Keyword.t()} | module() From a30ad2475b8af3e0f18e25ab67f10a4011e69702 Mon Sep 17 00:00:00 2001 From: Chaz Watkins Date: Thu, 7 Aug 2025 13:56:47 -0500 Subject: [PATCH 2/5] chore: exclude field?: false resource calculations from resource struct --- lib/ash/resource/info.ex | 28 ++++++++++++++++-------- lib/ash/resource/schema.ex | 4 ++-- test/resource/calculations_test.exs | 33 +++++++++++++++++++++++++++++ test/resource/info_test.exs | 10 +++++++++ 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/lib/ash/resource/info.ex b/lib/ash/resource/info.ex index b78def1ee..a58efaea4 100644 --- a/lib/ash/resource/info.ex +++ b/lib/ash/resource/info.ex @@ -476,7 +476,7 @@ defmodule Ash.Resource.Info do list(Ash.Resource.Calculation.t()) def public_calculations(resource) do resource - |> Extension.get_entities([:calculations]) + |> fields([:calculations]) |> Enum.filter(& &1.public?) end @@ -485,14 +485,14 @@ defmodule Ash.Resource.Info do Ash.Resource.Calculation.t() | nil def public_calculation(resource, name) when is_binary(name) do resource - |> calculations() - |> Enum.find(&(to_string(&1.name) == name && &1.public?)) + |> public_calculations() + |> Enum.find(&(to_string(&1.name) == name)) end def public_calculation(resource, name) do resource - |> calculations() - |> Enum.find(&(&1.name == name && &1.public?)) + |> public_calculations() + |> Enum.find(&(&1.name == name)) end @doc """ @@ -834,7 +834,14 @@ defmodule Ash.Resource.Info do | Ash.Resource.Relationships.relationship() ] def fields(resource, types \\ [:attributes, :aggregates, :calculations, :relationships]) do - Enum.flat_map(types, &Extension.get_entities(resource, [&1])) + types + |> Enum.flat_map(&Extension.get_entities(resource, [&1])) + |> Enum.filter(fn entity -> + case entity do + %Ash.Resource.Calculation{field?: false} -> false + _ -> true + end + end) end @doc "Get a field from a resource by name" @@ -852,15 +859,18 @@ defmodule Ash.Resource.Info do relationship(resource, name) @doc "Returns all public attributes, aggregates, calculations and relationships of a resource" - @spec public_fields(Spark.Dsl.t() | Ash.Resource.t()) :: [ + @spec public_fields( + Spark.Dsl.t() | Ash.Resource.t(), + types :: list(:attributes | :aggregates | :calculations | :relationships) + ) :: [ Ash.Resource.Attribute.t() | Ash.Resource.Aggregate.t() | Ash.Resource.Calculation.t() | Ash.Resource.Relationships.relationship() ] - def public_fields(resource) do + def public_fields(resource, types \\ [:attributes, :aggregates, :calculations, :relationships]) do resource - |> fields() + |> fields(types) |> Enum.filter(& &1.public?) end diff --git a/lib/ash/resource/schema.ex b/lib/ash/resource/schema.ex index f028c7c40..b1f2772d5 100644 --- a/lib/ash/resource/schema.ex +++ b/lib/ash/resource/schema.ex @@ -108,7 +108,7 @@ defmodule Ash.Schema do ) end - for calculation <- Ash.Resource.Info.calculations(__MODULE__), + for calculation <- Ash.Resource.Info.fields(__MODULE__, [:calculations]), calculation.name not in Ash.Resource.reserved_names() do {mod, _} = calculation.calculation @@ -282,7 +282,7 @@ defmodule Ash.Schema do ) end - for calculation <- Ash.Resource.Info.calculations(__MODULE__), + for calculation <- Ash.Resource.Info.fields(__MODULE__, [:calculations]), calculation.name not in Ash.Resource.reserved_names() do {mod, _} = calculation.calculation diff --git a/test/resource/calculations_test.exs b/test/resource/calculations_test.exs index aefd74343..931f97a07 100644 --- a/test/resource/calculations_test.exs +++ b/test/resource/calculations_test.exs @@ -71,6 +71,39 @@ defmodule Ash.Test.Resource.CalculationsTest do assert nil == Ash.Resource.Info.calculation(Post, :totally_legit_calculation) end + test "Calculation with field?: false is excluded from resource record struct" do + defposts do + actions do + defaults [:create] + + default_accept [:name, :contents] + end + + calculations do + calculate :name_and_contents, :string, concat([:name, :contents]) do + public?(true) + end + + calculate :explicit_field_calculation, :string, concat([:name, :contents]) do + field?(true) + end + + calculate :non_field_calculation, :string, concat([:name, :contents]) do + field?(false) + end + end + end + + post = + Post + |> Ash.Changeset.for_create(:create, %{name: "name", contents: "contents"}) + |> Ash.create!() + + assert Map.has_key?(post, :name_and_contents) + assert Map.has_key?(post, :explicit_field_calculation) + refute Map.has_key?(post, :non_field_calculation) + end + test "Calculation descriptions are allowed" do defposts do calculations do diff --git a/test/resource/info_test.exs b/test/resource/info_test.exs index f6b9576fc..a6cfbf061 100644 --- a/test/resource/info_test.exs +++ b/test/resource/info_test.exs @@ -139,6 +139,10 @@ defmodule Ash.Test.Resource.InfoTest do calculate :formatted_post_title, :string, expr("Post title: " <> post_title), public?: true, load: [:post_title] + + calculate :non_field_calculation, :string, expr("Post title: " <> post_title), + load: [:post_title], + field?: false end relationships do @@ -219,6 +223,12 @@ defmodule Ash.Test.Resource.InfoTest do assert %Resource.Relationships.BelongsTo{name: :post} = Info.public_relationship(Post, [:comments, :post]) end + + test "fields/2 excludes calculations with field?: false" do + calculation_fields = Info.fields(Comment, [:calculations]) + + refute Enum.any?(calculation_fields, &(&1.name == :non_field_calculation)) + end end describe "extensions/1" do From 0f0639e03124fa5226f677a98f42a9d97822ff89 Mon Sep 17 00:00:00 2001 From: Chaz Watkins Date: Mon, 4 Aug 2025 09:48:49 -0500 Subject: [PATCH 3/5] chore: field?: false resource calculations cannot be loaded --- lib/ash/query/query.ex | 6 +-- lib/ash/resource/calculation/calculation.ex | 10 +++++ test/resource/calculations_test.exs | 48 +++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/lib/ash/query/query.ex b/lib/ash/query/query.ex index 452f95ed7..f09c29cb4 100644 --- a/lib/ash/query/query.ex +++ b/lib/ash/query/query.ex @@ -2266,7 +2266,7 @@ defmodule Ash.Query do {value, nil} :error -> - {resource_calculation.name, resource_calculation.name} + Ash.Resource.Calculation.query_name_and_load(resource_calculation) end is_map(args) -> @@ -2275,11 +2275,11 @@ defmodule Ash.Query do {value, nil} :error -> - {resource_calculation.name, resource_calculation.name} + Ash.Resource.Calculation.query_name_and_load(resource_calculation) end true -> - {resource_calculation.name, resource_calculation.name} + Ash.Resource.Calculation.query_name_and_load(resource_calculation) end case Calculation.from_resource_calculation(query.resource, resource_calculation, diff --git a/lib/ash/resource/calculation/calculation.ex b/lib/ash/resource/calculation/calculation.ex index d6675200c..60fb55993 100644 --- a/lib/ash/resource/calculation/calculation.ex +++ b/lib/ash/resource/calculation/calculation.ex @@ -218,4 +218,14 @@ defmodule Ash.Resource.Calculation do def expr_calc(expr) do {:ok, {Ash.Resource.Calculation.Expression, expr: expr}} end + + @doc false + @spec query_name_and_load(t()) :: {atom, atom | nil} + def query_name_and_load(calculation) do + if calculation.field? do + {calculation.name, calculation.name} + else + {calculation.name, nil} + end + end end diff --git a/test/resource/calculations_test.exs b/test/resource/calculations_test.exs index 931f97a07..b74067c3e 100644 --- a/test/resource/calculations_test.exs +++ b/test/resource/calculations_test.exs @@ -104,6 +104,54 @@ defmodule Ash.Test.Resource.CalculationsTest do refute Map.has_key?(post, :non_field_calculation) end + test "Calculation with field?: false cannot be loaded directly, but can be used in expressions" do + defmodule Post do + @moduledoc false + use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key :id + + attribute :name, :string do + public?(true) + end + + attribute :contents, :string do + public?(true) + end + end + + actions do + default_accept :* + defaults [:read, :destroy, update: :*, create: :*] + end + + calculations do + calculate :name_and_contents, :string, expr(non_field_calculation) do + public?(true) + end + + calculate :non_field_calculation, :string, concat([:name, :contents]) do + field?(false) + end + end + end + + Post + |> Ash.Changeset.for_create(:create, %{name: "name", contents: "contents"}) + |> Ash.create!() + + post = + Post + |> Ash.Query.for_read(:read, %{}) + |> Ash.read_one!() + |> Ash.load!([:non_field_calculation, :name_and_contents]) + + assert :name_and_contents in Map.keys(post) + assert post.name_and_contents == "namecontents" + refute :non_field_calculation in Map.keys(post) + end + test "Calculation descriptions are allowed" do defposts do calculations do From d59201dfb1c67ecbfde2f9fb72b28be17ec09ccf Mon Sep 17 00:00:00 2001 From: Chaz Watkins Date: Mon, 4 Aug 2025 12:44:35 -0500 Subject: [PATCH 4/5] chore: return InvalidLoad when loading field?: false calcs --- lib/ash/query/query.ex | 36 +++++++++++--- lib/ash/resource/calculation/calculation.ex | 7 ++- test/resource/calculations_test.exs | 55 ++++++++++++++++----- 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/lib/ash/query/query.ex b/lib/ash/query/query.ex index f09c29cb4..18d9dc7de 100644 --- a/lib/ash/query/query.ex +++ b/lib/ash/query/query.ex @@ -2216,7 +2216,9 @@ defmodule Ash.Query do end {field, {args, load_through}}, query -> - if resource_calculation = Ash.Resource.Info.calculation(query.resource, field) do + resource_calculation = Ash.Resource.Info.calculation(query.resource, field) + + if Ash.Resource.Calculation.can_load?(resource_calculation) do load_resource_calculation(query, resource_calculation, args, load_through) else add_error( @@ -2232,7 +2234,15 @@ defmodule Ash.Query do load_relationship(query, rel, rest, opts) resource_calculation = Ash.Resource.Info.calculation(query.resource, field) -> - load_resource_calculation(query, resource_calculation, rest) + if Ash.Resource.Calculation.can_load?(resource_calculation) do + load_resource_calculation(query, resource_calculation, rest) + else + add_error( + query, + :load, + Ash.Error.Query.InvalidLoad.exception(load: [{field, rest}]) + ) + end attribute = Ash.Resource.Info.attribute(query.resource, field) -> if Ash.Type.can_load?(attribute.type, attribute.constraints) do @@ -2360,11 +2370,12 @@ defmodule Ash.Query do end end + # TODO: Remove this dead function - resource_calc_to_calc/4 @doc false - def resource_calc_to_calc(query, name, resource_calculation, args \\ %{}) do + def resource_calc_to_calc(query, _name, resource_calculation, args \\ %{}) do {name, load} = case fetch_key(args, :as) do - :error -> {name, name} + :error -> Ash.Resource.Calculation.query_name_and_load(resource_calculation) {:ok, key} -> {key, nil} end @@ -2475,7 +2486,15 @@ defmodule Ash.Query do end resource_calculation = Ash.Resource.Info.calculation(query.resource, field) -> - load_resource_calculation(query, resource_calculation, %{}) + if Ash.Resource.Calculation.can_load?(resource_calculation) do + load_resource_calculation(query, resource_calculation, %{}) + else + add_error( + query, + :load, + Ash.Error.Query.InvalidLoad.exception(load: field) + ) + end true -> add_error(query, :load, Ash.Error.Query.InvalidLoad.exception(load: field)) @@ -3635,14 +3654,17 @@ defmodule Ash.Query do args = Map.put(args, :as, as_name) - if resource_calculation = Ash.Resource.Info.calculation(query.resource, calc_name) do + with %Ash.Resource.Calculation{} = resource_calculation <- + Ash.Resource.Info.calculation(query.resource, calc_name), + true <- Ash.Resource.Calculation.can_load?(resource_calculation) do if opts[:load_through] do load_resource_calculation(query, resource_calculation, args) else load_resource_calculation(query, resource_calculation, args, opts[:load_through]) end else - add_error(query, "No such calculation: #{inspect(calc_name)}") + nil -> add_error(query, "No such calculation: #{inspect(calc_name)}") + false -> add_error(query, :load, Ash.Error.Query.InvalidLoad.exception(load: calc_name)) end end diff --git a/lib/ash/resource/calculation/calculation.ex b/lib/ash/resource/calculation/calculation.ex index 60fb55993..dcd781529 100644 --- a/lib/ash/resource/calculation/calculation.ex +++ b/lib/ash/resource/calculation/calculation.ex @@ -219,10 +219,15 @@ defmodule Ash.Resource.Calculation do {:ok, {Ash.Resource.Calculation.Expression, expr: expr}} end + @doc false + @spec can_load?(t() | nil) :: boolean() + def can_load?(%__MODULE__{} = calculation), do: calculation.field? + def can_load?(_), do: false + @doc false @spec query_name_and_load(t()) :: {atom, atom | nil} def query_name_and_load(calculation) do - if calculation.field? do + if can_load?(calculation) do {calculation.name, calculation.name} else {calculation.name, nil} diff --git a/test/resource/calculations_test.exs b/test/resource/calculations_test.exs index b74067c3e..8b268210d 100644 --- a/test/resource/calculations_test.exs +++ b/test/resource/calculations_test.exs @@ -104,8 +104,8 @@ defmodule Ash.Test.Resource.CalculationsTest do refute Map.has_key?(post, :non_field_calculation) end - test "Calculation with field?: false cannot be loaded directly, but can be used in expressions" do - defmodule Post do + test "Calculation with field?: false can be used in expressions" do + defmodule PostForCalculation do @moduledoc false use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets @@ -137,19 +137,52 @@ defmodule Ash.Test.Resource.CalculationsTest do end end - Post - |> Ash.Changeset.for_create(:create, %{name: "name", contents: "contents"}) - |> Ash.create!() - post = - Post - |> Ash.Query.for_read(:read, %{}) - |> Ash.read_one!() - |> Ash.load!([:non_field_calculation, :name_and_contents]) + PostForCalculation + |> Ash.Changeset.for_create(:create, %{name: "name", contents: "contents"}) + |> Ash.create!() + |> Ash.load!([:name_and_contents]) assert :name_and_contents in Map.keys(post) assert post.name_and_contents == "namecontents" - refute :non_field_calculation in Map.keys(post) + end + + test "Calculation with field?: false returns InvalidLoad error when loaded directly" do + defmodule PostForCalculationError do + @moduledoc false + use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key :id + + attribute :name, :string do + public?(true) + end + + attribute :contents, :string do + public?(true) + end + end + + actions do + default_accept :* + defaults [:read, :destroy, update: :*, create: :*] + end + + calculations do + calculate :non_field_calculation, :string, concat([:name, :contents]) do + field?(false) + end + end + end + + post = + PostForCalculationError + |> Ash.Changeset.for_create(:create, %{name: "name", contents: "contents"}) + |> Ash.create!() + + assert {:error, %{errors: [%Ash.Error.Query.InvalidLoad{load: :non_field_calculation}]}} = + Ash.load(post, [:non_field_calculation]) end test "Calculation descriptions are allowed" do From 55b53d4264fd9a873b2d6350218b4f966f688764 Mon Sep 17 00:00:00 2001 From: Chaz Watkins Date: Mon, 4 Aug 2025 13:06:47 -0500 Subject: [PATCH 5/5] chore: unhide Resource.Calculation field? option --- documentation/dsls/DSL-Ash.Resource.md | 1 + documentation/topics/resources/calculations.md | 14 ++++++++++++++ lib/ash/resource/calculation/calculation.ex | 1 - 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/documentation/dsls/DSL-Ash.Resource.md b/documentation/dsls/DSL-Ash.Resource.md index aa29ac84f..0f0e4ccea 100644 --- a/documentation/dsls/DSL-Ash.Resource.md +++ b/documentation/dsls/DSL-Ash.Resource.md @@ -3742,6 +3742,7 @@ end | [`allow_nil?`](#calculations-calculate-allow_nil?){: #calculations-calculate-allow_nil? } | `boolean` | `true` | Whether or not the calculation can return nil. | | [`filterable?`](#calculations-calculate-filterable?){: #calculations-calculate-filterable? } | `boolean \| :simple_equality` | `true` | Whether or not the calculation should be usable in filters. | | [`sortable?`](#calculations-calculate-sortable?){: #calculations-calculate-sortable? } | `boolean` | `true` | Whether or not the calculation can be referenced in sorts. | +| [`field?`](#calculations-calculate-field?){: #calculations-calculate-field? } | `boolean` | `true` | Whether or not the calculation should be included as a field in the resource's record struct. | ### calculations.calculate.argument diff --git a/documentation/topics/resources/calculations.md b/documentation/topics/resources/calculations.md index a3eee05f8..fbd7fdfe8 100644 --- a/documentation/topics/resources/calculations.md +++ b/documentation/topics/resources/calculations.md @@ -181,3 +181,17 @@ fields by using the configured fields in the map and providing further loads. Ash.load!(organization, user_statuses: {%{}, [user: [full_name: %{separator: " "}]]}), # => [%{user: %User{full_name: "Zach Daniel"}, status: :active}, %{user: %User{full_name: "Tobey Maguire"}, status: :inactive}] ``` + +### Non-Field Calculations + +By default, calculations are included as fields on the resource's struct. + +To exclude a calculation from the resource's struct, you can set `field?: false`. This excludes the calculation from the resource's struct, makes it not loadable, but still allows it to be used in expressions. + +```elixir +calculations do + calculate :full_name, :string, expr(first_name <> " " <> last_name) do + field? false + end +end +``` diff --git a/lib/ash/resource/calculation/calculation.ex b/lib/ash/resource/calculation/calculation.ex index dcd781529..88d607c9e 100644 --- a/lib/ash/resource/calculation/calculation.ex +++ b/lib/ash/resource/calculation/calculation.ex @@ -90,7 +90,6 @@ defmodule Ash.Resource.Calculation do field?: [ type: :boolean, default: true, - hide: true, doc: """ Whether or not the calculation should be included as a field in the resource's record struct. """