improvement: support manual relationships with joins

This commit is contained in:
Zach Daniel 2022-09-13 16:27:39 -04:00
parent 14aa244a47
commit 7fc6c91cac
10 changed files with 746 additions and 28 deletions

View file

@ -123,7 +123,7 @@
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, [max_nesting: 3]},
{Credo.Check.Refactor.Nesting, [max_nesting: 4]},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},

View file

@ -0,0 +1,87 @@
# Join Manual Relationships
See {{link:ash:guide:Defining Manual Relationships}} for an idea of manual relationships in general.
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.
## 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
use AshPostgres.ManualRelationship
require Ash.Query
require Ecto.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
# query is the "source" query that is being built.
# _opts are options provided to the manual relationship, i.e `{Manual, opt: :val}`
# current_binding is what the source of the relationship is bound to. Access fields with `as(^current_binding).field`
# as_binding is the binding that your join should create. When you join, make sure you say `as: ^as_binding` on the
# part of the query that represents the destination of the relationship
# type is `:inner` or `:left`.
# destination_query is what you should join to to add the destination to the query, i.e `join: dest in ^destination-query`
def ash_postgres_join(query, _opts, current_binding, as_binding, :inner, destination_query) do
{:ok,
Ecto.Query.from(_ in query,
join: dest in ^destination_query,
as: ^as_binding,
on: dest.representative_id == as(^current_binding).id,
on: dest.priority > as(^current_binding).priority_threshold
)}
end
def ash_postgres_join(query, _opts, current_binding, as_binding, :left, destination_query) do
{:ok,
Ecto.Query.from(_ in query,
left_join: dest in ^destination_query,
as: ^as_binding,
on: dest.representative_id == as(^current_binding).id,
on: dest.priority > as(^current_binding).priority_threshold
)}
end
# _opts are options provided to the manual relationship, i.e `{Manual, opt: :val}`
# current_binding is what the source of the relationship is bound to. Access fields with `parent_as(^current_binding).field`
# as_binding is the binding that has already been created for your join. Access fields on it via `as(^as_binding)`
# destination_query is what you should use as the basis of your query
def ash_postgres_subquery(_opts, current_binding, as_binding, destination_query) do
{:ok,
Ecto.Query.from(_ in destination_query,
where: parent_as(^current_binding).id == as(^as_binding).representative_id,
where: as(^as_binding).priority > parent_as(^current_binding).priority_threshold
)}
end
end
```

View file

@ -208,6 +208,40 @@ defmodule AshPostgres.Aggregate do
}
end
def agg_subquery_for_lateral_join(
current_binding,
query,
subquery,
%{
manual: {module, opts}
} = relationship
) do
case module.ash_postgres_subquery(
opts,
current_binding,
0,
subquery
) do
{:ok, inner_sub} ->
{:ok,
from(sub in subquery(inner_sub), [])
|> AshPostgres.Join.set_join_prefix(query, relationship.destination)}
other ->
other
end
rescue
e in UndefinedFunctionError ->
if e.function == :ash_postgres_subquery do
reraise """
Cannot join to a manual relationship #{inspect(module)} that does not implement the `AshPostgres.ManualRelationship` behaviour.
""",
__STACKTRACE__
else
reraise e, __STACKTRACE__
end
end
def agg_subquery_for_lateral_join(current_binding, query, subquery, relationship) do
{dest_binding, dest_field} =
case relationship.type do
@ -229,8 +263,9 @@ defmodule AshPostgres.Aggregate do
)
end
{:ok,
from(sub in subquery(inner_sub), [])
|> AshPostgres.Join.set_join_prefix(query, relationship.destination)
|> AshPostgres.Join.set_join_prefix(query, relationship.destination)}
end
defp select_dynamic(resource, query, aggregate) do

View file

@ -476,6 +476,19 @@ defmodule AshPostgres.DataLayer do
def can?(_, :limit), do: true
def can?(_, :offset), do: true
def can?(_, :multitenancy), do: true
def can?(_, {:filter_relationship, %{manual: {module, _}}}) do
Spark.implements_behaviour?(module, AshPostgres.ManualRelationship)
end
def can?(_, {:filter_relationship, _}), do: true
def can?(_, {:aggregate_relationship, %{manual: {module, _}}}) do
Spark.implements_behaviour?(module, AshPostgres.ManualRelationship)
end
def can?(_, {:aggregate_relationship, _}), do: true
def can?(_, :timeout), do: true
def can?(_, {:filter_expr, _}), do: true
def can?(_, :nested_expressions), do: true

View file

@ -344,6 +344,135 @@ defmodule AshPostgres.Join do
end
end
defp do_join_relationship(
query,
%{manual: {module, opts}} = relationship,
path,
kind,
source,
filter
) do
full_path = path ++ [relationship.name]
initial_ash_bindings = query.__ash_bindings__
binding_data =
case kind do
{:aggregate, name, _agg} ->
%{type: :aggregate, name: name, path: full_path, source: source}
_ ->
%{type: kind, path: full_path, source: source}
end
query = AshPostgres.DataLayer.add_binding(query, binding_data)
used_calculations =
Ash.Filter.used_calculations(
filter,
relationship.destination,
full_path
)
used_aggregates =
filter
|> AshPostgres.Aggregate.used_aggregates(relationship, used_calculations, full_path)
|> Enum.map(fn aggregate ->
%{aggregate | load: aggregate.name}
end)
use_root_query_bindings? = Enum.empty?(used_aggregates)
case maybe_get_resource_query(
relationship.destination,
relationship,
query,
full_path,
use_root_query_bindings?
) do
{:error, error} ->
{:error, error}
{:ok, relationship_destination} ->
relationship_destination =
relationship_destination
|> Ecto.Queryable.to_query()
|> set_join_prefix(query, relationship.destination)
binding_kind =
case kind do
{:aggregate, _, _} ->
:left
other ->
other
end
current_binding =
Enum.find_value(initial_ash_bindings.bindings, 0, fn {binding, data} ->
if data.type == binding_kind && data.path == path do
binding
end
end)
relationship_destination
|> AshPostgres.Aggregate.add_aggregates(used_aggregates, relationship.destination)
|> case do
{:ok, relationship_destination} ->
relationship_destination =
case used_aggregates do
[] ->
relationship_destination
_ ->
subquery(relationship_destination)
end
case kind do
{:aggregate, _, subquery} ->
case AshPostgres.Aggregate.agg_subquery_for_lateral_join(
current_binding,
query,
subquery,
relationship
) do
{:ok, subquery} ->
{:ok,
from([{row, current_binding}] in query,
left_lateral_join: destination in ^subquery,
as: ^initial_ash_bindings.current
)}
other ->
other
end
kind ->
module.ash_postgres_join(
query,
opts,
current_binding,
initial_ash_bindings.current,
kind,
relationship_destination
)
end
{:error, error} ->
{:error, error}
end
end
rescue
e in UndefinedFunctionError ->
if e.function == :ash_postgres_join do
reraise """
Cannot join to a manual relationship #{inspect(module)} that does not implement the `AshPostgres.ManualRelationship` behaviour.
""",
__STACKTRACE__
else
reraise e, __STACKTRACE__
end
end
defp do_join_relationship(
query,
%{type: :many_to_many} = relationship,
@ -478,20 +607,23 @@ defmodule AshPostgres.Join do
case kind do
{:aggregate, _, subquery} ->
subquery =
AshPostgres.Aggregate.agg_subquery_for_lateral_join(
case AshPostgres.Aggregate.agg_subquery_for_lateral_join(
current_binding,
query,
subquery,
relationship
)
) do
{:ok, subquery} ->
{:ok,
from([{row, current_binding}] in query,
left_lateral_join: through in ^subquery,
as: ^initial_ash_bindings.current
)}
other ->
other
end
:inner ->
{:ok,
from([{row, current_binding}] in query,
@ -614,20 +746,23 @@ defmodule AshPostgres.Join do
case {kind, Map.get(relationship, :no_attributes?)} do
{{:aggregate, _, subquery}, _} ->
subquery =
AshPostgres.Aggregate.agg_subquery_for_lateral_join(
case AshPostgres.Aggregate.agg_subquery_for_lateral_join(
current_binding,
query,
subquery,
relationship
)
) do
{:ok, subquery} ->
{:ok,
from([{row, current_binding}] in query,
left_lateral_join: destination in ^subquery,
as: ^initial_ash_bindings.current
)}
other ->
other
end
{_, true} ->
from([{row, current_binding}] in query,
join: destination in ^relationship_destination,

View file

@ -0,0 +1,25 @@
defmodule AshPostgres.ManualRelationship do
@moduledoc "A behavior for postgres-specific manual relationship functionality"
@callback ash_postgres_join(
source_query :: Ecto.Query.t(),
opts :: Keyword.t(),
current_binding :: term,
destination_binding :: term,
type :: :inner | :left,
destination_query :: Ecto.Query.t()
) :: {:ok, Ecto.Query.t()} | {:error, term}
@callback ash_postgres_subquery(
opts :: Keyword.t(),
current_binding :: term,
destination_binding :: term,
destination_query :: Ecto.Query.t()
) :: {:ok, Ecto.Query.t()} | {:error, term}
defmacro __using__(_) do
quote do
@behaviour AshPostgres.ManualRelationship
end
end
end

View file

@ -0,0 +1,367 @@
defmodule AshPostgres.Test.ManualRelationshipsTest do
use AshPostgres.RepoCase, async: false
alias AshPostgres.Test.{Api, Comment, Post}
require Ash.Query
describe "manual first" do
test "aggregates can be loaded with no data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
assert %{count_of_comments_containing_title: 0} =
Api.load!(post, :count_of_comments_containing_title)
end
test "aggregates can be loaded with data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert %{count_of_comments_containing_title: 1} =
Api.load!(post, :count_of_comments_containing_title)
end
test "relationships can be filtered on with no data" do
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
assert [] =
Post |> Ash.Query.filter(comments_containing_title.title == "title") |> Api.read!()
end
test "aggregates can be filtered on with no data" do
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
assert [] = Post |> Ash.Query.filter(count_of_comments_containing_title == 1) |> Api.read!()
end
test "aggregates can be filtered on with data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert [_] =
Post |> Ash.Query.filter(count_of_comments_containing_title == 1) |> Api.read!()
end
test "relationships can be filtered on with data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert [_] =
Post
|> Ash.Query.filter(comments_containing_title.title == "title2")
|> Api.read!()
end
end
describe "manual last" do
test "aggregates can be loaded with no data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
comment =
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert %{count_of_comments_containing_title: 0} =
Api.load!(comment, :count_of_comments_containing_title)
end
test "aggregates can be loaded with data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
comment =
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert %{count_of_comments_containing_title: 1} =
Api.load!(comment, :count_of_comments_containing_title)
end
test "aggregates can be filtered on with no data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert [] =
Comment
|> Ash.Query.filter(count_of_comments_containing_title == 1)
|> Api.read!()
end
test "relationships can be filtered on with no data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert [] =
Comment
|> Ash.Query.filter(post.comments_containing_title.title == "title2")
|> Api.read!()
end
test "aggregates can be filtered on with data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert [_, _] =
Comment
|> Ash.Query.filter(count_of_comments_containing_title == 1)
|> Api.read!()
end
test "relationships can be filtered on with data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert [_, _] =
Comment
|> Ash.Query.filter(post.comments_containing_title.title == "title2")
|> Api.read!()
end
end
describe "manual middle" do
test "aggregates can be loaded with no data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
comment =
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert %{posts_for_comments_containing_title: []} =
Api.load!(comment, :posts_for_comments_containing_title)
end
test "aggregates can be loaded with data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
comment =
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert %{posts_for_comments_containing_title: ["title"]} =
Api.load!(comment, :posts_for_comments_containing_title)
end
test "aggregates can be filtered on with no data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert [] =
Comment
|> Ash.Query.filter("title" in posts_for_comments_containing_title)
|> Api.read!()
end
test "relationships can be filtered on with no data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert [] =
Comment
|> Ash.Query.filter(post.comments_containing_title.post.title == "title")
|> Api.read!()
end
test "aggregates can be filtered on with data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert [_, _] =
Comment
|> Ash.Query.filter(post.comments_containing_title.post.title == "title")
|> Api.read!()
end
test "relationships can be filtered on with data" do
post =
Post
|> Ash.Changeset.new(%{title: "title"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "title2"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
Comment
|> Ash.Changeset.new(%{title: "no match"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
assert [_, _] =
Comment
|> Ash.Query.filter(post.comments_containing_title.post.title == "title")
|> Api.read!()
end
end
end

View file

@ -0,0 +1,48 @@
defmodule AshPostgres.Test.Post.CommentsContainingTitle do
@moduledoc false
use Ash.Resource.ManualRelationship
use AshPostgres.ManualRelationship
require Ash.Query
require Ecto.Query
def load(posts, _opts, %{query: query, actor: actor, authorize?: authorize?}) do
post_ids = Enum.map(posts, & &1.id)
{:ok,
query
|> Ash.Query.filter(post_id in ^post_ids)
|> Ash.Query.filter(contains(title, post.title))
|> AshPostgres.Test.Api.read!(actor: actor, authorize?: authorize?)
|> Enum.group_by(& &1.post_id)}
end
def ash_postgres_join(query, _opts, current_binding, as_binding, :inner, destination_query) do
{:ok,
Ecto.Query.from(_ in query,
join: dest in ^destination_query,
as: ^as_binding,
on: dest.post_id == as(^current_binding).id,
on: fragment("strpos(?, ?) > 0", dest.title, as(^current_binding).title)
)}
end
def ash_postgres_join(query, _opts, current_binding, as_binding, :left, destination_query) do
{:ok,
Ecto.Query.from(_ in query,
left_join: dest in ^destination_query,
as: ^as_binding,
on: dest.post_id == as(^current_binding).id,
on: fragment("strpos(?, ?) > 0", dest.title, as(^current_binding).title)
)}
end
def ash_postgres_subquery(_opts, current_binding, as_binding, destination_query) do
{:ok,
Ecto.Query.from(_ in destination_query,
where: parent_as(^current_binding).id == as(^as_binding).post_id,
where:
fragment("strpos(?, ?) > 0", as(^as_binding).title, parent_as(^current_binding).title)
)}
end
end

View file

@ -34,6 +34,8 @@ defmodule AshPostgres.Test.Comment do
aggregates do
first(:post_category, :post, :category)
count(:co_popular_comments, [:post, :popular_comments])
count(:count_of_comments_containing_title, [:post, :comments_containing_title])
list(:posts_for_comments_containing_title, [:post, :comments_containing_title, :post], :title)
end
relationships do

View file

@ -75,7 +75,11 @@ defmodule AshPostgres.Test.Post do
has_many :popular_comments, AshPostgres.Test.Comment do
destination_attribute(:post_id)
# filter(expr(likes > 10))
filter(expr(likes > 10))
end
has_many :comments_containing_title, AshPostgres.Test.Comment do
manual(AshPostgres.Test.Post.CommentsContainingTitle)
end
has_many(:ratings, AshPostgres.Test.Rating,
@ -127,6 +131,8 @@ defmodule AshPostgres.Test.Post do
filter(title: "match")
end
count(:count_of_comments_containing_title, :comments_containing_title)
first :first_comment, :comments, :title do
sort(title: :asc_nils_last)
end