improvement: add filterable? option to relationships

improvement: add data layer capability for aggregate relationships & filter relationships
improvement: add guide on manual relationships
This commit is contained in:
Zach Daniel 2022-09-13 13:52:11 -04:00
parent 4260b9a1c9
commit e36f8c3e59
27 changed files with 154 additions and 43 deletions

View file

@ -0,0 +1,39 @@
# Defining Manual Relationships
Manual relationships allow for expressing complex/non-typical relationships between resources in a standard way.
Individual data layers may interact with manual relationships in their own way, so see their corresponding guides.
By default, the only thing manual relationships support is being loaded.
## Example
```elixir
# in the resource
relationships do
has_many :tickets_above_threshold, Helpdesk.Support.Ticket do
manual Helpdesk.Support.Ticket.Relationships.TicketsAboveThreshold
end
end
# implementation
defmodule Helpdesk.Support.Ticket.Relationships.TicketsAboveThreshold do
use Ash.Resource.ManualRelationship
require Ash.Query
def load(records, _opts, %{query: query, actor: actor, authorize?: authorize?}) do
# Use existing records to limit resultds
rep_ids = Enum.map(records, & &1.id)
# Using Ash to get the destination records is ideal, so you can authorize access like normal
# but if you need to use a raw ecto query here, you can. As long as you return the right structure.
{:ok,
query
|> Ash.Query.filter(representative_id in ^rep_ids)
|> Ash.Query.filter(priority > representative.priority_threshold)
|> Helpdesk.Support.read!(actor: actor, authorize?: authorize?)
# Return the items grouped by the primary key of the source, i.e representative.id => [...tickets above threshold]
|> Enum.group_by(& &1.representative_id)}
end
end
```

View file

@ -149,7 +149,19 @@ defmodule Ash.Actions.Load do
primary_key = Ash.Resource.Info.primary_key(last_relationship.source)
map_or_update(data, lead_path, fn record ->
Map.put(record, last_relationship.name, Map.get(value, Map.take(record, primary_key)))
case primary_key do
[field] ->
Map.put(
record,
last_relationship.name,
Map.get(value, Map.take(record, primary_key)) ||
Map.get(value, Map.get(record, field)) || []
)
_ ->
Map.put(record, last_relationship.name, Map.get(value, Map.take(record, primary_key))) ||
[]
end
end)
end

View file

