mirror of
https://github.com/ash-project/ash_postgres.git
synced 2024-09-19 21:13:19 +12:00
improvement: support manual relationships with joins
This commit is contained in:
parent
14aa244a47
commit
7fc6c91cac
10 changed files with 746 additions and 28 deletions
|
@ -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, []},
|
||||
|
||||
|
|
87
documentation/how_to/join-manual-relationships.md
Normal file
87
documentation/how_to/join-manual-relationships.md
Normal 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
|
||||
```
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
151
lib/join.ex
151
lib/join.ex
|
@ -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,
|
||||
|
|
25
lib/manual_relationship.ex
Normal file
25
lib/manual_relationship.ex
Normal 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
|
367
test/manual_relationships_test.exs
Normal file
367
test/manual_relationships_test.exs
Normal 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
|
48
test/support/relationships/comments_containing_title.ex
Normal file
48
test/support/relationships/comments_containing_title.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue