diff --git a/.formatter.exs b/.formatter.exs index dfbae687..4e192992 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -41,6 +41,7 @@ spark_locals_without_parens = [ read_one: 3, relationships: 1, relay?: 1, + relay_id_translations: 1, root_level_errors?: 1, show_metadata: 1, show_raised_errors?: 1, diff --git a/documentation/dsls/DSL:-AshGraphql.Resource.cheatmd b/documentation/dsls/DSL:-AshGraphql.Resource.cheatmd index af262ca7..7c2c4f2a 100644 --- a/documentation/dsls/DSL:-AshGraphql.Resource.cheatmd +++ b/documentation/dsls/DSL:-AshGraphql.Resource.cheatmd @@ -639,6 +639,29 @@ get :get_post, :read + + + + + relay_id_translations + + + + + + Keyword.t + + + [] + + + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + +The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + + + + @@ -856,6 +879,29 @@ read_one :current_user, :current_user + + + + + relay_id_translations + + + + + + Keyword.t + + + [] + + + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + +The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + + + + @@ -1078,6 +1124,29 @@ list :list_posts_paginated, :read, relay?: true + + + + + relay_id_translations + + + + + + Keyword.t + + + [] + + + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + +The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + + + + @@ -1161,7 +1230,43 @@ action :check_status, :check_status +### Options + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDocs
+ + + relay_id_translations + + + + + Keyword.t + + [] + + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + +The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. +
@@ -1341,6 +1446,29 @@ create :create_post, :create + + + + + relay_id_translations + + + + + + Keyword.t + + + [] + + + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + +The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + + + + @@ -1477,6 +1605,29 @@ update :update_post, :update + + + + + relay_id_translations + + + + + + Keyword.t + + + [] + + + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + +The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + + + + @@ -1613,6 +1764,29 @@ destroy :destroy_post, :destroy + + + + + relay_id_translations + + + + + + Keyword.t + + + [] + + + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + +The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + + + + @@ -1696,7 +1870,43 @@ action :check_status, :check_status +### Options + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDocs
+ + + relay_id_translations + + + + + Keyword.t + + [] + + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + +The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. +
diff --git a/lib/graphql/id_translator.ex b/lib/graphql/id_translator.ex new file mode 100644 index 00000000..9bda4c5a --- /dev/null +++ b/lib/graphql/id_translator.ex @@ -0,0 +1,43 @@ +defmodule AshGraphql.Graphql.IdTranslator do + def translate_relay_ids(%{state: :unresolved} = resolution, relay_id_translations) do + arguments = + Enum.reduce(relay_id_translations, resolution.arguments, &process/2) + + %{resolution | arguments: arguments} + end + + def translate_relay_ids(resolution, _relay_id_translations) do + resolution + end + + defp process({field, nested_translations}, args) when is_list(nested_translations) do + case Map.get(args, field) do + subtree when is_map(subtree) -> + new_subtree = Enum.reduce(nested_translations, subtree, &process/2) + Map.put(args, field, new_subtree) + + _ -> + args + end + end + + defp process({field, type}, args) when is_atom(type) do + case Map.get(args, field) do + id when is_binary(id) -> + {:ok, %{type: ^type, id: decoded_id}} = AshGraphql.Resource.decode_relay_id(id) + Map.put(args, field, decoded_id) + + [id | _] = ids when is_binary(id) -> + decoded_ids = + Enum.map(ids, fn id -> + {:ok, %{type: ^type, id: decoded_id}} = AshGraphql.Resource.decode_relay_id(id) + decoded_id + end) + + Map.put(args, field, decoded_ids) + + _ -> + args + end + end +end diff --git a/lib/resource/mutation.ex b/lib/resource/mutation.ex index d8503ca6..75d6a181 100644 --- a/lib/resource/mutation.ex +++ b/lib/resource/mutation.ex @@ -8,7 +8,8 @@ defmodule AshGraphql.Resource.Mutation do :read_action, :upsert?, :upsert_identity, - :modify_resolution + :modify_resolution, + :relay_id_translations ] @create_schema [ @@ -37,6 +38,15 @@ defmodule AshGraphql.Resource.Mutation do doc: """ An MFA that will be called with the resolution, the query, and the result of the action as the first three arguments. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. """ + ], + relay_id_translations: [ + type: :keyword_list, + doc: """ + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + + The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + """, + default: [] ] ] @@ -61,6 +71,15 @@ defmodule AshGraphql.Resource.Mutation do type: :atom, doc: "The read action to use to fetch the record to be updated. Defaults to the primary read action." + ], + relay_id_translations: [ + type: :keyword_list, + doc: """ + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + + The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + """, + default: [] ] ] @@ -85,6 +104,15 @@ defmodule AshGraphql.Resource.Mutation do doc: """ The identity to use to fetch the record to be destroyed. Use `false` if no identity is required. """ + ], + relay_id_translations: [ + type: :keyword_list, + doc: """ + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + + The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + """, + default: [] ] ] diff --git a/lib/resource/query.ex b/lib/resource/query.ex index 9a597da6..ff18ef8a 100644 --- a/lib/resource/query.ex +++ b/lib/resource/query.ex @@ -7,6 +7,7 @@ defmodule AshGraphql.Resource.Query do :identity, :allow_nil?, :modify_resolution, + :relay_id_translations, as_mutation?: false, metadata_names: [], metadata_types: [], @@ -52,6 +53,15 @@ defmodule AshGraphql.Resource.Query do doc: """ Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. """ + ], + relay_id_translations: [ + type: :keyword_list, + doc: """ + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + + The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + """, + default: [] ] ] diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 10f6b65a..b31ec78a 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -57,12 +57,21 @@ defmodule AshGraphql.Resource do type: :atom, doc: "The action to use for the query.", required: true + ], + relay_id_translations: [ + type: :keyword_list, + doc: """ + A keyword list indicating which arguments or attributes will be automatically translated from a global Relay ID to a normal ID. + + The keyword list can be arbitrarily nested to match the argument structure. The leaves of the keyword list must have the attribute or argument name as key and its GraphQL type as value. + """, + default: [] ] ] defmodule Action do @moduledoc "Represents a configured generic action" - defstruct [:type, :name, :action] + defstruct [:type, :name, :action, :relay_id_translations] end @action %Spark.Dsl.Entity{ @@ -499,6 +508,7 @@ defmodule AshGraphql.Resource do identifier: name, middleware: action_middleware ++ + id_translation_middleware(query.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, query, false}} ], @@ -520,6 +530,7 @@ defmodule AshGraphql.Resource do identifier: query.name, middleware: action_middleware ++ + id_translation_middleware(query.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, query, relay_ids?}} ], @@ -569,6 +580,7 @@ defmodule AshGraphql.Resource do identifier: name, middleware: action_middleware ++ + id_translation_middleware(query.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, query, true}} ], @@ -593,6 +605,7 @@ defmodule AshGraphql.Resource do identifier: mutation.name, middleware: action_middleware ++ + id_translation_middleware(mutation.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation, relay_ids?}} ], @@ -636,6 +649,7 @@ defmodule AshGraphql.Resource do identifier: mutation.name, middleware: action_middleware ++ + id_translation_middleware(mutation.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation, relay_ids?}} ], @@ -687,6 +701,7 @@ defmodule AshGraphql.Resource do identifier: mutation.name, middleware: action_middleware ++ + id_translation_middleware(mutation.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation, relay_ids?}} ], @@ -862,6 +877,20 @@ defmodule AshGraphql.Resource do end) end + defp id_translation_middleware(relay_id_translations, true) do + # TODO: compile time check that all keys in relay_id_translations actually map + # to an argument or attribute in the query/mutation + # Is it possible to also check that the value is an existing GraphQL type? + # Probably not since we're in the process of constructing the schema + [ + {{AshGraphql.Graphql.IdTranslator, :translate_relay_ids}, relay_id_translations} + ] + end + + defp id_translation_middleware(_relay_id_translations, _relay_ids?) do + [] + end + # sobelow_skip ["DOS.StringToAtom"] defp metadata_field(resource, mutation, schema) do metadata_fields = diff --git a/test/relay_ids_test.exs b/test/relay_ids_test.exs index 757fe14c..38716679 100644 --- a/test/relay_ids_test.exs +++ b/test/relay_ids_test.exs @@ -257,4 +257,168 @@ defmodule AshGraphql.RelayIdsTest do |> AshGraphql.Resource.decode_relay_id() end end + + describe "relay ID translation" do + test "works with create mutations" do + author_id = + User + |> Ash.Changeset.for_create(:create, %{name: "Fred"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + + resp = + """ + mutation SimpleCreatePost($input: SimpleCreatePostInput) { + simpleCreatePost(input: $input) { + result { + text + author { + id + } + } + errors { + message + } + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "input" => %{ + "text" => "foo", + "author_id" => author_id + } + } + ) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "simpleCreatePost" => %{ + "result" => %{ + "text" => "foo", + "author" => %{ + "id" => ^author_id + } + } + } + } + } = result + end + + test "works in update mutations" do + author_id = + User + |> Ash.Changeset.for_create(:create, %{name: "Fred"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + + post_id = + Post + |> Ash.Changeset.for_create(:create, %{text: "foo"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + + resp = + """ + mutation AssignAuthor($id: ID!, $input: AssignAuthorInput) { + assignAuthor(id: $id, input: $input) { + result { + text + author { + id + } + } + errors { + message + } + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "id" => post_id, + "input" => %{ + "author_id" => author_id + } + } + ) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "assignAuthor" => %{ + "result" => %{ + "author" => %{ + "id" => ^author_id + } + } + } + } + } = result + end + + test "works with lists" do + author_id = + User + |> Ash.Changeset.for_create(:create, %{name: "Fred"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + + post_ids = + Enum.map(1..5, fn i -> + Post + |> Ash.Changeset.for_create(:create, %{text: "foo #{i}"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + end) + + resp = + """ + mutation AssignPosts($id: ID!, $input: AssignPostsInput) { + assignPosts(id: $id, input: $input) { + result { + posts { + id + } + } + errors { + message + } + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "id" => author_id, + "input" => %{ + "post_ids" => post_ids + } + } + ) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "assignPosts" => %{ + "result" => %{ + "posts" => posts + } + } + } + } = result + + assert length(posts) == 5 + Enum.each(posts, fn post -> assert post["id"] in post_ids end) + end + end end diff --git a/test/support/relay_ids/resources/post.ex b/test/support/relay_ids/resources/post.ex index fb950e19..e623caf3 100644 --- a/test/support/relay_ids/resources/post.ex +++ b/test/support/relay_ids/resources/post.ex @@ -16,8 +16,9 @@ defmodule AshGraphql.Test.RelayIds.Post do end mutations do - create :simple_create_post, :create + create :simple_create_post, :create, relay_id_translations: [input: [author_id: :user]] update :update_post, :update + update :assign_author, :assign_author, relay_id_translations: [input: [author_id: :user]] destroy :delete_post, :destroy end end @@ -31,6 +32,12 @@ defmodule AshGraphql.Test.RelayIds.Post do change(set_attribute(:author_id, arg(:author_id))) end + + update :assign_author do + argument(:author_id, :uuid) + + change(set_attribute(:author_id, arg(:author_id))) + end end attributes do diff --git a/test/support/relay_ids/resources/user.ex b/test/support/relay_ids/resources/user.ex index 577ff111..5323f41c 100644 --- a/test/support/relay_ids/resources/user.ex +++ b/test/support/relay_ids/resources/user.ex @@ -14,11 +14,18 @@ defmodule AshGraphql.Test.RelayIds.User do mutations do create :create_user, :create + update :assign_posts, :assign_posts, relay_id_translations: [input: [post_ids: :post]] end end actions do defaults([:create, :update, :destroy, :read]) + + update :assign_posts do + argument(:post_ids, {:array, :uuid}) + + change(manage_relationship(:post_ids, :posts, value_is_key: :id, type: :append_and_remove)) + end end attributes do