Skip to content

Commit

Permalink
feat: relationship pagination (#166)
Browse files Browse the repository at this point in the history
  • Loading branch information
rbino authored May 26, 2024
1 parent 3be2f32 commit 62940c3
Show file tree
Hide file tree
Showing 17 changed files with 1,102 additions and 345 deletions.
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ spark_locals_without_parens = [
metadata_names: 1,
metadata_types: 1,
modify_resolution: 1,
paginate_relationship_with: 1,
paginate_with: 1,
primary_key_delimiter: 1,
read_action: 1,
Expand Down
1 change: 1 addition & 0 deletions documentation/dsls/DSL:-AshGraphql.Resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ end
| [`derive_sort?`](#graphql-derive_sort?){: #graphql-derive_sort? } | `boolean` | `true` | Set to false to disable the automatic generation of a sort input for read actions. |
| [`encode_primary_key?`](#graphql-encode_primary_key?){: #graphql-encode_primary_key? } | `boolean` | `true` | For resources with composite primary keys, or primary keys not called `:id`, this will cause the id to be encoded as a single `id` attribute, both in the representation of the resource and in get requests |
| [`relationships`](#graphql-relationships){: #graphql-relationships } | `list(atom)` | | A list of relationships to include on the created type. Defaults to all public relationships where the destination defines a graphql type. |
| [`paginate_relationship_with`](#graphql-paginate_relationship_with){: #graphql-paginate_relationship_with } | `keyword` | `[]` | A keyword list indicating which kind of pagination should be used for each `has_many` and `many_to_many` relationships, e.g. `related_things: :keyset, other_related_things: :offset`. Valid pagination values are `nil`, `:offset`, `:keyset` and `:relay`. |
| [`field_names`](#graphql-field_names){: #graphql-field_names } | `keyword` | | A keyword list of name overrides for attributes. |
| [`hide_fields`](#graphql-hide_fields){: #graphql-hide_fields } | `list(atom)` | | A list of attributes to hide from the domain |
| [`show_fields`](#graphql-show_fields){: #graphql-show_fields } | `list(atom)` | | A list of attributes to show in the domain. If not specified includes all (excluding `hide_fiels`). |
Expand Down
242 changes: 154 additions & 88 deletions lib/graphql/resolver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ defmodule AshGraphql.Graphql.Resolver do
),
result_fields <-
get_result_fields(
AshGraphql.Resource.pagination_strategy(
AshGraphql.Resource.query_pagination_strategy(
gql_query,
Ash.Resource.Info.action(resource, action)
),
Expand Down Expand Up @@ -774,32 +774,37 @@ defmodule AshGraphql.Graphql.Resolver do
def validate_resolve_opts(resolution, resource, pagination, relay?, opts, args, query, action) do
action = Ash.Resource.Info.action(resource, action)

case AshGraphql.Resource.pagination_strategy(query, action) do
case AshGraphql.Resource.query_pagination_strategy(query, action) do
nil ->
{:ok, opts}

strategy ->
with page_opts <-
args
|> Map.take([:limit, :offset, :first, :after, :before, :last])
|> Enum.reject(fn {_, val} -> is_nil(val) end),
{:ok, page_opts} <- validate_offset_opts(page_opts, strategy, pagination),
{:ok, page_opts} <- validate_keyset_opts(page_opts, strategy, pagination) do
type = page_type(resource, strategy, relay?)
field_names = resolution |> fields([], type) |> names_only()

page =
if Enum.any?(field_names, &(&1 == :count)) do
Keyword.put(page_opts, :count, true)
else
page_opts
end

with {:ok, page} <- page_opts(resolution, resource, pagination, relay?, args, strategy) do
{:ok, Keyword.put(opts, :page, page)}
end
end
end

defp page_opts(resolution, resource, pagination, relay?, args, strategy, nested \\ []) do
page_opts =
args
|> Map.take([:limit, :offset, :first, :after, :before, :last])
|> Enum.reject(fn {_, val} -> is_nil(val) end)

with {:ok, page_opts} <- validate_offset_opts(page_opts, strategy, pagination),
{:ok, page_opts} <- validate_keyset_opts(page_opts, strategy, pagination) do
type = page_type(resource, strategy, relay?)

field_names = resolution |> fields(nested, type) |> names_only()

page_opts =
if Enum.any?(field_names, &(&1 == :count)) do
Keyword.put(page_opts, :count, true)
else
error ->
error
page_opts
end

{:ok, page_opts}
end
end

Expand All @@ -820,7 +825,8 @@ defmodule AshGraphql.Graphql.Resolver do
{:ok, opts}
end

defp validate_keyset_opts(opts, :keyset, %{max_page_size: max_page_size}) do
defp validate_keyset_opts(opts, strategy, %{max_page_size: max_page_size})
when strategy in [:keyset, :relay] do
case opts |> Keyword.take([:first, :last, :after, :before]) |> Enum.into(%{}) do
%{first: _first, last: _last} ->
{:error,
Expand Down Expand Up @@ -879,14 +885,15 @@ defmodule AshGraphql.Graphql.Resolver do
["results"]
end

defp get_result_fields(:relay, _) do
["edges", "node"]
end

defp get_result_fields(_pagination, _) do
[]
end

defp paginate(
_resource,
_gql_query,
_action,
defp paginate_with_keyset(
%Ash.Page.Keyset{
results: results,
more?: more,
Expand Down Expand Up @@ -947,69 +954,30 @@ defmodule AshGraphql.Graphql.Resolver do
end
end

defp paginate(
_resource,
_gql_query,
_action,
%Ash.Page.Offset{results: results, count: count, more?: more},
true
) do
{start_cursor, end_cursor} =
case results do
[] ->
{nil, nil}

[first] ->
{first.__metadata__.keyset, first.__metadata__.keyset}

[first | rest] ->
last = List.last(rest)
{first.__metadata__.keyset, last.__metadata__.keyset}
end
defp paginate_with_offset(%Ash.Page.Offset{results: results, count: count, more?: more?}) do
{:ok, %{results: results, count: count, more?: more?}}
end

has_previous_page = false
has_next_page = more

{
:ok,
%{
page_info: %{
start_cursor: start_cursor,
end_cursor: end_cursor,
has_next_page: has_next_page,
has_previous_page: has_previous_page
},
count: count,
edges:
Enum.map(results, fn result ->
%{
cursor: result.__metadata__.keyset,
node: result
}
end)
}
}
defp paginate(_resource, _gql_query, _action, %Ash.Page.Keyset{} = keyset, relay?) do
paginate_with_keyset(keyset, relay?)
end

defp paginate(
_resource,
_gql_query,
_action,
%Ash.Page.Offset{results: results, count: count, more?: more?},
_
) do
{:ok, %{results: results, count: count, more?: more?}}
defp paginate(resource, query, action, %Ash.Page.Offset{} = offset, relay?) do
# If a read action supports both offset and keyset, it will return an offset page by default
# Check what strategy we're using and convert the page accordingly
pagination_strategy = query_pagination_strategy(query, resource, action)

if relay? or pagination_strategy == :keyset do
offset
|> offset_to_keyset()
|> paginate_with_keyset(relay?)
else
paginate_with_offset(offset)
end
end

defp paginate(resource, query, action, page, relay?) do
action =
if is_atom(action) do
Ash.Resource.Info.action(resource, action)
else
action
end

case AshGraphql.Resource.pagination_strategy(query, action) do
case query_pagination_strategy(query, resource, action) do
nil ->
{:ok, page}

Expand Down Expand Up @@ -1041,6 +1009,50 @@ defmodule AshGraphql.Graphql.Resolver do
end
end

defp query_pagination_strategy(query, resource, action) when is_atom(action) do
action = Ash.Resource.Info.action(resource, action)
query_pagination_strategy(query, resource, action)
end

defp query_pagination_strategy(query, _resource, action) do
AshGraphql.Resource.query_pagination_strategy(query, action)
end

defp offset_to_keyset(%Ash.Page.Offset{} = offset) do
%Ash.Page.Keyset{
results: offset.results,
limit: offset.limit,
more?: offset.more?,
count: offset.count,
after: nil,
before: nil
}
end

defp paginate_relationship(%Ash.Page.Keyset{} = keyset, strategy) do
relay? = strategy == :relay
paginate_with_keyset(keyset, relay?)
end

defp paginate_relationship(%Ash.Page.Offset{} = offset, strategy)
when strategy in [:relay, :keyset] do
# If a read action supports both offset and keyset, it will return an offset page by default,
# so we might end up here even with relay or keyset strategy
relay? = strategy == :relay

offset
|> offset_to_keyset()
|> paginate_with_keyset(relay?)
end

defp paginate_relationship(%Ash.Page.Offset{} = offset, :offset) do
paginate_with_offset(offset)
end

defp paginate_relationship(page, _) do
{:ok, page}
end

def mutate(%Absinthe.Resolution{state: :resolved} = resolution, _),
do: resolution

Expand Down Expand Up @@ -1827,15 +1839,49 @@ defmodule AshGraphql.Graphql.Resolver do
|> Ash.Query.set_tenant(Map.get(context, :tenant))
|> Ash.Query.set_context(get_context(context))

pagination_strategy =
AshGraphql.Resource.relationship_pagination_strategy(
resource,
relationship.name,
read_action
)

will_paginate? = pagination_strategy != nil
relay? = pagination_strategy == :relay
result_fields = get_result_fields(pagination_strategy, relay?)

nested = Enum.map(Enum.reverse([selection | path]), & &1.name)

related_query =
if pagination_strategy do
case page_opts(
resolution,
relationship.destination,
read_action.pagination,
relay?,
args,
pagination_strategy,
nested
) do
{:ok, page_opts} ->
Ash.Query.page(related_query, page_opts)

{:error, error} ->
Ash.Query.add_error(related_query, error)
end
else
related_query
end

related_query =
args
|> apply_load_arguments(related_query)
|> apply_load_arguments(related_query, will_paginate?)
|> set_query_arguments(read_action, args)
|> select_fields(
relationship.destination,
resolution,
nil,
Enum.map(Enum.reverse([selection | path]), & &1.name)
nested
)
|> load_fields(
load_opts,
Expand All @@ -1844,7 +1890,8 @@ defmodule AshGraphql.Graphql.Resolver do
[
selection | path
],
context
context,
result_fields
)

if selection.alias do
Expand Down Expand Up @@ -2445,10 +2492,10 @@ defmodule AshGraphql.Graphql.Resolver do

defp unwrap_type_and_constraints(other, constraints), do: {other, constraints}

def resolve_assoc(%Absinthe.Resolution{state: :resolved} = resolution, _),
def resolve_assoc_one(%Absinthe.Resolution{state: :resolved} = resolution, _),
do: resolution

def resolve_assoc(
def resolve_assoc_one(
%{source: parent} = resolution,
{_domain, relationship}
) do
Expand All @@ -2462,6 +2509,25 @@ defmodule AshGraphql.Graphql.Resolver do
Absinthe.Resolution.put_result(resolution, {:ok, value})
end

def resolve_assoc_many(%Absinthe.Resolution{state: :resolved} = resolution, _),
do: resolution

def resolve_assoc_many(
%{source: parent} = resolution,
{_domain, relationship, pagination_strategy}
) do
page =
if resolution.definition.alias do
Map.get(parent.calculations, {:__ash_graphql_relationship__, resolution.definition.alias})
else
Map.get(parent, relationship.name)
end

result = paginate_relationship(page, pagination_strategy)

Absinthe.Resolution.put_result(resolution, result)
end

def resolve_id(%Absinthe.Resolution{state: :resolved} = resolution, _),
do: resolution

Expand Down Expand Up @@ -2673,7 +2739,7 @@ defmodule AshGraphql.Graphql.Resolver do
AshGraphql.Resource.Info.type(resource)
end

defp apply_load_arguments(arguments, query, will_paginate? \\ false) do
defp apply_load_arguments(arguments, query, will_paginate?) do
Enum.reduce(arguments, query, fn
{:limit, limit}, query when not will_paginate? ->
Ash.Query.limit(query, limit)
Expand Down
5 changes: 5 additions & 0 deletions lib/resource/info.ex
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ defmodule AshGraphql.Resource.Info do
resource |> Ash.Resource.Info.public_relationships() |> Enum.map(& &1.name)
end

@doc "Pagination configuration for list relationships"
def paginate_relationship_with(resource) do
Extension.get_opt(resource, [:graphql], :paginate_relationship_with, [])
end

@doc "Graphql argument name overrides for the resource"
def argument_names(resource) do
Extension.get_opt(resource, [:graphql], :argument_names, [])
Expand Down
Loading

0 comments on commit 62940c3

Please sign in to comment.