mirror of
https://github.com/ash-project/ash.git
synced 2024-09-19 21:13:10 +12:00
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:
parent
4260b9a1c9
commit
e36f8c3e59
27 changed files with 154 additions and 43 deletions
39
documentation/how_to/defining-manual-relationships.md
Normal file
39
documentation/how_to/defining-manual-relationships.md
Normal 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
|
||||
```
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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} ->
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: """
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue