Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions documentation/dsls/DSL-Ash.Resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions documentation/topics/resources/calculations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
42 changes: 32 additions & 10 deletions lib/ash/query/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -2266,7 +2276,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) ->
Expand All @@ -2275,11 +2285,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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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

Expand Down
28 changes: 26 additions & 2 deletions lib/ash/resource/calculation/calculation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ defmodule Ash.Resource.Calculation do
public?: false,
async?: false,
sensitive?: false,
type: nil
type: nil,
field?: true

@schema [
name: [
Expand Down Expand Up @@ -85,6 +86,13 @@ defmodule Ash.Resource.Calculation do
doc: """
Whether or not the calculation can be referenced in sorts.
"""
],
field?: [
type: :boolean,
default: true,
doc: """
Whether or not the calculation should be included as a field in the resource's record struct.
"""
]
]

Expand Down Expand Up @@ -132,7 +140,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()
Expand Down Expand Up @@ -208,4 +217,19 @@ defmodule Ash.Resource.Calculation do
def expr_calc(expr) 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 can_load?(calculation) do
{calculation.name, calculation.name}
else
{calculation.name, nil}
end
end
end
28 changes: 19 additions & 9 deletions lib/ash/resource/info.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 """
Expand Down Expand Up @@ -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"
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions lib/ash/resource/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
114 changes: 114 additions & 0 deletions test/resource/calculations_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,120 @@ 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 with field?: false can be used in expressions" do
defmodule PostForCalculation 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 =
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"
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
defposts do
calculations do
Expand Down
Loading
Loading