@ -344,7 +344,7 @@ defmodule Ash.Actions.ManagedRelationships do
def validate_required_belongs_to({changeset, instructions}, preflight?) do
changeset.resource
|> Ash.Resource.Info.relationships()
|> Enum.filter(&(&1.type == :belongs_to && &1.required?))
|> Enum.filter(&(&1.type == :belongs_to && !&1.allow_nil?))
|> keep_relationships_to_validate(changeset, preflight?)
|> Enum.reduce({changeset, instructions}, fn required_relationship,
{changeset, instructions} ->

View file

@ -19,6 +19,7 @@ defmodule Ash.DataLayer do
| {:lateral_join, list(Ash.Resource.t())}
| {:join, Ash.Resource.t()}
| {:aggregate, Ash.Query.Aggregate.kind()}
| {:aggregate_relationship, Ash.Resource.Relationships.relationship()}
| {:query_aggregate, Ash.Query.Aggregate.kind()}
| :select
| :aggregate_filter
@ -34,6 +35,7 @@ defmodule Ash.DataLayer do
| :transact
| :filter
| {:filter_expr, struct}
| {:filter_relationship, Ash.Resource.Relationships.relationship()}
| :sort
| {:sort, Ash.Type.t()}
| :upsert

View file

@ -190,6 +190,8 @@ defmodule Ash.DataLayer.Ets do
def can?(_, :upsert), do: true
def can?(_, :aggregate_filter), do: true
def can?(_, :aggregate_sort), do: true
def can?(_, {:aggregate_relationship, _}), do: true
def can?(_, {:filter_relationship, _}), do: true
def can?(_, {:aggregate, :count}), do: true
def can?(_, :create), do: true
def can?(_, :read), do: true

View file

@ -94,6 +94,7 @@ defmodule Ash.DataLayer.Mnesia do
def can?(_, :destroy), do: true
def can?(_, :sort), do: true
def can?(_, :filter), do: true
def can?(_, {:filter_relationship, _}), do: true
def can?(_, :limit), do: true
def can?(_, :offset), do: true
def can?(_, :boolean_filter), do: true

View file

@ -1937,7 +1937,7 @@ defmodule Ash.Filter do
hydrate_refs(List.wrap(nested_statement), context),
refs <- list_refs(args),
:ok <-
validate_not_crossing_datalayer_boundaries(
validate_refs(
refs,
context.root_resource,
{function, nested_statement}
@ -2117,7 +2117,7 @@ defmodule Ash.Filter do
hydrate_refs(nested_statement, context),
refs <- list_refs([left, right]),
:ok <-
validate_not_crossing_datalayer_boundaries(
validate_refs(
refs,
context.root_resource,
{field, nested_statement}
@ -2204,7 +2204,46 @@ defmodule Ash.Filter do
end
end
defp validate_not_crossing_datalayer_boundaries(refs, resource, expr) do
defp validate_refs(refs, resource, expr) do
with :ok <- validate_filterable_relationship_paths(refs, resource) do
validate_not_crossing_data_layer_boundaries(refs, resource, expr)
end
end
defp validate_filterable_relationship_paths(refs, resource) do
Enum.find_value(
refs,
:ok,
fn ref ->
case check_filterable(resource, ref.relationship_path) do
:ok ->
false
{:error, error} ->
{:error, error}
end
end
)
end
defp check_filterable(_resource, []), do: :ok
defp check_filterable(resource, [relationship | rest]) do
relationship = Ash.Resource.Info.relationship(resource, relationship)
if relationship.filterable? do
if Ash.DataLayer.data_layer_can?(resource, {:filter_relationship, relationship}) do
check_filterable(relationship.destination, rest)
else
{:error, "#{inspect(resource)}.#{relationship.name} is not filterable"}
end
else
{:error,
"#{inspect(resource)}.#{relationship.name} has been configured as filterable?: false"}
end
end
defp validate_not_crossing_data_layer_boundaries(refs, resource, expr) do
refs
|> Enum.flat_map(&each_related(resource, &1.relationship_path))
|> Enum.filter(& &1)
@ -2252,7 +2291,7 @@ defmodule Ash.Filter do
hydrate_refs(args, context),
refs <- list_refs([left, right]),
:ok <-
validate_not_crossing_datalayer_boundaries(refs, context.root_resource, call),
validate_refs(refs, context.root_resource, call),
{:ok, operator} <- Operator.new(op_module, left, right) do
if is_boolean(operator) do
{:ok, operator}
@ -2313,7 +2352,7 @@ defmodule Ash.Filter do
{:ok, args} <-
hydrate_refs(args, context),
refs <- list_refs(args),
:ok <- validate_not_crossing_datalayer_boundaries(refs, context.root_resource, call),
:ok <- validate_refs(refs, context.root_resource, call),
{:func, function_module} when not is_nil(function_module) <-
{:func, get_function(name, context.resource, context.public?)},
{:ok, function} <-
@ -2709,7 +2748,7 @@ defmodule Ash.Filter do
hydrate_refs([left, value], context),
refs <- list_refs([left, right]),
:ok <-
validate_not_crossing_datalayer_boundaries(
validate_refs(
refs,
context.root_resource,
{attr, value}

View file

@ -217,8 +217,8 @@ defmodule Ash.Resource.Dsl do
end
# And in `BookWord` (the join resource)
belongs_to :book, Book, primary_key?: true, required?: true
belongs_to :word, Word, primary_key?: true, required?: true
belongs_to :book, Book, primary_key?: true, allow_nil?: false
belongs_to :word, Word, primary_key?: true, allow_nil?: false
"""
],
modules: [:destination, :through],

View file

@ -15,13 +15,14 @@ defmodule Ash.Resource.Relationships.BelongsTo do
:api,
:not_found_message,
:violation_message,
:required?,
:allow_nil?,
:filter,
:sort,
:writable?,
:context,
:description,
:attribute_writable?,
filterable?: true,
validate_destination_attribute?: true,
cardinality: :one,
type: :belongs_to
@ -36,7 +37,7 @@ defmodule Ash.Resource.Relationships.BelongsTo do
filter: Ash.Filter.t() | nil,
source: Ash.Resource.t(),
destination: Ash.Resource.t(),
required?: boolean,
allow_nil?: boolean,
primary_key?: boolean,
define_attribute?: boolean,
attribute_type: term,
@ -44,6 +45,7 @@ defmodule Ash.Resource.Relationships.BelongsTo do
attribute_writable?: boolean,
destination_attribute: atom,
private?: boolean,
filterable?: boolean,
source_attribute: atom | nil,
description: String.t()
}
@ -66,9 +68,9 @@ defmodule Ash.Resource.Relationships.BelongsTo do
doc:
"Whether the generated attribute is, or is part of, the primary key of a resource."
],
required?: [
allow_nil?: [
type: :boolean,
default: false,
default: true,
links: [],
doc:
"Whether this relationship must always be present, e.g: must be included on creation, and never removed (it may be modified). The generated attribute will not allow nil values."

View file

@ -17,6 +17,7 @@ defmodule Ash.Resource.Relationships.HasMany do
:manual,
:api,
:writable?,
filterable?: true,
no_attributes?: false,
could_be_related_at_creation?: false,
validate_destination_attribute?: true,
@ -37,6 +38,7 @@ defmodule Ash.Resource.Relationships.HasMany do
destination: Ash.Resource.t(),
destination_attribute: atom,
private?: boolean,
filterable?: boolean,
source_attribute: atom,
description: String.t(),
manual: atom | {atom, Keyword.t()} | nil

View file

@ -24,7 +24,8 @@ defmodule Ash.Resource.Relationships.HasOne do
validate_destination_attribute?: true,
cardinality: :one,
type: :has_one,
required?: false
allow_nil?: false,
filterable?: true
]
@type t :: %__MODULE__{
@ -32,6 +33,7 @@ defmodule Ash.Resource.Relationships.HasOne do
cardinality: :one,
source: Ash.Resource.t(),
name: atom,
filterable?: boolean,
read_action: atom,
no_attributes?: boolean,
writable?: boolean,
@ -55,9 +57,10 @@ defmodule Ash.Resource.Relationships.HasOne do
@opt_schema Spark.OptionsHelpers.merge_schemas(
[manual(), no_attributes()] ++
[
required?: [
allow_nil?: [
type: :boolean,
links: [],
default: true,
doc: """
Marks the relationship as required. Has no effect on validations, but can inform extensions that there will always be a related entity.
"""

View file

@ -20,6 +20,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do
:context,
:filter,
:has_many,
filterable?: true,
could_be_related_at_creation?: false,
validate_destination_attribute?: true,
cardinality: :many,
@ -37,6 +38,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do
name: atom,
through: Ash.Resource.t(),
destination: Ash.Resource.t(),
filterable?: boolean,
join_relationship: atom,
source_attribute: atom,
destination_attribute: atom,

View file

@ -99,6 +99,11 @@ defmodule Ash.Resource.Relationships.SharedOptions do
""",
links: []
],
filterable?: [
type: :boolean,
default: true,
doc: "If set to `false`, the relationship will not be usable in filters."
],
sort: [
type: :any,
doc: """

View file

@ -27,14 +27,14 @@ defmodule Ash.Resource.Transformers.BelongsToAttribute do
if relationship.primary_key? do
false
else
not relationship.required?
relationship.allow_nil?
end,
writable?: relationship.attribute_writable?,
private?: !relationship.attribute_writable?,
primary_key?: relationship.primary_key?
)
valid_opts? = not (relationship.primary_key? && !relationship.required?)
valid_opts? = not (relationship.primary_key? && relationship.allow_nil?)
entity_or_error =
if valid_opts? do

View file

@ -123,11 +123,11 @@ defmodule Ash.Test.Actions.AsyncLoadTest do
end
relationships do
belongs_to :post, Post, primary_key?: true, required?: true
belongs_to :post, Post, primary_key?: true, allow_nil?: false
belongs_to :category, Ash.Test.Actions.AsyncLoadTest.Category,
primary_key?: true,
required?: true
allow_nil?: false
end
end

View file

@ -54,7 +54,7 @@ defmodule Ash.Test.Actions.BelongsToTest do
end
relationships do
belongs_to :reviewer, Ash.Test.Actions.BelongsToTest.Reviewer, required?: false
belongs_to :reviewer, Ash.Test.Actions.BelongsToTest.Reviewer, allow_nil?: true
end
end

View file

@ -69,7 +69,7 @@ defmodule Ash.Test.Actions.CreateTest do
end
relationships do
belongs_to(:author, Ash.Test.Actions.CreateTest.Author, required?: true)
belongs_to(:author, Ash.Test.Actions.CreateTest.Author, allow_nil?: false)
end
end
@ -200,12 +200,12 @@ defmodule Ash.Test.Actions.CreateTest do
relationships do
belongs_to(:source_post, Ash.Test.Actions.CreateTest.Post,
primary_key?: true,
required?: true
allow_nil?: false
)
belongs_to(:destination_post, Ash.Test.Actions.CreateTest.Post,
primary_key?: true,
required?: true
allow_nil?: false
)
end
end

View file

@ -110,11 +110,11 @@ defmodule Ash.Test.Actions.LoadTest do
end
relationships do
belongs_to :post, Post, primary_key?: true, required?: true
belongs_to :post, Post, primary_key?: true, allow_nil?: false
belongs_to :category, Ash.Test.Actions.LoadTest.Category,
primary_key?: true,
required?: true
allow_nil?: false
end
end

View file

@ -144,11 +144,11 @@ defmodule Ash.Test.Actions.UpdateTest do
relationships do
belongs_to :source_post, Ash.Test.Actions.UpdateTest.Post,
primary_key?: true,
required?: true
allow_nil?: false
belongs_to :destination_post, Ash.Test.Actions.UpdateTest.Post,
primary_key?: true,
required?: true
allow_nil?: false
end
end

View file

@ -97,11 +97,13 @@ defmodule Ash.Test.Changeset.ChangesetTest do
end
relationships do
belongs_to :post, Ash.Test.Changeset.ChangesetTest.Post, primary_key?: true, required?: true
belongs_to :post, Ash.Test.Changeset.ChangesetTest.Post,
primary_key?: true,
allow_nil?: false
belongs_to :category, Ash.Test.Changeset.ChangesetTest.Category,
primary_key?: true,
required?: true
allow_nil?: false
end
end

View file

@ -65,12 +65,12 @@ defmodule Ash.Test.Filter.FilterInteractionTest do
relationships do
belongs_to(:source_post, Ash.Test.Filter.FilterInteractionTest.Post,
primary_key?: true,
required?: true
allow_nil?: false
)
belongs_to(:destination_post, Ash.Test.Filter.FilterInteractionTest.Post,
primary_key?: true,
required?: true
allow_nil?: false
)
end
end

View file

@ -87,11 +87,11 @@ defmodule Ash.Test.Filter.FilterTest do
relationships do
belongs_to :source_post, Ash.Test.Filter.FilterTest.Post,
primary_key?: true,
required?: true
allow_nil?: false
belongs_to :destination_post, Ash.Test.Filter.FilterTest.Post,
primary_key?: true,
required?: true
allow_nil?: false
end
end

View file

@ -78,11 +78,11 @@ defmodule Ash.Test.GeneratorTest do
end
relationships do
belongs_to :post, Post, primary_key?: true, required?: true
belongs_to :post, Post, primary_key?: true, allow_nil?: false
belongs_to :category, Ash.Test.GeneratorTest.Category,
primary_key?: true,
required?: true
allow_nil?: false
end
end

View file

@ -28,11 +28,11 @@ defmodule Ash.Test.NotifierTest do
relationships do
belongs_to :source_post, Ash.Test.NotifierTest.Post,
primary_key?: true,
required?: true
allow_nil?: false
belongs_to :destination_post, Ash.Test.NotifierTest.Post,
primary_key?: true,
required?: true
allow_nil?: false
end
end

View file

@ -26,7 +26,7 @@ defmodule Ash.Test.Resource.Changes.RelateActorTest do
relationships do
belongs_to :author, Author do
required? false
allow_nil? true
end
end

View file

@ -79,11 +79,11 @@ defmodule Ash.Test.SeedTest do
end
relationships do
belongs_to :post, Post, primary_key?: true, required?: true
belongs_to :post, Post, primary_key?: true, allow_nil?: false
belongs_to :category, Ash.Test.SeedTest.Category,
primary_key?: true,
required?: true
allow_nil?: false
end
end

View file

@ -60,11 +60,11 @@ defmodule Ash.Test.TracerTest.AsyncLoadTest do
end
relationships do
belongs_to :post, Post, primary_key?: true, required?: true
belongs_to :post, Post, primary_key?: true, allow_nil?: false
belongs_to :category, Ash.Test.TracerTest.AsyncLoadTest.Category,
primary_key?: true,
required?: true
allow_nil?: false
end
end