improvement: support select_by_default? flag on attributes

This commit is contained in:
Zach Daniel 2024-09-10 13:24:56 -04:00
parent 0f31d463d9
commit e33dc23a07
19 changed files with 117 additions and 63 deletions

View file

@ -187,6 +187,7 @@ spark_locals_without_parens = [
resource: 1,
resource: 2,
run: 1,
select_by_default?: 1,
sensitive?: 1,
short_name: 1,
skip_global_validations?: 1,

View file

@ -89,6 +89,7 @@ end
| [`description`](#attributes-attribute-description){: #attributes-attribute-description } | `String.t` | | An optional description for the attribute. |
| [`sensitive?`](#attributes-attribute-sensitive?){: #attributes-attribute-sensitive? } | `boolean` | `false` | Whether or not the attribute value contains sensitive information, like PII(Personally Identifiable Information). See the [Sensitive Data guide](/documentation/topics/security/sensitive-data.md) for more. |
| [`source`](#attributes-attribute-source){: #attributes-attribute-source } | `atom` | | If the field should be mapped to a different name in the data layer. Support varies by data layer. |
| [`select_by_default?`](#attributes-attribute-select_by_default?){: #attributes-attribute-select_by_default? } | `boolean` | `true` | Whether or not the attribute is selected by default. |
| [`always_select?`](#attributes-attribute-always_select?){: #attributes-attribute-always_select? } | `boolean` | `false` | Whether or not to ensure this attribute is always selected when reading from the database, regardless of applied select statements. |
| [`primary_key?`](#attributes-attribute-primary_key?){: #attributes-attribute-primary_key? } | `boolean` | `false` | Whether the attribute is the primary key. Composite primary key is also possible by using `primary_key? true` in more than one attribute. If primary_key? is true, allow_nil? must be false. |
| [`allow_nil?`](#attributes-attribute-allow_nil?){: #attributes-attribute-allow_nil? } | `boolean` | `true` | Whether or not the attribute can be set to nil. If nil value is given error is raised. |

View file

@ -782,10 +782,14 @@ defmodule Ash.Actions.Helpers do
resource
|> Ash.Resource.Info.attributes()
|> Enum.flat_map(fn attribute ->
if attribute.always_select? || attribute.primary_key? || attribute.name in select do
[]
if is_nil(select) do
attribute.select_by_default?
else
[attribute.name]
if attribute.always_select? || attribute.primary_key? || attribute.name in select do
[]
else
[attribute.name]
end
end
end)
|> Enum.reduce(result, fn key, record ->
@ -804,22 +808,4 @@ defmodule Ash.Actions.Helpers do
results
end
end
def attributes_to_select(%{select: nil, resource: resource}) do
resource
|> Ash.Resource.Info.attributes()
|> Enum.map(& &1.name)
end
def attributes_to_select(%{select: select, resource: resource}) do
resource
|> Ash.Resource.Info.attributes()
|> Enum.flat_map(fn attribute ->
if attribute.always_select? || attribute.primary_key? || attribute.name in select do
[attribute.name]
else
[]
end
end)
end
end

View file

@ -898,12 +898,10 @@ defmodule Ash.Actions.Read do
if query.select do
query
else
to_select =
query.resource
|> Ash.Resource.Info.attributes()
|> Enum.map(& &1.name)
Ash.Query.select(query, to_select)
Ash.Query.select(
query,
Ash.Resource.Info.selected_by_default_attribute_names(query.resource)
)
end
end

View file

@ -438,9 +438,21 @@ defmodule Ash.Changeset do
"""
def select(changeset, fields, opts \\ []) do
if opts[:replace?] do
%{changeset | select: Enum.uniq(List.wrap(fields))}
case fields do
%MapSet{} = fields -> %{changeset | select: Enum.to_list(fields)}
fields -> %{changeset | select: Enum.uniq(List.wrap(fields))}
end
else
%{changeset | select: Enum.uniq(List.wrap(fields) ++ (changeset.select || []))}
case fields do
%MapSet{} ->
%{
changeset
| select: MapSet.union(MapSet.new(changeset.select), fields) |> MapSet.to_list()
}
fields ->
%{changeset | select: Enum.uniq(List.wrap(fields) ++ (changeset.select || []))}
end
end
end
@ -476,10 +488,7 @@ defmodule Ash.Changeset do
if changeset.select do
Ash.Changeset.select(changeset, List.wrap(fields))
else
to_select =
changeset.resource
|> Ash.Resource.Info.attributes()
|> Enum.map(& &1.name)
to_select = Ash.Resource.Info.selected_by_default_attribute_names(changeset.resource)
Ash.Changeset.select(changeset, to_select)
end

View file

@ -844,9 +844,14 @@ defmodule Ash.Query do
Use `ensure_selected/2` if you wish to make sure a field has been selected, without deselecting any other fields.
"""
def select(query, fields, opts \\ []) do
fields =
case fields do
%MapSet{} = fields -> fields
fields -> MapSet.new(List.wrap(fields))
end
query = new(query)
existing_fields = Ash.Resource.Info.attribute_names(query.resource)
fields = MapSet.new(List.wrap(fields))
valid_fields = MapSet.intersection(fields, existing_fields)
@ -863,16 +868,16 @@ defmodule Ash.Query do
query
end
always_select =
select =
valid_fields
|> MapSet.union(Ash.Resource.Info.always_selected_attribute_names(query.resource))
|> MapSet.union(MapSet.new(Ash.Resource.Info.primary_key(query.resource)))
new_select =
if opts[:replace?] do
always_select
select
else
MapSet.union(MapSet.new(query.select || []), always_select)
MapSet.union(MapSet.new(query.select || []), select)
end
%{query | select: MapSet.to_list(new_select)}
@ -1021,10 +1026,7 @@ defmodule Ash.Query do
if query.select do
Ash.Query.select(query, List.wrap(fields))
else
to_select =
query.resource
|> Ash.Resource.Info.attributes()
|> Enum.map(& &1.name)
to_select = Ash.Resource.Info.selected_by_default_attribute_names(query.resource)
Ash.Query.select(query, to_select)
end
@ -1063,22 +1065,26 @@ defmodule Ash.Query do
select =
if query.select do
query.select
query.select -- List.wrap(fields)
else
query.resource
|> Ash.Resource.Info.attributes()
|> Enum.map(& &1.name)
MapSet.difference(
Ash.Resource.Info.selected_by_default_attribute_names(query.resource),
MapSet.new(List.wrap(fields))
)
end
select = select -- List.wrap(fields)
select(query, select, replace?: true)
end
def selecting?(query, field) do
case query.select do
nil ->
not is_nil(Ash.Resource.Info.attribute(query.resource, field))
query.resource
|> Ash.Resource.Info.attribute(field)
|> case do
%{select_by_default?: true} -> true
_ -> false
end
select ->
if field in select do

View file

@ -10,6 +10,7 @@ defmodule Ash.Resource.Attribute do
:public?,
:writable?,
:always_select?,
:select_by_default?,
:default,
:update_default,
:description,
@ -39,6 +40,7 @@ defmodule Ash.Resource.Attribute do
primary_key?: boolean(),
public?: boolean(),
sortable?: boolean(),
select_by_default?: boolean(),
default: nil | term | (-> term),
update_default: nil | term | (-> term) | (Ash.Resource.record() -> term),
sensitive?: boolean(),
@ -79,6 +81,13 @@ defmodule Ash.Resource.Attribute do
If the field should be mapped to a different name in the data layer. Support varies by data layer.
"""
],
select_by_default?: [
type: :boolean,
default: true,
doc: """
Whether or not the attribute is selected by default.
"""
],
always_select?: [
type: :boolean,
default: false,

View file

@ -1496,6 +1496,7 @@ defmodule Ash.Resource.Dsl do
Ash.Resource.Verifiers.ValidateRelationshipAttributesMatch,
Ash.Resource.Verifiers.VerifyReservedCalculationArguments,
Ash.Resource.Verifiers.VerifyIdentityFields,
Ash.Resource.Verifiers.VerifySelectedByDefault,
Ash.Resource.Verifiers.EnsureAggregateFieldIsAttributeOrCalculation,
Ash.Resource.Verifiers.ValidateRelationshipAttributes,
Ash.Resource.Verifiers.NoReservedFieldNames,

View file

@ -5,7 +5,7 @@ defmodule Ash.Resource.Igniter do
def list_resources(igniter) do
Igniter.Code.Module.find_all_matching_modules(igniter, fn _mod, zipper ->
zipper
|> Igniter.Code.Module.move_to_use(resource_mods())
|> Igniter.Code.Module.move_to_use(resource_mods(igniter))
|> case do
{:ok, _} ->
true
@ -24,7 +24,7 @@ defmodule Ash.Resource.Igniter do
{:ok, {igniter, _source, zipper}} ->
with {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper),
{:ok, zipper} <-
Igniter.Code.Module.move_to_use(zipper, resource_mods()),
Igniter.Code.Module.move_to_use(zipper, resource_mods(igniter)),
{:ok, zipper} <-
Igniter.Code.Function.move_to_nth_argument(zipper, 1),
{:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :domain),
@ -41,8 +41,8 @@ defmodule Ash.Resource.Igniter do
end
end
def resource_mods do
app_name = Igniter.Project.Application.app_name()
def resource_mods(igniter) do
app_name = Igniter.Project.Application.app_name(igniter)
[Ash.Resource | List.wrap(Application.get_env(app_name, :base_resources))]
end

View file

@ -779,6 +779,11 @@ defmodule Ash.Resource.Info do
Extension.get_persisted(resource, :always_selected_attribute_names)
end
@spec selected_by_default_attribute_names(Spark.Dsl.t() | Ash.Resource.t()) :: MapSet.t()
def selected_by_default_attribute_names(resource) do
Extension.get_persisted(resource, :selected_by_default_attribute_names)
end
@doc "Returns all attributes, aggregates, calculations and relationships of a resource"
@spec fields(
Spark.Dsl.t() | Ash.Resource.t(),

View file

@ -66,12 +66,18 @@ defmodule Ash.Resource.Transformers.AttributesByName do
|> Enum.map(& &1.name)
|> MapSet.new()
selected_by_default_attribute_names =
Enum.filter(attributes, & &1.select_by_default?)
|> Enum.map(& &1.name)
|> MapSet.new()
{:ok,
persist(
dsl_state,
%{
attributes_by_name: attributes_by_name,
attribute_names: attribute_names,
selected_by_default_attribute_names: selected_by_default_attribute_names,
create_attributes_with_static_defaults: create_attributes_with_static_defaults,
create_attributes_with_non_matching_lazy_defaults:
create_attributes_with_non_matching_lazy_defaults,

View file

@ -0,0 +1,30 @@
defmodule Ash.Resource.Verifiers.VerifySelectedByDefault do
@moduledoc """
Raises an error when a required primary key is missing
"""
use Spark.Dsl.Verifier
alias Spark.Dsl.Verifier
def verify(dsl) do
resource = Verifier.get_persisted(dsl, :module)
data_layer = Ash.DataLayer.data_layer(resource)
if is_nil(data_layer) || data_layer.can?(resource, :select) do
:ok
else
Enum.each(Ash.Resource.Info.attributes(dsl), fn attribute ->
if !attribute.select_by_default? do
raise Spark.Error.DslError,
module: resource,
path: [:attributes, attribute.name],
message: """
Attribute #{inspect(resource)}.#{attribute.name} was marked with `select_by_default: false`,
but the data layer #{inspect(data_layer)} does not support selecting attributes.
This means that all attributes will always be selected.
"""
end
end)
end
end
end

View file

@ -18,7 +18,7 @@ defmodule Mix.Tasks.Ash.Gen.BaseResource do
glob = Path.join([base_resource_file, "..", "**", "*.ex"])
app_name = Igniter.Project.Application.app_name()
app_name = Igniter.Project.Application.app_name(igniter)
# need `Igniter.glob(igniter, path, filter)` to get all existing or new files that match a path & condition
# for each file that defines a resource that uses `Ash.Resource`, that is "further down" from this file,

View file

@ -28,7 +28,7 @@ defmodule Mix.Tasks.Ash.Gen.Domain do
domain_file = Igniter.Code.Module.proper_location(domain)
app_name = Igniter.Project.Application.app_name()
app_name = Igniter.Project.Application.app_name(igniter)
if "--ignore-if-exists" in argv && Igniter.exists?(igniter, domain_file) do
igniter

View file

@ -72,7 +72,7 @@ defmodule Mix.Tasks.Ash.Gen.Resource do
def igniter(igniter, argv) do
{%{resource: resource}, argv} = positional_args!(argv)
resource = Igniter.Code.Module.parse(resource)
app_name = Igniter.Project.Application.app_name()
app_name = Igniter.Project.Application.app_name(igniter)
options = options!(argv)

View file

@ -81,10 +81,12 @@ defmodule Mix.Tasks.Ash.Install do
end
defp generate_example(igniter, argv) do
domain_module_name = Igniter.Code.Module.module_name("Support")
ticket_resource = Igniter.Code.Module.module_name("Support.Ticket")
representative_resource = Igniter.Code.Module.module_name("Support.Representative")
ticket_status_module_name = Igniter.Code.Module.module_name("Support.Ticket.Types.Status")
domain_module_name = Igniter.Code.Module.module_name(igniter, "Support")
ticket_resource = Igniter.Code.Module.module_name(igniter, "Support.Ticket")
representative_resource = Igniter.Code.Module.module_name(igniter, "Support.Representative")
ticket_status_module_name =
Igniter.Code.Module.module_name(igniter, "Support.Ticket.Types.Status")
igniter
|> Igniter.compose_task("ash.gen.domain", [inspect(domain_module_name)])

View file

@ -360,7 +360,7 @@ defmodule Ash.MixProject do
{:simple_sat, "~> 0.1 and >= 0.1.1", optional: true},
# Code Generators
{:igniter, "~> 0.3 and >= 0.3.11"},
{:igniter, "~> 0.3 and >= 0.3.33"},
# IO Utilities
{:owl, "~> 0.11"},

View file

@ -18,7 +18,7 @@
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
"git_ops": {:hex, :git_ops, "2.6.1", "cc7799a68c26cf814d6d1a5121415b4f5bf813de200908f930b27a2f1fe9dad5", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "ce62d07e41fe993ec22c35d5edb11cf333a21ddaead6f5d9868fcb607d42039e"},
"glob_ex": {:hex, :glob_ex, "0.1.8", "f7ef872877ca2ae7a792ab1f9ff73d9c16bf46ecb028603a8a3c5283016adc07", [:mix], [], "hexpm", "9e39d01729419a60a937c9260a43981440c43aa4cadd1fa6672fecd58241c464"},
"igniter": {:hex, :igniter, "0.3.25", "cce36fd49b499d215d0605ee3bfeb8fabe2f86b70b2df24ef3a50797409bceee", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [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", "ffccb3c03cfdc8694be27a4c1d5615799ac140c27f32c74d1817171c4d411a62"},
"igniter": {:hex, :igniter, "0.3.34", "fee93422583884b4a6985de45797097d36f36283d4e61c6154d0e8ec02e19e2b", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [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", "b8e0bd0cdc8354b44f292a3eab4eaac155e4a9c9784b066ec29a2587595bcae8"},
"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"},
@ -39,7 +39,7 @@
"simple_sat": {:hex, :simple_sat, "0.1.3", "f650fc3c184a5fe741868b5ac56dc77fdbb428468f6dbf1978e14d0334497578", [:mix], [], "hexpm", "a54305066a356b7194dc81db2a89232bacdc0b3edaef68ed9aba28dcbc34887b"},
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
"sourceror": {:hex, :sourceror, "1.6.0", "9907884e1449a4bd7dbaabe95088ed4d9a09c3c791fb0103964e6316bc9448a7", [:mix], [], "hexpm", "e90aef8c82dacf32c89c8ef83d1416fc343cd3e5556773eeffd2c1e3f991f699"},
"spark": {:hex, :spark, "2.2.24", "0cbd0e224af530f8f12f0e83ac5743b21802fb821d85b58d32a4da7e2268522b", [:mix], [{:igniter, ">= 0.2.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f05fd64ef74b3f3fe7817743962956dcc8a8e84bb9dc796ac7bf7fdcf4db5b6d"},
"spark": {:hex, :spark, "2.2.26", "1701f388a9cfb2e27cd037b6f4b72a999e49bdb2d2f946bdbde8a991ce42c499", [:mix], [{:igniter, ">= 0.2.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "7a57860e4d15ab2e395dffeac617f3ee64d371b47f7b3d718a8d535d75cc7556"},
"spitfire": {:hex, :spitfire, "0.1.3", "7ea0f544005dfbe48e615ed90250c9a271bfe126914012023fd5e4b6b82b7ec7", [:mix], [], "hexpm", "d53b5107bcff526a05c5bb54c95e77b36834550affd5830c9f58760e8c543657"},
"splode": {:hex, :splode, "0.2.4", "71046334c39605095ca4bed5d008372e56454060997da14f9868534c17b84b53", [:mix], [], "hexpm", "ca3b95f0d8d4b482b5357954fec857abd0fa3ea509d623334c1328e7382044c2"},
"statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"},

View file

@ -613,7 +613,7 @@ defmodule Ash.Test.Actions.ReadTest do
|> strip_metadata()
end
test "a sort will sor rows accordingly when descending", %{
test "a sort will sort rows accordingly when descending", %{
post1: post1,
post2: post2
} do