diff --git a/.formatter.exs b/.formatter.exs index ebdbc565..96b095c3 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -71,6 +71,7 @@ spark_locals_without_parens = [ relationships: 1, relay?: 1, relay_id_translations: 1, + result_name: 1, root_level_errors?: 1, show_fields: 1, show_metadata: 1, diff --git a/documentation/dsls/DSL-AshGraphql.Domain.md b/documentation/dsls/DSL-AshGraphql.Domain.md index aac985c7..6be889af 100644 --- a/documentation/dsls/DSL-AshGraphql.Domain.md +++ b/documentation/dsls/DSL-AshGraphql.Domain.md @@ -337,6 +337,7 @@ create :create_post, :create | [`modify_resolution`](#graphql-mutations-create-modify_resolution){: #graphql-mutations-create-modify_resolution } | `mfa` | | 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. | | [`meta`](#graphql-mutations-create-meta){: #graphql-mutations-create-meta } | `keyword` | `[]` | A keyword list of metadata for the mutation. | | [`group`](#graphql-mutations-create-group){: #graphql-mutations-create-group } | `atom` | | An optional grouping key used to nest this mutation under a group wrapper. | +| [`result_name`](#graphql-mutations-create-result_name){: #graphql-mutations-create-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | @@ -384,6 +385,7 @@ update :update_post, :update | [`modify_resolution`](#graphql-mutations-update-modify_resolution){: #graphql-mutations-update-modify_resolution } | `mfa` | | 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. | | [`meta`](#graphql-mutations-update-meta){: #graphql-mutations-update-meta } | `keyword` | `[]` | A keyword list of metadata for the mutation. | | [`group`](#graphql-mutations-update-group){: #graphql-mutations-update-group } | `atom` | | An optional grouping key used to nest this mutation under a group wrapper. | +| [`result_name`](#graphql-mutations-update-result_name){: #graphql-mutations-update-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | @@ -431,6 +433,7 @@ destroy :destroy_post, :destroy | [`modify_resolution`](#graphql-mutations-destroy-modify_resolution){: #graphql-mutations-destroy-modify_resolution } | `mfa` | | 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. | | [`meta`](#graphql-mutations-destroy-meta){: #graphql-mutations-destroy-meta } | `keyword` | `[]` | A keyword list of metadata for the mutation. | | [`group`](#graphql-mutations-destroy-group){: #graphql-mutations-destroy-group } | `atom` | | An optional grouping key used to nest this mutation under a group wrapper. | +| [`result_name`](#graphql-mutations-destroy-result_name){: #graphql-mutations-destroy-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | @@ -476,6 +479,7 @@ action :check_status, :check_status | [`labels`](#graphql-mutations-action-labels){: #graphql-mutations-action-labels } | `atom \| list(atom)` | `[]` | Labels used to include or exclude this action from schemas with matching labels. | | [`meta`](#graphql-mutations-action-meta){: #graphql-mutations-action-meta } | `keyword` | `[]` | A keyword list of metadata for the action. | | [`args`](#graphql-mutations-action-args){: #graphql-mutations-action-args } | `list(atom)` | | A list of action attributes or arguments that should get their own arguments in the mutation instead of being passed in an input object. | +| [`result_name`](#graphql-mutations-action-result_name){: #graphql-mutations-action-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | diff --git a/documentation/dsls/DSL-AshGraphql.Resource.md b/documentation/dsls/DSL-AshGraphql.Resource.md index 06ae7f9d..48a44ec7 100644 --- a/documentation/dsls/DSL-AshGraphql.Resource.md +++ b/documentation/dsls/DSL-AshGraphql.Resource.md @@ -613,6 +613,7 @@ create :create_post, :create | [`modify_resolution`](#graphql-mutations-create-modify_resolution){: #graphql-mutations-create-modify_resolution } | `mfa` | | 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. | | [`meta`](#graphql-mutations-create-meta){: #graphql-mutations-create-meta } | `keyword` | `[]` | A keyword list of metadata for the mutation. | | [`group`](#graphql-mutations-create-group){: #graphql-mutations-create-group } | `atom` | | An optional grouping key used to nest this mutation under a group wrapper. | +| [`result_name`](#graphql-mutations-create-result_name){: #graphql-mutations-create-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | @@ -659,6 +660,7 @@ update :update_post, :update | [`modify_resolution`](#graphql-mutations-update-modify_resolution){: #graphql-mutations-update-modify_resolution } | `mfa` | | 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. | | [`meta`](#graphql-mutations-update-meta){: #graphql-mutations-update-meta } | `keyword` | `[]` | A keyword list of metadata for the mutation. | | [`group`](#graphql-mutations-update-group){: #graphql-mutations-update-group } | `atom` | | An optional grouping key used to nest this mutation under a group wrapper. | +| [`result_name`](#graphql-mutations-update-result_name){: #graphql-mutations-update-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | @@ -705,6 +707,7 @@ destroy :destroy_post, :destroy | [`modify_resolution`](#graphql-mutations-destroy-modify_resolution){: #graphql-mutations-destroy-modify_resolution } | `mfa` | | 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. | | [`meta`](#graphql-mutations-destroy-meta){: #graphql-mutations-destroy-meta } | `keyword` | `[]` | A keyword list of metadata for the mutation. | | [`group`](#graphql-mutations-destroy-group){: #graphql-mutations-destroy-group } | `atom` | | An optional grouping key used to nest this mutation under a group wrapper. | +| [`result_name`](#graphql-mutations-destroy-result_name){: #graphql-mutations-destroy-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | @@ -749,6 +752,7 @@ action :check_status, :check_status | [`labels`](#graphql-mutations-action-labels){: #graphql-mutations-action-labels } | `atom \| list(atom)` | `[]` | Labels used to include or exclude this action from schemas with matching labels. | | [`meta`](#graphql-mutations-action-meta){: #graphql-mutations-action-meta } | `keyword` | `[]` | A keyword list of metadata for the action. | | [`args`](#graphql-mutations-action-args){: #graphql-mutations-action-args } | `list(atom)` | | A list of action attributes or arguments that should get their own arguments in the mutation instead of being passed in an input object. | +| [`result_name`](#graphql-mutations-action-result_name){: #graphql-mutations-action-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | @@ -821,6 +825,7 @@ create :create_post, :create | [`modify_resolution`](#graphql-mutations-group-create-modify_resolution){: #graphql-mutations-group-create-modify_resolution } | `mfa` | | 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. | | [`meta`](#graphql-mutations-group-create-meta){: #graphql-mutations-group-create-meta } | `keyword` | `[]` | A keyword list of metadata for the mutation. | | [`group`](#graphql-mutations-group-create-group){: #graphql-mutations-group-create-group } | `atom` | | An optional grouping key used to nest this mutation under a group wrapper. | +| [`result_name`](#graphql-mutations-group-create-result_name){: #graphql-mutations-group-create-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | @@ -867,6 +872,7 @@ update :update_post, :update | [`modify_resolution`](#graphql-mutations-group-update-modify_resolution){: #graphql-mutations-group-update-modify_resolution } | `mfa` | | 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. | | [`meta`](#graphql-mutations-group-update-meta){: #graphql-mutations-group-update-meta } | `keyword` | `[]` | A keyword list of metadata for the mutation. | | [`group`](#graphql-mutations-group-update-group){: #graphql-mutations-group-update-group } | `atom` | | An optional grouping key used to nest this mutation under a group wrapper. | +| [`result_name`](#graphql-mutations-group-update-result_name){: #graphql-mutations-group-update-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | @@ -913,6 +919,7 @@ destroy :destroy_post, :destroy | [`modify_resolution`](#graphql-mutations-group-destroy-modify_resolution){: #graphql-mutations-group-destroy-modify_resolution } | `mfa` | | 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. | | [`meta`](#graphql-mutations-group-destroy-meta){: #graphql-mutations-group-destroy-meta } | `keyword` | `[]` | A keyword list of metadata for the mutation. | | [`group`](#graphql-mutations-group-destroy-group){: #graphql-mutations-group-destroy-group } | `atom` | | An optional grouping key used to nest this mutation under a group wrapper. | +| [`result_name`](#graphql-mutations-group-destroy-result_name){: #graphql-mutations-group-destroy-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | @@ -957,6 +964,7 @@ action :check_status, :check_status | [`labels`](#graphql-mutations-group-action-labels){: #graphql-mutations-group-action-labels } | `atom \| list(atom)` | `[]` | Labels used to include or exclude this action from schemas with matching labels. | | [`meta`](#graphql-mutations-group-action-meta){: #graphql-mutations-group-action-meta } | `keyword` | `[]` | A keyword list of metadata for the action. | | [`args`](#graphql-mutations-group-action-args){: #graphql-mutations-group-action-args } | `list(atom)` | | A list of action attributes or arguments that should get their own arguments in the mutation instead of being passed in an input object. | +| [`result_name`](#graphql-mutations-group-action-result_name){: #graphql-mutations-group-action-result_name } | `atom` | | The GraphQL field name for the mutation result. Defaults to `result`. | diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 91011c93..1e1b06be 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -1524,7 +1524,7 @@ defmodule AshGraphql.Graphql.Resolver do upsert_identity: upsert_identity, args: args, modify_resolution: modify - }, _relay_ids?} + } = mutation, _relay_ids?} ) do case handle_mutation_arguments(resource, action, nil, arguments, args) do {:ok, input} -> @@ -1561,6 +1561,7 @@ defmodule AshGraphql.Graphql.Resolver do end type_name = mutation_result_type(mutation_name) + result_field_name = mutation_result_field_name(mutation) changeset = resource @@ -1571,7 +1572,7 @@ defmodule AshGraphql.Graphql.Resolver do actor: Map.get(context, :actor), authorize?: AshGraphql.Domain.Info.authorize?(domain) ) - |> select_fields(resource, resolution, type_name, ["result"]) + |> select_fields(resource, resolution, type_name, [result_field_name]) |> load_fields( [ domain: domain, @@ -1585,7 +1586,7 @@ defmodule AshGraphql.Graphql.Resolver do resolution.path, context, type_name, - ["result"] + [result_field_name] ) {result, modify_args} = @@ -1654,7 +1655,7 @@ defmodule AshGraphql.Graphql.Resolver do read_action: read_action, args: args, modify_resolution: modify - }, relay_ids?} + } = mutation, relay_ids?} ) do read_action = read_action || Ash.Resource.Info.primary_action!(resource, :read).name @@ -1689,6 +1690,8 @@ defmodule AshGraphql.Graphql.Resolver do |> set_query_arguments(read_action, read_action_input) |> Ash.Query.limit(1) + result_field_name = mutation_result_field_name(mutation) + {result, modify_args} = query |> Ash.bulk_update(action, input, @@ -1706,7 +1709,7 @@ defmodule AshGraphql.Graphql.Resolver do actor: Map.get(context, :actor), select: get_select(resource, resolution, mutation_result_type(mutation_name), [ - "result" + result_field_name ]), load: get_loads( @@ -1722,7 +1725,7 @@ defmodule AshGraphql.Graphql.Resolver do resolution.path, context, mutation_result_type(mutation_name), - ["result"] + [result_field_name] ) ) |> case do @@ -1824,7 +1827,7 @@ defmodule AshGraphql.Graphql.Resolver do read_action: read_action, args: args, modify_resolution: modify - }, relay_ids?} + } = mutation, relay_ids?} ) do read_action = read_action || Ash.Resource.Info.primary_action!(resource, :read).name @@ -1851,6 +1854,8 @@ defmodule AshGraphql.Graphql.Resolver do case filter do {:ok, filter} -> + result_field_name = mutation_result_field_name(mutation) + query = resource |> Ash.Query.do_filter(filter) @@ -1858,7 +1863,14 @@ defmodule AshGraphql.Graphql.Resolver do |> Ash.Query.set_context(get_context(context)) |> set_query_arguments(read_action, read_action_input) |> Ash.Query.limit(1) - |> pre_load_for_mutation(domain, resource, resolution, context, mutation_name) + |> pre_load_for_mutation( + domain, + resource, + resolution, + context, + mutation_name, + result_field_name + ) {result, modify_args} = query @@ -1877,7 +1889,7 @@ defmodule AshGraphql.Graphql.Resolver do domain: domain, select: get_select(resource, resolution, mutation_result_type(mutation_name), [ - "result" + result_field_name ]) ) |> case do @@ -2128,7 +2140,15 @@ defmodule AshGraphql.Graphql.Resolver do # Pre-load aggregates and calculations on the query before destruction # to ensure they are available in the returned record for GraphQL serialization - defp pre_load_for_mutation(query, domain, resource, resolution, context, mutation_name) do + defp pre_load_for_mutation( + query, + domain, + resource, + resolution, + context, + mutation_name, + result_field_name + ) do load_opts = [ domain: domain, tenant: Map.get(context, :tenant), @@ -2145,7 +2165,7 @@ defmodule AshGraphql.Graphql.Resolver do resolution.path, context, mutation_result_type(mutation_name), - ["result"] + [result_field_name] ) end @@ -2219,7 +2239,7 @@ defmodule AshGraphql.Graphql.Resolver do fields end - selection = Enum.find(selections, &(&1.name == nested)) + selection = Enum.find(selections, &field_matches_path?(&1, nested)) if selection do nested_fields_and_path(resolution, [selection | path], rest) @@ -2726,6 +2746,13 @@ defmodule AshGraphql.Graphql.Resolver do String.to_atom("#{mutation_name}_result") end + defp mutation_result_field_name(mutation) do + case Map.get(mutation, :result_name) do + nil -> "result" + name when is_atom(name) -> Atom.to_string(name) + end + end + # sobelow_skip ["DOS.StringToAtom"] defp subscription_result_type(subscription_name) do String.to_atom("#{subscription_name}_result") @@ -2809,7 +2836,7 @@ defmodule AshGraphql.Graphql.Resolver do cache = resolution.fields_cache Enum.reduce(names, {project, cache}, fn name, {fields, cache} -> - case fields |> Enum.find(&(&1.name == name)) do + case fields |> Enum.find(&field_matches_path?(&1, name)) do nil -> {fields, cache} @@ -2829,6 +2856,10 @@ defmodule AshGraphql.Graphql.Resolver do |> elem(0) end + defp field_matches_path?(field, name) do + field.name == name + end + defp names_only(fields) do Enum.map(fields, & &1.schema_node.identifier) end diff --git a/lib/resource/mutation.ex b/lib/resource/mutation.ex index 4bad412c..0b710baf 100644 --- a/lib/resource/mutation.ex +++ b/lib/resource/mutation.ex @@ -16,6 +16,7 @@ defmodule AshGraphql.Resource.Mutation do :modify_resolution, :relay_id_translations, :description, + :result_name, :__spark_metadata__, args: [], hide_inputs: [], @@ -76,6 +77,10 @@ defmodule AshGraphql.Resource.Mutation do group: [ type: :atom, doc: "An optional grouping key used to nest this mutation under a group wrapper." + ], + result_name: [ + type: :atom, + doc: "The GraphQL field name for the mutation result. Defaults to `result`." ] ] diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 924635c2..e6875c99 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -122,6 +122,7 @@ defmodule AshGraphql.Resource do :relay_id_translations, :error_location, :modify_resolution, + :result_name, :__spark_metadata__, args: [], hide_inputs: [], @@ -150,7 +151,7 @@ defmodule AshGraphql.Resource do schema: @action_schema |> Spark.Options.merge( - Mutation.create_schema() |> Keyword.take([:args]), + Mutation.create_schema() |> Keyword.take([:args, :result_name]), "Shared Mutation Options" ), args: [:name, :action], @@ -1543,6 +1544,8 @@ defmodule AshGraphql.Resource do } ] + result_field_name = mutation_result_field_name(mutation) + fields = if mutation.type == :action do [ @@ -1550,7 +1553,7 @@ defmodule AshGraphql.Resource do description: description, identifier: :result, module: schema, - name: "result", + name: result_field_name, type: generic_action_type( %{mutation.action | allow_nil?: true}, @@ -1581,7 +1584,7 @@ defmodule AshGraphql.Resource do description: description, identifier: :result, module: schema, - name: "result", + name: result_field_name, type: resource_type, __reference__: ref(__ENV__) } @@ -1648,6 +1651,13 @@ defmodule AshGraphql.Resource do end) end + defp mutation_result_field_name(mutation) do + case Map.get(mutation, :result_name) do + nil -> "result" + name when is_atom(name) -> Atom.to_string(name) + end + end + @doc false # sobelow_skip ["DOS.StringToAtom"] def query_types(resource, domain, all_domains, schema, labels \\ nil) do diff --git a/test/mutation_result_name_test.exs b/test/mutation_result_name_test.exs new file mode 100644 index 00000000..727f7f03 --- /dev/null +++ b/test/mutation_result_name_test.exs @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: 2020 ash_graphql contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshGraphql.MutationResultNameTest do + use ExUnit.Case, async: false + + setup do + on_exit(fn -> + Application.delete_env(:ash_graphql, AshGraphql.Test.Domain) + + AshGraphql.TestHelpers.stop_ets() + end) + end + + describe "mutation result_name" do + test "default create mutations still expose result" do + resp = + """ + mutation SimpleCreatePost($input: SimpleCreatePostInput) { + simpleCreatePost(input: $input) { + result { + text + comments { text } + } + errors { message } + } + } + """ + |> Absinthe.run(AshGraphql.Test.Schema, + variables: %{"input" => %{"text" => "foobar"}} + ) + + assert {:ok, result} = resp + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "simpleCreatePost" => %{ + "result" => %{ + "text" => "foobar", + "comments" => [] + } + } + } + } = result + end + + test "configured result_name exposes a custom result key with nested selection" do + resp = + """ + mutation CreatePostWithResultName($input: CreatePostWithResultNameInput) { + createPostWithResultName(input: $input) { + post { + text + comments { text } + } + errors { message } + } + } + """ + |> Absinthe.run(AshGraphql.Test.Schema, + variables: %{"input" => %{"text" => "custom key"}} + ) + + assert {:ok, result} = resp + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "createPostWithResultName" => %{ + "post" => %{ + "text" => "custom key", + "comments" => [] + } + } + } + } = result + end + + test "configured result_name resolves payload when resource has a public result attribute" do + resp = + """ + mutation CreatePostWithResultName($input: CreatePostWithResultNameInput) { + createPostWithResultName(input: $input) { + post { + text + result + } + errors { message } + } + } + """ + |> Absinthe.run(AshGraphql.Test.Schema, + variables: %{"input" => %{"text" => "body", "result" => "resource-result"}} + ) + + assert {:ok, result} = resp + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "createPostWithResultName" => %{ + "post" => %{ + "text" => "body", + "result" => "resource-result" + } + } + } + } = result + end + end +end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index b697e789..66da0673 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -235,6 +235,7 @@ defmodule AshGraphql.Test.Post do mutations do create :simple_create_post, :create, labels: [:public] + create :create_post_with_result_name, :create, result_name: :post create :create_post_with_arg, :create, args: [:text] create :create_post_with_argument_types, :create_with_argument_types create :create_post_with_error, :create_with_error @@ -558,6 +559,7 @@ defmodule AshGraphql.Test.Post do uuid_primary_key(:id) attribute(:text, :string, public?: true) + attribute(:result, :string, public?: true) attribute(:published, :boolean, default: false, public?: true) attribute(:foo, AshGraphql.Test.Foo, public?: true) attribute(:status, AshGraphql.Test.Status, public?: true)