diff --git a/.formatter.exs b/.formatter.exs index d842e5e..1fed60a 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -18,6 +18,7 @@ spark_locals_without_parens = [ get: 2, get: 3, identity: 1, + keyset_field: 1, list: 2, list: 3, lookup_identities: 1, diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index cf21e6f..7bdef69 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -202,8 +202,7 @@ defmodule AshGraphql.Graphql.Resolver do {:ok, opts} {:ok, page_opts} -> - page_fields = get_page_fields(pagination) - field_names = resolution |> fields(page_fields) |> names_only() + field_names = resolution |> fields([]) |> names_only() page = if Enum.any?(field_names, &(&1 == :count)) do @@ -310,9 +309,10 @@ defmodule AshGraphql.Graphql.Resolver do results: results, more?: more, after: after_cursor, - before: before_cursor + before: before_cursor, + count: count }, - true + relay? ) do {start_cursor, end_cursor} = case results do @@ -340,36 +340,29 @@ defmodule AshGraphql.Graphql.Resolver do {more, not Enum.empty?(results)} end - { - :ok, - %{ - page_info: %{ - start_cursor: start_cursor, - end_cursor: end_cursor, - has_next_page: has_next_page, - has_previous_page: has_previous_page - }, - edges: - Enum.map(results, fn result -> - %{ - cursor: result.__metadata__.keyset, - node: result - } - end) + if relay? do + { + :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) + } } - } - end - - defp paginate( - _resource, - _action, - %Ash.Page.Keyset{ - results: results, - count: count - }, - false - ) do - {:ok, %{results: results, count: count}} + else + {:ok, %{results: results, count: count, start_keyset: start_cursor, end_keyset: end_cursor}} + end end defp paginate(_resource, _action, %Ash.Page.Offset{results: results, count: count}, _) do @@ -1171,6 +1164,14 @@ defmodule AshGraphql.Graphql.Resolver do Absinthe.Resolution.put_result(resolution, {:ok, Map.get(parent, field)}) end + def resolve_keyset( + %{source: parent} = resolution, + _field + ) do + parent.__metadata__ + Absinthe.Resolution.put_result(resolution, {:ok, Map.get(parent.__metadata__, :keyset)}) + end + def resolve_composite_id( %{source: parent} = resolution, {_resource, _fields} diff --git a/lib/resource/info.ex b/lib/resource/info.ex index 8d08cb6..e92ebd6 100644 --- a/lib/resource/info.ex +++ b/lib/resource/info.ex @@ -33,6 +33,11 @@ defmodule AshGraphql.Resource.Info do Extension.get_opt(resource, [:graphql], :attribute_types, []) end + @doc "The field name to place the keyset of a result in" + def keyset_field(resource) do + Extension.get_opt(resource, [:graphql], :keyset_field, nil) + end + @doc "Graphql field name (attribute/relationship/calculation/arguments) overrides for the resource" def field_names(resource) do Extension.get_opt(resource, [:graphql], :field_names, []) diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 74258fa..dfa60b2 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -243,6 +243,15 @@ defmodule AshGraphql.Resource do doc: "A nested keyword list of action names, to argument name remappings. i.e `create: [arg_name: :new_name]`" ], + keyset_field: [ + type: :atom, + doc: """ + If set, the keyset will be displayed on all read actions in this field. + + It will always be `nil` unless at least one of the read actions on a resource uses keyset pagination. + It will also be nil on any mutation results. + """ + ], attribute_types: [ type: :keyword_list, doc: @@ -858,10 +867,15 @@ defmodule AshGraphql.Resource do # sobelow_skip ["DOS.StringToAtom"] defp query_type(%{type: :list, relay?: relay?}, _resource, action, type) do if action.pagination do - if relay? do - String.to_atom("#{type}_connection") - else - String.to_atom("page_of_#{type}") + cond do + relay? -> + String.to_atom("#{type}_connection") + + action.pagination.keyset? -> + String.to_atom("keyset_page_of_#{type}") + + true -> + String.to_atom("page_of_#{type}") end else %Absinthe.Blueprint.TypeReference.NonNull{ @@ -1182,6 +1196,8 @@ defmodule AshGraphql.Resource do List.wrap(filter_input(resource, schema)) ++ filter_field_types(resource, schema) ++ List.wrap(page_of(resource, schema)) ++ + List.wrap(relay_page(resource, schema)) ++ + List.wrap(keyset_page_of(resource, schema)) ++ enum_definitions(resource, schema, __ENV__) ++ managed_relationship_definitions(resource, schema) end @@ -2223,8 +2239,7 @@ defmodule AshGraphql.Resource do end) end - # sobelow_skip ["DOS.StringToAtom"] - defp page_of(resource, schema) do + defp relay_page(resource, schema) do type = AshGraphql.Resource.Info.type(resource) paginatable? = @@ -2240,6 +2255,14 @@ defmodule AshGraphql.Resource do |> queries() |> Enum.any?(& &1.relay?) + countable? = + resource + |> queries() + |> Enum.any?(fn query -> + action = Ash.Resource.Info.action(resource, query.action) + query.relay? && action.pagination && action.pagination.countable + end) + if relay? do [ %Absinthe.Blueprint.Schema.ObjectTypeDefinition{ @@ -2271,67 +2294,157 @@ defmodule AshGraphql.Resource do }, %Absinthe.Blueprint.Schema.ObjectTypeDefinition{ description: "#{inspect(type)} connection", - fields: [ - %Absinthe.Blueprint.Schema.FieldDefinition{ - description: "Page information", - identifier: :page_info, - module: schema, - name: "page_info", - __reference__: ref(__ENV__), - type: %Absinthe.Blueprint.TypeReference.NonNull{ - of_type: :page_info + fields: + [ + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: "Page information", + identifier: :page_info, + module: schema, + name: "page_info", + __reference__: ref(__ENV__), + type: %Absinthe.Blueprint.TypeReference.NonNull{ + of_type: :page_info + } + }, + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: "#{inspect(type)} edges", + identifier: :edges, + module: schema, + name: "edges", + __reference__: ref(__ENV__), + type: %Absinthe.Blueprint.TypeReference.List{ + of_type: String.to_atom("#{type}_edge") + } } - }, - %Absinthe.Blueprint.Schema.FieldDefinition{ - description: "#{inspect(type)} edges", - identifier: :edges, - module: schema, - name: "edges", - __reference__: ref(__ENV__), - type: %Absinthe.Blueprint.TypeReference.List{ - of_type: String.to_atom("#{type}_edge") - } - } - ], + ] + |> add_count_to_relay(schema, countable?), identifier: String.to_atom("#{type}_connection"), module: schema, name: Macro.camelize("#{type}_connection"), __reference__: ref(__ENV__) } ] - else - %Absinthe.Blueprint.Schema.ObjectTypeDefinition{ - description: "A page of #{inspect(type)}", - fields: [ - %Absinthe.Blueprint.Schema.FieldDefinition{ - description: "The records contained in the page", - identifier: :results, - module: schema, - name: "results", - __reference__: ref(__ENV__), - type: %Absinthe.Blueprint.TypeReference.List{ - of_type: %Absinthe.Blueprint.TypeReference.NonNull{ - of_type: type - } - } - }, - %Absinthe.Blueprint.Schema.FieldDefinition{ - description: "The count of records", - identifier: :count, - module: schema, - name: "count", - type: :integer, - __reference__: ref(__ENV__) - } - ], - identifier: String.to_atom("page_of_#{type}"), - module: schema, - name: Macro.camelize("page_of_#{type}"), - __reference__: ref(__ENV__) - } end - else - nil + end + end + + defp add_count_to_relay(fields, schema, true) do + [ + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: "Total count on all pages", + identifier: :count, + module: schema, + name: "count", + __reference__: ref(__ENV__), + type: :integer + } + | fields + ] + end + + defp add_count_to_relay(fields, _, _), do: fields + + # sobelow_skip ["DOS.StringToAtom"] + defp page_of(resource, schema) do + type = AshGraphql.Resource.Info.type(resource) + + paginatable? = + resource + |> Ash.Resource.Info.actions() + |> Enum.any?(fn action -> + action.type == :read && action.pagination + end) + + if paginatable? do + %Absinthe.Blueprint.Schema.ObjectTypeDefinition{ + description: "A page of #{inspect(type)}", + fields: [ + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: "The records contained in the page", + identifier: :results, + module: schema, + name: "results", + __reference__: ref(__ENV__), + type: %Absinthe.Blueprint.TypeReference.List{ + of_type: %Absinthe.Blueprint.TypeReference.NonNull{ + of_type: type + } + } + }, + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: "The count of records", + identifier: :count, + module: schema, + name: "count", + type: :integer, + __reference__: ref(__ENV__) + } + ], + identifier: String.to_atom("page_of_#{type}"), + module: schema, + name: Macro.camelize("page_of_#{type}"), + __reference__: ref(__ENV__) + } + end + end + + # sobelow_skip ["DOS.StringToAtom"] + defp keyset_page_of(resource, schema) do + type = AshGraphql.Resource.Info.type(resource) + + paginatable? = + resource + |> Ash.Resource.Info.actions() + |> Enum.any?(fn action -> + action.type == :read && action.pagination + end) + + if paginatable? do + %Absinthe.Blueprint.Schema.ObjectTypeDefinition{ + description: "A keyset page of #{inspect(type)}", + fields: [ + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: "The records contained in the page", + identifier: :results, + module: schema, + name: "results", + __reference__: ref(__ENV__), + type: %Absinthe.Blueprint.TypeReference.List{ + of_type: %Absinthe.Blueprint.TypeReference.NonNull{ + of_type: type + } + } + }, + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: "The count of records", + identifier: :count, + module: schema, + name: "count", + type: :integer, + __reference__: ref(__ENV__) + }, + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: "The first keyset in the results", + identifier: :start_keyset, + module: schema, + name: "start_keyset", + type: :string, + __reference__: ref(__ENV__) + }, + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: "The last keyset in the results", + identifier: :end_keyset, + module: schema, + name: "end_keyset", + type: :string, + __reference__: ref(__ENV__) + } + ], + identifier: String.to_atom("keyset_page_of_#{type}"), + module: schema, + name: Macro.camelize("keyset_page_of_#{type}"), + __reference__: ref(__ENV__) + } end end @@ -2379,7 +2492,30 @@ defmodule AshGraphql.Resource do attributes(resource, schema) ++ relationships(resource, api, schema) ++ aggregates(resource, schema) ++ - calculations(resource, api, schema) + calculations(resource, api, schema) ++ + keyset(resource, schema) + end + + defp keyset(resource, schema) do + case AshGraphql.Resource.Info.keyset_field(resource) do + nil -> + [] + + field -> + [ + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: "The pagination #{field}.", + identifier: field, + module: schema, + middleware: [ + {{AshGraphql.Graphql.Resolver, :resolve_keyset}, field} + ], + name: to_string(field), + type: :string, + __reference__: ref(__ENV__) + } + ] + end end defp attributes(resource, schema) do diff --git a/test/paginate_test.exs b/test/paginate_test.exs index 5935c1d..22f5ba5 100644 --- a/test/paginate_test.exs +++ b/test/paginate_test.exs @@ -35,8 +35,11 @@ defmodule AshGraphql.PaginateTest do query KeysetPaginatedPosts { keysetPaginatedPosts(sort: [{field: TEXT}]) { count + startKeyset + endKeyset results{ text + keyset } } } @@ -46,9 +49,11 @@ defmodule AshGraphql.PaginateTest do %{ data: %{ "keysetPaginatedPosts" => %{ + "startKeyset" => start_keyset, + "endKeyset" => end_keyset, "count" => 5, "results" => [ - %{"text" => "a"}, + %{"text" => "a", "keyset" => keyset}, %{"text" => "b"}, %{"text" => "c"}, %{"text" => "d"}, @@ -57,6 +62,10 @@ defmodule AshGraphql.PaginateTest do } } }} = Absinthe.run(doc, AshGraphql.Test.Schema) + + assert is_binary(keyset) + assert is_binary(start_keyset) + assert is_binary(end_keyset) end end diff --git a/test/relay_test.exs b/test/relay_test.exs index 6714704..9dd5463 100644 --- a/test/relay_test.exs +++ b/test/relay_test.exs @@ -48,6 +48,7 @@ defmodule AshGraphql.RelayTest do startCursor endCursor } + count edges{ cursor node { @@ -62,6 +63,7 @@ defmodule AshGraphql.RelayTest do %{ data: %{ "getRelayTags" => %{ + "count" => 5, "pageInfo" => %{ "hasNextPage" => false, "hasPreviousPage" => false, diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 77ffe7f..557c4b4 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -70,7 +70,8 @@ defmodule AshGraphql.Test.Post do attribute_types integer_as_string_in_api: :string attribute_input_types integer_as_string_in_api: :string - field_names(text_1_and_2: :text1_and2) + field_names text_1_and_2: :text1_and2 + keyset_field :keyset queries do get :get_post, :read @@ -162,7 +163,12 @@ defmodule AshGraphql.Test.Post do end read :keyset_paginated do - pagination(required?: true, keyset?: true, countable: true, default_limit: 20) + pagination( + required?: true, + keyset?: true, + countable: true, + default_limit: 20 + ) end read :paginated_without_limit do