diff --git a/documentation/topics/relay.md b/documentation/topics/relay.md index 476b8009..938a6055 100644 --- a/documentation/topics/relay.md +++ b/documentation/topics/relay.md @@ -14,3 +14,14 @@ Use the following option when calling `use AshGraphql` ```elixir use AshGraphql, define_relay_types?: false ``` + +## Relay Global IDs + +Use the following option to generate Relay Global IDs (see +[here](https://relay.dev/graphql/objectidentification.htm)). + +```elixir +use AshGraphql, relay_ids?: true +``` + +This allows refetching a node using the `node` query and passing its global ID. diff --git a/lib/api/api.ex b/lib/api/api.ex index 6f678afe..380ee657 100644 --- a/lib/api/api.ex +++ b/lib/api/api.ex @@ -72,28 +72,33 @@ defmodule AshGraphql.Api do defdelegate debug?(api), to: AshGraphql.Api.Info @doc false - def queries(api, resources, action_middleware, schema) do - Enum.flat_map(resources, &AshGraphql.Resource.queries(api, &1, action_middleware, schema)) + def queries(api, resources, action_middleware, schema, relay_ids?) do + Enum.flat_map( + resources, + &AshGraphql.Resource.queries(api, &1, action_middleware, schema, relay_ids?) + ) end @doc false - def mutations(api, resources, action_middleware, schema) do + def mutations(api, resources, action_middleware, schema, relay_ids?) do resources |> Enum.filter(fn resource -> AshGraphql.Resource in Spark.extensions(resource) end) - |> Enum.flat_map(&AshGraphql.Resource.mutations(api, &1, action_middleware, schema)) + |> Enum.flat_map( + &AshGraphql.Resource.mutations(api, &1, action_middleware, schema, relay_ids?) + ) end @doc false - def type_definitions(api, resources, schema, env, first?, define_relay_types?) do + def type_definitions(api, resources, schema, env, first?, define_relay_types?, relay_ids?) do resource_types = resources |> Enum.reject(&Ash.Resource.Info.embedded?/1) |> Enum.flat_map(fn resource -> if AshGraphql.Resource in Spark.extensions(resource) && AshGraphql.Resource.Info.type(resource) do - AshGraphql.Resource.type_definitions(resource, api, schema) ++ + AshGraphql.Resource.type_definitions(resource, api, schema, relay_ids?) ++ AshGraphql.Resource.mutation_types(resource, schema) else AshGraphql.Resource.no_graphql_types(resource, schema) @@ -118,6 +123,7 @@ defmodule AshGraphql.Api do } ], identifier: :node, + resolve_type: &AshGraphql.Graphql.Resolver.resolve_node_type/2, __reference__: AshGraphql.Resource.ref(env), module: schema }, diff --git a/lib/ash_graphql.ex b/lib/ash_graphql.ex index 1d57c9ac..8b4476f4 100644 --- a/lib/ash_graphql.ex +++ b/lib/ash_graphql.ex @@ -36,7 +36,8 @@ defmodule AshGraphql do apis: opts[:apis], api: opts[:api], action_middleware: opts[:action_middleware] || [], - define_relay_types?: Keyword.get(opts, :define_relay_types?, true) + define_relay_types?: Keyword.get(opts, :define_relay_types?, true), + relay_ids?: Keyword.get(opts, :relay_ids?, false) ], generated: true do require Ash.Api.Info @@ -136,16 +137,37 @@ defmodule AshGraphql do api = unquote(api) action_middleware = unquote(action_middleware) + api_queries = + AshGraphql.Api.queries( + api, + unquote(resources), + action_middleware, + __MODULE__, + unquote(relay_ids?) + ) + + relay_queries = + if unquote(first?) and unquote(define_relay_types?) and unquote(relay_ids?) do + apis_with_resources = unquote(Enum.map(apis, &{elem(&1, 0), elem(&1, 1)})) + AshGraphql.relay_queries(apis_with_resources, unquote(schema), __ENV__) + else + [] + end + blueprint_with_queries = - api - |> AshGraphql.Api.queries(unquote(resources), action_middleware, __MODULE__) + (relay_queries ++ api_queries) |> Enum.reduce(blueprint, fn query, blueprint -> Absinthe.Blueprint.add_field(blueprint, "RootQueryType", query) end) blueprint_with_mutations = api - |> AshGraphql.Api.mutations(unquote(resources), action_middleware, __MODULE__) + |> AshGraphql.Api.mutations( + unquote(resources), + action_middleware, + __MODULE__, + unquote(relay_ids?) + ) |> Enum.reduce(blueprint_with_queries, fn mutation, blueprint -> Absinthe.Blueprint.add_field(blueprint, "RootMutationType", mutation) end) @@ -155,7 +177,11 @@ defmodule AshGraphql do apis = unquote(Enum.map(apis, &elem(&1, 0))) embedded_types = - AshGraphql.get_embedded_types(unquote(ash_resources), unquote(schema)) + AshGraphql.get_embedded_types( + unquote(ash_resources), + unquote(schema), + unquote(relay_ids?) + ) global_enums = AshGraphql.global_enums(unquote(ash_resources), unquote(schema), __ENV__) @@ -171,7 +197,8 @@ defmodule AshGraphql do unquote(schema), __ENV__, true, - unquote(define_relay_types?) + unquote(define_relay_types?), + unquote(relay_ids?) ) ++ global_enums ++ global_unions ++ @@ -185,7 +212,8 @@ defmodule AshGraphql do unquote(schema), __ENV__, false, - false + false, + unquote(relay_ids?) ) end @@ -334,6 +362,50 @@ defmodule AshGraphql do end end + def relay_queries(apis_with_resources, schema, env) do + type_to_api_and_resource_map = + apis_with_resources + |> Enum.flat_map(fn {api, resources} -> + resources + |> Enum.flat_map(fn resource -> + type = AshGraphql.Resource.Info.type(resource) + + if type do + [{type, {api, resource}}] + else + [] + end + end) + end) + |> Enum.into(%{}) + + [ + %Absinthe.Blueprint.Schema.FieldDefinition{ + name: "node", + identifier: :node, + arguments: [ + %Absinthe.Blueprint.Schema.InputValueDefinition{ + name: "id", + identifier: :id, + type: %Absinthe.Blueprint.TypeReference.NonNull{ + of_type: :id + }, + description: "The Node unique identifier", + __reference__: AshGraphql.Resource.ref(env) + } + ], + middleware: [ + {{AshGraphql.Graphql.Resolver, :resolve_node}, type_to_api_and_resource_map} + ], + complexity: {AshGraphql.Graphql.Resolver, :query_complexity}, + module: schema, + description: "Retrieves a Node from its global id", + type: %Absinthe.Blueprint.TypeReference.NonNull{of_type: :node}, + __reference__: AshGraphql.Resource.ref(__ENV__) + } + ] + end + defp nested_attrs({:array, type}, constraints, already_checked) do nested_attrs(type, constraints[:items] || [], already_checked) end @@ -491,7 +563,7 @@ defmodule AshGraphql do end # sobelow_skip ["DOS.BinToAtom"] - def get_embedded_types(all_resources, schema) do + def get_embedded_types(all_resources, schema, relay_ids?) do all_resources |> Enum.flat_map(fn resource -> resource @@ -566,7 +638,8 @@ defmodule AshGraphql do AshGraphql.Resource.type_definition( embedded_type, Module.concat(embedded_type, ShadowApi), - schema + schema, + relay_ids? ), AshGraphql.Resource.embedded_type_input( source_resource, diff --git a/lib/error.ex b/lib/error.ex index e59a2419..36f9cd41 100644 --- a/lib/error.ex +++ b/lib/error.ex @@ -141,3 +141,15 @@ defimpl AshGraphql.Error, for: Ash.Error.Forbidden.ForbiddenField do } end end + +defimpl AshGraphql.Error, for: Ash.Error.Invalid.InvalidPrimaryKey do + def to_error(error) do + %{ + message: "invalid primary key provided", + short_message: "invalid primary key provided", + fields: [], + vars: Map.new(error.vars), + code: Ash.ErrorKind.code(error) + } + end +end diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 206097a9..8bcdfd4e 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -135,8 +135,9 @@ defmodule AshGraphql.Graphql.Resolver do type: :get, action: action, identity: identity, + type_name: type_name, modify_resolution: modify - } = gql_query} + } = gql_query, relay_ids?} ) do case handle_arguments(resource, action, arguments) do {:ok, arguments} -> @@ -165,7 +166,7 @@ defmodule AshGraphql.Graphql.Resolver do tenant: Map.get(context, :tenant) ] - filter = identity_filter(identity, resource, arguments) + filter = identity_filter(identity, resource, arguments, relay_ids?) query = resource @@ -173,7 +174,7 @@ defmodule AshGraphql.Graphql.Resolver do |> Ash.Query.set_tenant(Map.get(context, :tenant)) |> Ash.Query.set_context(get_context(context)) |> set_query_arguments(action, arguments) - |> select_fields(resource, resolution) + |> select_fields(resource, resolution, type_name) {result, modify_args} = case filter do @@ -212,7 +213,7 @@ defmodule AshGraphql.Graphql.Resolver do |> Ash.Query.set_tenant(Map.get(context, :tenant)) |> Ash.Query.set_context(get_context(context)) |> set_query_arguments(action, arguments) - |> select_fields(resource, resolution) + |> select_fields(resource, resolution, type_name) |> load_fields( [ api: api, @@ -278,8 +279,14 @@ defmodule AshGraphql.Graphql.Resolver do def resolve( %{arguments: args, context: context} = resolution, {api, resource, - %{name: query_name, type: :read_one, action: action, modify_resolution: modify} = - gql_query} + %{ + name: query_name, + type: :read_one, + action: action, + modify_resolution: modify, + type_name: type_name + } = + gql_query, _relay_ids?} ) do metadata = %{ api: api, @@ -313,7 +320,7 @@ defmodule AshGraphql.Graphql.Resolver do |> Ash.Query.set_tenant(Map.get(context, :tenant)) |> Ash.Query.set_context(get_context(context)) |> set_query_arguments(action, args) - |> select_fields(resource, resolution) + |> select_fields(resource, resolution, type_name) |> load_fields( [ api: api, @@ -371,8 +378,9 @@ defmodule AshGraphql.Graphql.Resolver do type: :list, relay?: relay?, action: action, + type_name: type_name, modify_resolution: modify - } = gql_query} + } = gql_query, _relay_ids?} ) do case handle_arguments(resource, action, args) do {:ok, args} -> @@ -405,14 +413,15 @@ defmodule AshGraphql.Graphql.Resolver do query = apply_load_arguments(args, Ash.Query.new(resource), true) {result, modify_args} = - with {:ok, opts} <- validate_resolve_opts(resolution, pagination, opts, args), + with {:ok, opts} <- + validate_resolve_opts(resolution, resource, pagination, relay?, opts, args), result_fields <- get_result_fields(pagination, relay?), initial_query <- query |> Ash.Query.set_tenant(Map.get(context, :tenant)) |> Ash.Query.set_context(get_context(context)) |> set_query_arguments(action, args) - |> select_fields(resource, resolution, result_fields), + |> select_fields(resource, resolution, type_name, result_fields), query <- load_fields( initial_query, @@ -727,7 +736,7 @@ defmodule AshGraphql.Graphql.Resolver do end end - def validate_resolve_opts(resolution, pagination, opts, args) do + def validate_resolve_opts(resolution, resource, pagination, relay?, opts, args) do if pagination && (pagination.offset? || pagination.keyset?) do with page_opts <- args @@ -735,7 +744,8 @@ defmodule AshGraphql.Graphql.Resolver do |> Enum.reject(fn {_, val} -> is_nil(val) end), {:ok, page_opts} <- validate_offset_opts(page_opts, pagination), {:ok, page_opts} <- validate_keyset_opts(page_opts, pagination) do - field_names = resolution |> fields([]) |> names_only() + type = page_type(resource, pagination, relay?) + field_names = resolution |> fields([], type) |> names_only() page = if Enum.any?(field_names, &(&1 == :count)) do @@ -990,7 +1000,7 @@ defmodule AshGraphql.Graphql.Resolver do upsert?: upsert?, upsert_identity: upsert_identity, modify_resolution: modify - }} + }, _relay_ids?} ) do input = arguments[:input] || %{} @@ -1029,6 +1039,8 @@ defmodule AshGraphql.Graphql.Resolver do opts end + type_name = mutation_result_type(mutation_name) + changeset = resource |> Ash.Changeset.new() @@ -1038,7 +1050,7 @@ defmodule AshGraphql.Graphql.Resolver do actor: Map.get(context, :actor), authorize?: AshGraphql.Api.Info.authorize?(api) ) - |> select_fields(resource, resolution, ["result"]) + |> select_fields(resource, resolution, type_name, ["result"]) |> load_fields( [ api: api, @@ -1111,7 +1123,7 @@ defmodule AshGraphql.Graphql.Resolver do identity: identity, read_action: read_action, modify_resolution: modify - }} + }, relay_ids?} ) do read_action = read_action || Ash.Resource.Info.primary_action!(resource, :read).name input = arguments[:input] || %{} @@ -1142,7 +1154,7 @@ defmodule AshGraphql.Graphql.Resolver do :gql_mutation, mutation_name, metadata do - filter = identity_filter(identity, resource, arguments) + filter = identity_filter(identity, resource, arguments, relay_ids?) case filter do {:ok, filter} -> @@ -1174,6 +1186,8 @@ defmodule AshGraphql.Graphql.Resolver do tenant: Map.get(context, :tenant) ] + type_name = mutation_result_type(mutation_name) + changeset = initial |> Ash.Changeset.new() @@ -1183,7 +1197,7 @@ defmodule AshGraphql.Graphql.Resolver do actor: Map.get(context, :actor), authorize?: AshGraphql.Api.Info.authorize?(api) ) - |> select_fields(resource, resolution, ["result"]) + |> select_fields(resource, resolution, type_name, ["result"]) |> load_fields( [ api: api, @@ -1271,7 +1285,7 @@ defmodule AshGraphql.Graphql.Resolver do identity: identity, read_action: read_action, modify_resolution: modify - }} + }, relay_ids?} ) do read_action = read_action || Ash.Resource.Info.primary_action!(resource, :read).name input = arguments[:input] || %{} @@ -1302,7 +1316,7 @@ defmodule AshGraphql.Graphql.Resolver do :gql_mutation, mutation_name, metadata do - filter = identity_filter(identity, resource, arguments) + filter = identity_filter(identity, resource, arguments, relay_ids?) case filter do {:ok, filter} -> @@ -1334,6 +1348,8 @@ defmodule AshGraphql.Graphql.Resolver do tenant: Map.get(context, :tenant) ] + type_name = mutation_result_type(mutation_name) + changeset = initial |> Ash.Changeset.new() @@ -1343,7 +1359,7 @@ defmodule AshGraphql.Graphql.Resolver do actor: Map.get(context, :actor), authorize?: AshGraphql.Api.Info.authorize?(api) ) - |> select_fields(resource, resolution, ["result"]) + |> select_fields(resource, resolution, type_name, ["result"]) {result, modify_args} = changeset @@ -1440,13 +1456,17 @@ defmodule AshGraphql.Graphql.Resolver do apply(m, f, [resolution | args] ++ a) end - def identity_filter(false, _resource, _arguments) do + def identity_filter(false, _resource, _arguments, _relay_ids?) do {:ok, nil} end - def identity_filter(nil, resource, arguments) do - if AshGraphql.Resource.Info.encode_primary_key?(resource) do - case AshGraphql.Resource.decode_primary_key(resource, Map.get(arguments, :id) || "") do + def identity_filter(nil, resource, arguments, relay_ids?) do + if relay_ids? or AshGraphql.Resource.Info.encode_primary_key?(resource) do + case AshGraphql.Resource.decode_id( + resource, + Map.get(arguments, :id) || "", + relay_ids? + ) do {:ok, value} -> {:ok, value} @@ -1472,7 +1492,7 @@ defmodule AshGraphql.Graphql.Resolver do end end - def identity_filter(identity, resource, arguments) do + def identity_filter(identity, resource, arguments, _relay_ids?) do {:ok, resource |> Ash.Resource.Info.identities() @@ -1531,8 +1551,10 @@ defmodule AshGraphql.Graphql.Resolver do defp clear_fields(nil, _, _), do: nil defp clear_fields(result, resource, resolution) do + type = AshGraphql.Resource.Info.type(resource) + resolution - |> fields(["result"]) + |> fields(["result"], type) |> names_only() |> Enum.map(fn identifier -> Ash.Resource.Info.aggregate(resource, identifier) @@ -1754,6 +1776,7 @@ defmodule AshGraphql.Graphql.Resolver do |> select_fields( relationship.destination, resolution, + nil, Enum.map(Enum.reverse([selection | path]), & &1.name) ) |> load_fields( @@ -2083,11 +2106,32 @@ defmodule AshGraphql.Graphql.Resolver do end end + defp mutation_result_type(mutation_name) do + String.to_atom("#{mutation_name}_result") + end + + defp page_type(resource, pagination, relay?) do + type = AshGraphql.Resource.Info.type(resource) + + cond do + relay? -> + String.to_atom("#{type}_connection") + + pagination.keyset? -> + String.to_atom("keyset_page_of_#{type}") + + pagination.offset? -> + String.to_atom("page_of_#{type}") + end + end + @doc false - def select_fields(query_or_changeset, resource, resolution, nested \\ []) do + def select_fields(query_or_changeset, resource, resolution, type_override, nested \\ []) do + type = type_override || AshGraphql.Resource.Info.type(resource) + subfields = resolution - |> fields(nested) + |> fields(nested, type) |> names_only() |> Enum.map(&field_or_relationship(resource, &1)) |> Enum.filter(& &1) @@ -2118,12 +2162,15 @@ defmodule AshGraphql.Graphql.Resolver do end end - defp fields(%Absinthe.Resolution{} = resolution, []) do + defp fields(%Absinthe.Resolution{} = resolution, [], type) do resolution - |> Absinthe.Resolution.project() + |> Absinthe.Resolution.project(type) end - defp fields(%Absinthe.Resolution{} = resolution, names) do + defp fields(%Absinthe.Resolution{} = resolution, names, _type) do + # Here we don't pass the type to project because the Enum.reduce below already + # takes care of projecting the nested fields using the correct type + project = resolution |> Absinthe.Resolution.project() @@ -2375,9 +2422,12 @@ defmodule AshGraphql.Graphql.Resolver do def resolve_id( %{source: parent} = resolution, - {_resource, field} + {_resource, _field, relay_ids?} ) do - Absinthe.Resolution.put_result(resolution, {:ok, Map.get(parent, field)}) + Absinthe.Resolution.put_result( + resolution, + {:ok, AshGraphql.Resource.encode_id(parent, relay_ids?)} + ) end def resolve_union(%Absinthe.Resolution{state: :resolved} = resolution, _), @@ -2508,11 +2558,11 @@ defmodule AshGraphql.Graphql.Resolver do def resolve_composite_id( %{source: parent} = resolution, - {_resource, _fields} + {_resource, _fields, relay_ids?} ) do Absinthe.Resolution.put_result( resolution, - {:ok, AshGraphql.Resource.encode_primary_key(parent)} + {:ok, AshGraphql.Resource.encode_id(parent, relay_ids?)} ) end @@ -2536,6 +2586,26 @@ defmodule AshGraphql.Graphql.Resolver do child_complexity + 1 end + def resolve_node(%{arguments: %{id: id}} = resolution, type_to_api_and_resource_map) do + case AshGraphql.Resource.decode_relay_id(id) do + {:ok, {type, primary_key}} -> + {api, resource} = Map.fetch!(type_to_api_and_resource_map, type) + # We can be sure this returns something since we check this at compile time + query = AshGraphql.Resource.primary_key_get_query(resource) + + # We pass relay_ids? as false since we pass the already decoded primary key + put_in(resolution.arguments.id, primary_key) + |> resolve({api, resource, query, false}) + + {:error, _reason} = error -> + Absinthe.Resolution.put_result(resolution, error) + end + end + + def resolve_node_type(%resource{}, _) do + AshGraphql.Resource.Info.type(resource) + end + defp apply_load_arguments(arguments, query, will_paginate? \\ false) do Enum.reduce(arguments, query, fn {:limit, limit}, query when not will_paginate? -> diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 2fab7783..06a35ba2 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -400,6 +400,14 @@ defmodule AshGraphql.Resource do %{module: __MODULE__, location: %{file: env.file, line: env.line}} end + def encode_id(record, relay_ids?) do + if relay_ids? do + encode_relay_id(record) + else + encode_primary_key(record) + end + end + def encode_primary_key(%resource{} = record) do case Ash.Resource.Info.primary_key(resource) do [field] -> @@ -417,6 +425,30 @@ defmodule AshGraphql.Resource do end end + def encode_relay_id(%resource{} = record) do + type = type(resource) + primary_key = encode_primary_key(record) + + "#{type}:#{primary_key}" + |> Base.encode64() + end + + def decode_id(resource, id, relay_ids?) do + type = type(resource) + + if relay_ids? do + case decode_relay_id(id) do + {:ok, {^type, primary_key}} -> + decode_primary_key(resource, primary_key) + + _ -> + {:error, Ash.Error.Invalid.InvalidPrimaryKey.exception(resource: resource, value: id)} + end + else + decode_primary_key(resource, id) + end + end + def decode_primary_key(resource, value) do case Ash.Resource.Info.primary_key(resource) do [field] -> @@ -434,8 +466,22 @@ defmodule AshGraphql.Resource do end end + def decode_relay_id(id) do + [type_string, primary_key] = + id + |> Base.decode64!() + |> String.split(":", parts: 2) + + type = String.to_existing_atom(type_string) + + {:ok, {type, primary_key}} + rescue + _ -> + {:error, Ash.Error.Invalid.InvalidPrimaryKey.exception(resource: nil, value: id)} + end + @doc false - def queries(api, resource, action_middleware, schema, as_mutations? \\ false) do + def queries(api, resource, action_middleware, schema, relay_ids?, as_mutations? \\ false) do type = AshGraphql.Resource.Info.type(resource) if type do @@ -475,7 +521,7 @@ defmodule AshGraphql.Resource do middleware: action_middleware ++ [ - {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, query}} + {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, query, relay_ids?}} ], complexity: {AshGraphql.Graphql.Resolver, :query_complexity}, module: schema, @@ -492,7 +538,7 @@ defmodule AshGraphql.Resource do # sobelow_skip ["DOS.StringToAtom"] @doc false - def mutations(api, resource, action_middleware, schema) do + def mutations(api, resource, action_middleware, schema, relay_ids?) do resource |> mutations() |> Enum.map(fn @@ -540,7 +586,7 @@ defmodule AshGraphql.Resource do raise "No such action #{mutation.action} for #{inspect(resource)}" if action.soft? do - update_mutation(resource, schema, mutation, schema, action_middleware, api) + update_mutation(resource, schema, mutation, schema, action_middleware, api, relay_ids?) else %Absinthe.Blueprint.Schema.FieldDefinition{ arguments: mutation_args(mutation, resource, schema), @@ -548,7 +594,7 @@ defmodule AshGraphql.Resource do middleware: action_middleware ++ [ - {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation}} + {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation, relay_ids?}} ], module: schema, name: to_string(mutation.name), @@ -591,7 +637,7 @@ defmodule AshGraphql.Resource do middleware: action_middleware ++ [ - {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation}} + {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation, relay_ids?}} ], module: schema, name: to_string(mutation.name), @@ -601,13 +647,13 @@ defmodule AshGraphql.Resource do } mutation -> - update_mutation(resource, schema, mutation, schema, action_middleware, api) + update_mutation(resource, schema, mutation, schema, action_middleware, api, relay_ids?) end) - |> Enum.concat(queries(api, resource, action_middleware, schema, true)) + |> Enum.concat(queries(api, resource, action_middleware, schema, relay_ids?, true)) end # sobelow_skip ["DOS.StringToAtom"] - defp update_mutation(resource, schema, mutation, schema, action_middleware, api) do + defp update_mutation(resource, schema, mutation, schema, action_middleware, api, relay_ids?) do action = Ash.Resource.Info.action(resource, mutation.action) || raise "No such action #{mutation.action} for #{inspect(resource)}" @@ -642,7 +688,7 @@ defmodule AshGraphql.Resource do middleware: action_middleware ++ [ - {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation}} + {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation, relay_ids?}} ], module: schema, name: to_string(mutation.name), @@ -1494,10 +1540,10 @@ defmodule AshGraphql.Resource do end @doc false - def type_definitions(resource, api, schema) do + def type_definitions(resource, api, schema, relay_ids?) do List.wrap(calculation_input(resource, schema)) ++ - List.wrap(type_definition(resource, api, schema)) ++ - List.wrap(query_type_definitions(resource, api, schema)) ++ + List.wrap(type_definition(resource, api, schema, relay_ids?)) ++ + List.wrap(query_type_definitions(resource, api, schema, relay_ids?)) ++ List.wrap(sort_input(resource, schema)) ++ List.wrap(filter_input(resource, schema)) ++ filter_field_types(resource, schema) ++ @@ -3308,7 +3354,7 @@ defmodule AshGraphql.Resource do type.identifier == :node end - def query_type_definitions(resource, api, schema) do + def query_type_definitions(resource, api, schema, relay_ids?) do resource_type = AshGraphql.Resource.Info.type(resource) resource @@ -3317,34 +3363,28 @@ defmodule AshGraphql.Resource do |> Enum.map(fn query -> relay? = Map.get(query, :relay?) + # We can implement the Relay node interface only if the resource has a get + # query using the primary key as identity interfaces = - if relay? do + if relay? and primary_key_get_query(resource) != nil do [:node] else [] end - is_type_of = - if relay? do - &AshGraphql.Resource.is_node_type/1 - else - nil - end - %Absinthe.Blueprint.Schema.ObjectTypeDefinition{ description: Ash.Resource.Info.description(resource), interfaces: interfaces, - fields: fields(resource, api, schema, query), + fields: fields(resource, api, schema, relay_ids?, query), identifier: query.type_name, module: schema, name: Macro.camelize(to_string(query.type_name)), - __reference__: ref(__ENV__), - is_type_of: is_type_of + __reference__: ref(__ENV__) } end) end - def type_definition(resource, api, schema) do + def type_definition(resource, api, schema, relay_ids?) do actual_resource = Ash.Type.NewType.subtype_of(resource) if generate_object?(resource) do @@ -3356,36 +3396,31 @@ defmodule AshGraphql.Resource do resource |> queries() |> Enum.any?(&Map.get(&1, :relay?)) + |> Kernel.or(relay_ids?) + # We can implement the Relay node interface only if the resource has a get + # query using the primary key as identity interfaces = - if relay? do + if relay? and primary_key_get_query(resource) != nil do [:node] else [] end - is_type_of = - if relay? do - &AshGraphql.Resource.is_node_type/1 - else - nil - end - %Absinthe.Blueprint.Schema.ObjectTypeDefinition{ description: Ash.Resource.Info.description(resource), interfaces: interfaces, - fields: fields(resource, api, schema), + fields: fields(resource, api, schema, relay_ids?), identifier: type, module: schema, name: Macro.camelize(to_string(type)), - __reference__: ref(__ENV__), - is_type_of: is_type_of + __reference__: ref(__ENV__) } end end - defp fields(resource, api, schema, query \\ nil) do - attributes(resource, api, schema) ++ + defp fields(resource, api, schema, relay_ids?, query \\ nil) do + attributes(resource, api, schema, relay_ids?) ++ metadata(query, resource, schema) ++ relationships(resource, api, schema) ++ aggregates(resource, api, schema) ++ @@ -3448,7 +3483,7 @@ defmodule AshGraphql.Resource do end end - defp attributes(resource, api, schema) do + defp attributes(resource, api, schema, relay_ids?) do attribute_names = AshGraphql.Resource.Info.field_names(resource) attributes = @@ -3490,15 +3525,15 @@ defmodule AshGraphql.Resource do } end) - if AshGraphql.Resource.Info.encode_primary_key?(resource) do - encoded_primary_key_attributes(resource, schema) ++ + if relay_ids? or AshGraphql.Resource.Info.encode_primary_key?(resource) do + encoded_id(resource, schema, relay_ids?) ++ attributes else attributes end end - defp encoded_primary_key_attributes(resource, schema) do + defp encoded_id(resource, schema, relay_ids?) do case Ash.Resource.Info.primary_key(resource) do [field] -> attribute = Ash.Resource.Info.attribute(resource, field) @@ -3514,7 +3549,7 @@ defmodule AshGraphql.Resource do name: "id", type: %Absinthe.Blueprint.TypeReference.NonNull{of_type: :id}, middleware: [ - {{AshGraphql.Graphql.Resolver, :resolve_id}, {resource, field}} + {{AshGraphql.Graphql.Resolver, :resolve_id}, {resource, field, relay_ids?}} ], __reference__: ref(__ENV__) } @@ -3530,7 +3565,8 @@ defmodule AshGraphql.Resource do name: "id", type: %Absinthe.Blueprint.TypeReference.NonNull{of_type: :id}, middleware: [ - {{AshGraphql.Graphql.Resolver, :resolve_composite_id}, {resource, fields}} + {{AshGraphql.Graphql.Resolver, :resolve_composite_id}, + {resource, fields, relay_ids?}} ], __reference__: ref(__ENV__) } @@ -4118,4 +4154,11 @@ defmodule AshGraphql.Resource do name end end + + def primary_key_get_query(resource) do + # Find the get query with no identities, i.e. the one that uses the primary key + resource + |> AshGraphql.Resource.Info.queries() + |> Enum.find(&(&1.type == :get and (&1.identity == nil or &1.identity == false))) + end end diff --git a/lib/subscriptions.ex b/lib/subscriptions.ex index efae6b7d..226ae8dc 100644 --- a/lib/subscriptions.ex +++ b/lib/subscriptions.ex @@ -14,7 +14,7 @@ defmodule AshGraphql.Subscription do query |> Ash.Query.set_tenant(Map.get(context, :tenant)) |> Ash.Query.set_context(get_context(context)) - |> AshGraphql.Graphql.Resolver.select_fields(query.resource, resolution) + |> AshGraphql.Graphql.Resolver.select_fields(query.resource, resolution, nil) |> AshGraphql.Graphql.Resolver.load_fields( [ api: api, diff --git a/test/relay_ids_test.exs b/test/relay_ids_test.exs new file mode 100644 index 00000000..c6650228 --- /dev/null +++ b/test/relay_ids_test.exs @@ -0,0 +1,228 @@ +defmodule AshGraphql.RelayIdsTest do + use ExUnit.Case, async: false + + alias AshGraphql.Test.RelayIds.{Api, Post, ResourceWithNoPrimaryKeyGet, Schema, User} + + setup do + on_exit(fn -> + AshGraphql.TestHelpers.stop_ets() + end) + end + + describe "relay global ID" do + test "can be used in get queries and is exposed correctly in relationships" do + user = + User + |> Ash.Changeset.for_create(:create, %{name: "fred"}) + |> Api.create!() + + post = + Post + |> Ash.Changeset.for_create( + :create, + %{ + author_id: user.id, + text: "foo", + published: true + } + ) + |> Api.create!() + + user_relay_id = AshGraphql.Resource.encode_relay_id(user) + post_relay_id = AshGraphql.Resource.encode_relay_id(post) + + resp = + """ + query GetPost($id: ID!) { + getPost(id: $id) { + text + author { + id + name + } + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "id" => post_relay_id + } + ) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "getPost" => %{ + "text" => "foo", + "author" => %{"id" => ^user_relay_id, "name" => "fred"} + } + } + } = result + end + + test "returns error on invalid ID" do + resp = + """ + query GetPost($id: ID!) { + getPost(id: $id) { + text + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "id" => "invalid" + } + ) + + assert {:ok, result} = resp + assert [%{code: "invalid_primary_key"}] = result[:errors] + end + + test "returns error on ID for wrong resource" do + user = + User + |> Ash.Changeset.for_create(:create, %{name: "fred"}) + |> Api.create!() + + user_relay_id = AshGraphql.Resource.encode_relay_id(user) + + resp = + """ + query GetPost($id: ID!) { + getPost(id: $id) { + text + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "id" => user_relay_id + } + ) + + assert {:ok, result} = resp + assert [%{code: "invalid_primary_key"}] = result[:errors] + end + end + + describe "node interface and query" do + test "allows retrieving resources" do + user = + User + |> Ash.Changeset.for_create(:create, %{name: "fred"}) + |> Api.create!() + + post = + Post + |> Ash.Changeset.for_create( + :create, + %{ + author_id: user.id, + text: "foo", + published: true + } + ) + |> Api.create!() + + user_relay_id = AshGraphql.Resource.encode_relay_id(user) + post_relay_id = AshGraphql.Resource.encode_relay_id(post) + + document = + """ + query Node($id: ID!) { + node(id: $id) { + __typename + + ... on User { + name + } + + ... on Post { + text + } + } + } + """ + + resp = + document + |> Absinthe.run(Schema, + variables: %{ + "id" => post_relay_id + } + ) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "node" => %{ + "__typename" => "Post", + "text" => "foo" + } + } + } = result + + resp = + document + |> Absinthe.run(Schema, + variables: %{ + "id" => user_relay_id + } + ) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "node" => %{ + "__typename" => "User", + "name" => "fred" + } + } + } = result + end + + test "return an error for resources without a primary key get" do + resource = + ResourceWithNoPrimaryKeyGet + |> Ash.Changeset.for_create(:create, %{name: "foo"}) + |> Api.create!() + + document = + """ + query Node($id: ID!) { + node(id: $id) { + __typename + + ... on ResourceWithNoPrimaryKeyGet{ + name + } + } + } + """ + + resource_relay_id = AshGraphql.Resource.encode_relay_id(resource) + + resp = + document + |> Absinthe.run(Schema, + variables: %{ + "id" => resource_relay_id + } + ) + + assert {:ok, result} = resp + + assert result[:errors] != nil + end + end +end diff --git a/test/support/relay_ids/api.ex b/test/support/relay_ids/api.ex new file mode 100644 index 00000000..47d455d2 --- /dev/null +++ b/test/support/relay_ids/api.ex @@ -0,0 +1,13 @@ +defmodule AshGraphql.Test.RelayIds.Api do + @moduledoc false + + use Ash.Api, + extensions: [ + AshGraphql.Api + ], + otp_app: :ash_graphql + + resources do + registry(AshGraphql.Test.RelayIds.Registry) + end +end diff --git a/test/support/relay_ids/registry.ex b/test/support/relay_ids/registry.ex new file mode 100644 index 00000000..6a9f9108 --- /dev/null +++ b/test/support/relay_ids/registry.ex @@ -0,0 +1,10 @@ +defmodule AshGraphql.Test.RelayIds.Registry do + @moduledoc false + use Ash.Registry + + entries do + entry(AshGraphql.Test.RelayIds.Post) + entry(AshGraphql.Test.RelayIds.ResourceWithNoPrimaryKeyGet) + entry(AshGraphql.Test.RelayIds.User) + end +end diff --git a/test/support/relay_ids/resources/post.ex b/test/support/relay_ids/resources/post.ex new file mode 100644 index 00000000..fb950e19 --- /dev/null +++ b/test/support/relay_ids/resources/post.ex @@ -0,0 +1,46 @@ +defmodule AshGraphql.Test.RelayIds.Post do + @moduledoc false + + use Ash.Resource, + data_layer: Ash.DataLayer.Ets, + extensions: [AshGraphql.Resource] + + require Ash.Query + + graphql do + type :post + + queries do + get :get_post, :read + list :post_library, :read + end + + mutations do + create :simple_create_post, :create + update :update_post, :update + destroy :delete_post, :destroy + end + end + + actions do + defaults([:update, :read, :destroy]) + + create :create do + primary?(true) + argument(:author_id, :uuid) + + change(set_attribute(:author_id, arg(:author_id))) + end + end + + attributes do + uuid_primary_key(:id) + attribute(:text, :string) + end + + relationships do + belongs_to(:author, AshGraphql.Test.RelayIds.User) do + attribute_writable?(true) + end + end +end diff --git a/test/support/relay_ids/resources/resource_with_no_primary_key_get.ex b/test/support/relay_ids/resources/resource_with_no_primary_key_get.ex new file mode 100644 index 00000000..1c84f09b --- /dev/null +++ b/test/support/relay_ids/resources/resource_with_no_primary_key_get.ex @@ -0,0 +1,38 @@ +defmodule AshGraphql.Test.RelayIds.ResourceWithNoPrimaryKeyGet do + @moduledoc false + + use Ash.Resource, + data_layer: Ash.DataLayer.Ets, + extensions: [AshGraphql.Resource] + + graphql do + type :resource_with_no_primary_key_get + + queries do + get :get_resource_by_name, :get_by_name + end + + mutations do + create :create_resource, :create + end + end + + actions do + defaults([:create, :update, :destroy, :read]) + + read(:get_by_name, get_by: :name) + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, allow_nil?: false) + end + + identities do + identity(:name, [:name], pre_check_with: AshGraphql.Test.RelayIds.Api) + end + + relationships do + has_many(:posts, AshGraphql.Test.RelayIds.Post, destination_attribute: :author_id) + end +end diff --git a/test/support/relay_ids/resources/user.ex b/test/support/relay_ids/resources/user.ex new file mode 100644 index 00000000..577ff111 --- /dev/null +++ b/test/support/relay_ids/resources/user.ex @@ -0,0 +1,32 @@ +defmodule AshGraphql.Test.RelayIds.User do + @moduledoc false + + use Ash.Resource, + data_layer: Ash.DataLayer.Ets, + extensions: [AshGraphql.Resource] + + graphql do + type :user + + queries do + get :get_user, :read + end + + mutations do + create :create_user, :create + end + end + + actions do + defaults([:create, :update, :destroy, :read]) + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string) + end + + relationships do + has_many(:posts, AshGraphql.Test.RelayIds.Post, destination_attribute: :author_id) + end +end diff --git a/test/support/relay_ids/schema.ex b/test/support/relay_ids/schema.ex new file mode 100644 index 00000000..6b1cfabe --- /dev/null +++ b/test/support/relay_ids/schema.ex @@ -0,0 +1,30 @@ +defmodule AshGraphql.Test.RelayIds.Schema do + @moduledoc false + + use Absinthe.Schema + + @apis [AshGraphql.Test.RelayIds.Api] + + use AshGraphql, apis: @apis, relay_ids?: true + + query do + end + + mutation do + end + + object :foo do + field(:foo, :string) + field(:bar, :string) + end + + input_object :foo_input do + field(:foo, non_null(:string)) + field(:bar, non_null(:string)) + end + + enum :status do + value(:open, description: "The post is open") + value(:closed, description: "The post is closed") + end +end