From 401acc2835f0f3cc4a2a81f4627bcbb6191330ba Mon Sep 17 00:00:00 2001 From: "Eduardo B. Alexandre" Date: Thu, 27 Mar 2025 20:11:29 -0300 Subject: [PATCH] feat: Add validation queries (WIP) --- lib/graphql/resolver.ex | 267 ++++++++++++++++++ lib/resource/mutation.ex | 19 ++ lib/resource/resource.ex | 84 ++++-- lib/resource/transformers/validate_actions.ex | 23 +- 4 files changed, 361 insertions(+), 32 deletions(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 333bfa03..2b70a9e9 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -1405,6 +1405,16 @@ defmodule AshGraphql.Graphql.Resolver do def mutate(%Absinthe.Resolution{state: :resolved} = resolution, _), do: resolution + def mutate(resolution, {domain, resource, %{type: :validate, action: action} = mutation, relay_ids?}) do + case Ash.Resource.Info.action(resource, action) do + %Ash.Resource.Actions.Create{} -> + validate_create(resolution, domain, resource, mutation, relay_ids?) + + %Ash.Resource.Actions.Update{} -> + validate_update(resolution, domain, resource, mutation, relay_ids?) + end + end + def mutate( %{arguments: arguments, context: context} = resolution, {domain, resource, @@ -1869,6 +1879,263 @@ defmodule AshGraphql.Graphql.Resolver do end end + # TODO This duplicates a lot of code from the :create mutate function, + # maybe we should extract the common parts and reuse them in both places? + # For now I didn't do that because this would probably need to be changed + # to be a query instead of a mutation anyway + defp validate_update( + %{arguments: arguments, context: context} = resolution, + domain, resource, + %{ + name: mutation_name, + action: action, + 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] || %{} + + args_result = + with {:ok, input} <- handle_arguments(resource, action, input), + {:ok, read_action_input} <- + handle_arguments(resource, read_action, Map.delete(arguments, :input)) do + {:ok, input, read_action_input} + end + + case args_result do + {:ok, input, read_action_input} -> + metadata = %{ + domain: domain, + resource: resource, + resource_short_name: Ash.Resource.Info.short_name(resource), + actor: Map.get(context, :actor), + tenant: Map.get(context, :tenant), + action: action, + mutation: mutation_name, + source: :graphql, + authorize?: AshGraphql.Domain.Info.authorize?(domain) + } + + trace domain, + resource, + :gql_mutation, + mutation_name, + metadata do + filter = identity_filter(identity, resource, arguments, relay_ids?) + + case filter do + {:ok, filter} -> + resource + |> Ash.Query.do_filter(filter) + |> Ash.Query.set_tenant(Map.get(context, :tenant)) + |> Ash.Query.set_context(get_context(context)) + |> set_query_arguments(read_action, read_action_input) + |> Ash.Query.limit(1) + |> Ash.read_one() + |> case do + {:ok, fetched_resource} -> + type_name = mutation_result_type(mutation_name) + + dbg(fetched_resource) + + changeset = + fetched_resource + |> Ash.Changeset.new() + |> Ash.Changeset.set_tenant(Map.get(context, :tenant)) + |> Ash.Changeset.set_context(get_context(context) || %{}) + |> Ash.Changeset.for_update(action, input, + actor: Map.get(context, :actor), + authorize?: AshGraphql.Domain.Info.authorize?(domain) + ) + |> select_fields(resource, resolution, type_name, ["result"]) + |> load_fields( + [ + domain: domain, + tenant: Map.get(context, :tenant), + authorize?: AshGraphql.Domain.Info.authorize?(domain), + tracer: AshGraphql.Domain.Info.tracer(domain), + actor: Map.get(context, :actor) + ], + resource, + resolution, + resolution.path, + context, + mutation_result_type(mutation_name), + ["result"] + ) + + {result, modify_args} = + {{:ok, + %{ + result: nil, + errors: to_errors(changeset.errors, context, domain, resource, action) + }}, [changeset, {:ok, nil}]} + + resolution + |> Absinthe.Resolution.put_result(to_resolution(result, context, domain)) + |> add_root_errors(domain, resource, action, modify_args) + |> modify_resolution(modify, modify_args) + + {:error, error} -> + {:error, error} + end + + {:error, error} -> + Absinthe.Resolution.put_result( + resolution, + to_resolution({:error, error}, context, domain) + ) + end + end + + {:error, error} -> + {:error, error} + end + rescue + e -> + if AshGraphql.Domain.Info.show_raised_errors?(domain) do + error = Ash.Error.to_ash_error([e], __STACKTRACE__) + + if AshGraphql.Domain.Info.root_level_errors?(domain) do + Absinthe.Resolution.put_result( + resolution, + to_resolution({:error, error}, context, domain) + ) + else + Absinthe.Resolution.put_result( + resolution, + to_resolution( + {:ok, %{result: nil, errors: to_errors(error, context, domain, resource, action)}}, + context, + domain + ) + ) + end + else + something_went_wrong(resolution, e, domain, __STACKTRACE__) + end + end + + defp validate_create( + %{arguments: arguments, context: context} = resolution, + domain, resource, + %{ + name: mutation_name, + action: action, + upsert?: upsert?, + upsert_identity: upsert_identity, + modify_resolution: modify + } , _relay_ids? + ) do + input = arguments[:input] || %{} + + case handle_arguments(resource, action, input) do + {:ok, input} -> + metadata = %{ + domain: domain, + resource: resource, + resource_short_name: Ash.Resource.Info.short_name(resource), + actor: Map.get(context, :actor), + tenant: Map.get(context, :tenant), + action: action, + source: :graphql, + mutation_name: mutation_name, + authorize?: AshGraphql.Domain.Info.authorize?(domain) + } + + trace domain, + resource, + :gql_mutation, + mutation_name, + metadata do + opts = [ + actor: Map.get(context, :actor), + action: action, + authorize?: AshGraphql.Domain.Info.authorize?(domain), + tenant: Map.get(context, :tenant), + upsert?: upsert? + ] + + opts = + if upsert? && upsert_identity do + Keyword.put(opts, :upsert_identity, upsert_identity) + else + opts + end + + type_name = mutation_result_type(mutation_name) + + changeset = + resource + |> Ash.Changeset.new() + |> Ash.Changeset.set_tenant(Map.get(context, :tenant)) + |> Ash.Changeset.set_context(get_context(context)) + |> Ash.Changeset.for_create(action, input, + actor: Map.get(context, :actor), + authorize?: AshGraphql.Domain.Info.authorize?(domain) + ) + |> select_fields(resource, resolution, type_name, ["result"]) + |> load_fields( + [ + domain: domain, + tenant: Map.get(context, :tenant), + authorize?: AshGraphql.Domain.Info.authorize?(domain), + tracer: AshGraphql.Domain.Info.tracer(domain), + actor: Map.get(context, :actor) + ], + resource, + resolution, + resolution.path, + context, + type_name, + ["result"] + ) + + # TODO Remove the result somehow + + {result, modify_args} = + {{:ok, + %{ + result: nil, + errors: to_errors(changeset.errors, context, domain, resource, action) + }}, [changeset, {:ok, nil}]} + + resolution + |> Absinthe.Resolution.put_result(to_resolution(result, context, domain)) + |> add_root_errors(domain, resource, action, modify_args) + |> modify_resolution(modify, modify_args) + end + + {:error, error} -> + {:error, error} + end + rescue + e -> + if AshGraphql.Domain.Info.show_raised_errors?(domain) do + error = Ash.Error.to_ash_error([e], __STACKTRACE__) + + if AshGraphql.Domain.Info.root_level_errors?(domain) do + Absinthe.Resolution.put_result( + resolution, + to_resolution({:error, error}, context, domain) + ) + else + Absinthe.Resolution.put_result( + resolution, + to_resolution( + {:ok, %{result: nil, errors: to_errors(error, context, domain, resource, action)}}, + context, + domain + ) + ) + end + else + something_went_wrong(resolution, e, domain, __STACKTRACE__) + end + end + defp log_exception(e, stacktrace) do uuid = Ash.UUID.generate() diff --git a/lib/resource/mutation.ex b/lib/resource/mutation.ex index 72bc62de..cef32098 100644 --- a/lib/resource/mutation.ex +++ b/lib/resource/mutation.ex @@ -141,7 +141,26 @@ defmodule AshGraphql.Resource.Mutation do ] ] + @validate_schema [ + name: [ + type: :atom, + doc: "The name to use for the mutation.", + default: :get + ], + action: [ + type: :atom, + doc: "The action to use for the mutation.", + required: true + ], + description: [ + type: :string, + doc: + "The mutation description that gets shown in the Graphql schema. If not provided, the action description will be used." + ] + ] + def create_schema, do: @create_schema def update_schema, do: @update_schema def destroy_schema, do: @destroy_schema + def validate_schema, do: @validate_schema end diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 3215edaa..a761edcf 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -132,6 +132,20 @@ defmodule AshGraphql.Resource do ] } + @validate %Spark.Dsl.Entity{ + name: :validate, + schema: Mutation.validate_schema(), + args: [:name, :action], + describe: "A query to validate the update or creation of a record", + examples: [ + "validate :validate_create_post, :create" + ], + target: Mutation, + auto_set_fields: [ + type: :validate + ] + } + @update %Spark.Dsl.Entity{ name: :update, schema: Mutation.update_schema(), @@ -249,6 +263,7 @@ defmodule AshGraphql.Resource do create :create_post, :create update :update_post, :update destroy :destroy_post, :destroy + validate :validate_create_post, :create end """ ], @@ -256,11 +271,12 @@ defmodule AshGraphql.Resource do @create, @update, @destroy, - @action + @action, + @validate ] } - def mutations, do: [@create, @update, @destroy, @action] + def mutations, do: [@create, @update, @destroy, @action, @validate] @subscribe %Spark.Dsl.Entity{ name: :subscribe, @@ -812,6 +828,33 @@ defmodule AshGraphql.Resource do end %{type: :create} = mutation -> + create_mutation(resource, schema, mutation, action_middleware, domain, relay_ids?) + + %{type: :validate} = mutation -> + validate_mutation(resource, schema, mutation, action_middleware, domain, relay_ids?) + + mutation -> + update_mutation(resource, schema, mutation, schema, action_middleware, domain, relay_ids?) + end) + |> Enum.concat( + queries(domain, all_domains, resource, action_middleware, schema, relay_ids?, true) + ) + end + + defp validate_mutation(resource, schema, mutation, action_middleware, domain, relay_ids?) do + action = + Ash.Resource.Info.action(resource, mutation.action) || + raise "No such action #{mutation.action} for #{inspect(resource)}" + + case action do + %Ash.Resource.Actions.Create{} -> + create_mutation(resource, schema, mutation, action_middleware, domain, relay_ids?) + %Ash.Resource.Actions.Update{} -> + update_mutation(resource, schema, mutation, schema, action_middleware, domain, relay_ids?) + end + end + + defp create_mutation(resource, schema, mutation, action_middleware, domain, relay_ids?) do action = Ash.Resource.Info.action(resource, mutation.action) || raise "No such action #{mutation.action} for #{inspect(resource)}" @@ -857,13 +900,6 @@ defmodule AshGraphql.Resource do type: mutation_result_type(mutation.name, domain), __reference__: ref(__ENV__) } - - mutation -> - update_mutation(resource, schema, mutation, schema, action_middleware, domain, relay_ids?) - end) - |> Enum.concat( - queries(domain, all_domains, resource, action_middleware, schema, relay_ids?, true) - ) end # sobelow_skip ["DOS.StringToAtom"] @@ -1460,7 +1496,7 @@ defmodule AshGraphql.Resource do field_type = attribute.type |> field_type(attribute, resource, true) - |> maybe_wrap_non_null(explicitly_required || not allow_nil?) + |> maybe_wrap_non_null(explicitly_required || not allow_nil?, type) name = field_names[attribute.name] || attribute.name @@ -1483,12 +1519,12 @@ defmodule AshGraphql.Resource do case find_manage_change(argument, action, resource) do nil -> - type = + field_type = case AshGraphql.Resource.Info.argument_input_types(resource)[action.name][name] do nil -> argument.type |> field_type(argument, resource, true) - |> maybe_wrap_non_null(argument_required?(argument)) + |> maybe_wrap_non_null(argument_required?(argument), type) override -> unwrap_literal_type(override) @@ -1499,7 +1535,7 @@ defmodule AshGraphql.Resource do module: schema, name: to_string(name), description: argument.description, - type: type, + type: field_type, __reference__: ref(__ENV__) } @@ -1516,35 +1552,35 @@ defmodule AshGraphql.Resource do ] ) - type = + field_type = if managed.type_name do managed.type_name else default_managed_type_name(resource, action, argument) end - type = wrap_arrays(argument.type, type, argument.constraints) + field_type = wrap_arrays(argument.type, field_type, argument.constraints) %Absinthe.Blueprint.Schema.FieldDefinition{ identifier: argument.name, module: schema, name: to_string(name), description: argument.description, - type: maybe_wrap_non_null(type, argument_required?(argument)), + type: maybe_wrap_non_null(field_type, argument_required?(argument), type), __reference__: ref(__ENV__) } else - type = + field_type = argument.type |> field_type(argument, resource, true) - |> maybe_wrap_non_null(argument_required?(argument)) + |> maybe_wrap_non_null(argument_required?(argument), type) %Absinthe.Blueprint.Schema.FieldDefinition{ identifier: name, module: schema, name: to_string(name), description: Map.get(argument, :description, ""), - type: type, + type: field_type, __reference__: ref(__ENV__) } end @@ -1685,19 +1721,23 @@ defmodule AshGraphql.Resource do end end - defp maybe_wrap_non_null({:non_null, type}, true) do + defp maybe_wrap_non_null(type, non_null?, action_type \\ nil) + + defp maybe_wrap_non_null(type, _, :validate), do: type + + defp maybe_wrap_non_null({:non_null, type}, true, _) do %Absinthe.Blueprint.TypeReference.NonNull{ of_type: type } end - defp maybe_wrap_non_null(type, true) do + defp maybe_wrap_non_null(type, true, _) do %Absinthe.Blueprint.TypeReference.NonNull{ of_type: type } end - defp maybe_wrap_non_null(type, _), do: type + defp maybe_wrap_non_null(type, _, _), do: type defp get_fields(resource) do if AshGraphql.Resource.Info.encode_primary_key?(resource) do diff --git a/lib/resource/transformers/validate_actions.ex b/lib/resource/transformers/validate_actions.ex index ffbe799a..b9dbe9c7 100644 --- a/lib/resource/transformers/validate_actions.ex +++ b/lib/resource/transformers/validate_actions.ex @@ -12,27 +12,30 @@ defmodule AshGraphql.Resource.Transformers.ValidateActions do |> Transformer.get_entities([:graphql, :queries]) |> Enum.concat(Transformer.get_entities(dsl, [:graphql, :mutations])) |> Enum.each(fn query_or_mutation -> - type = + types = case query_or_mutation do %AshGraphql.Resource.Query{} -> - :read + [:read] %AshGraphql.Resource.Action{} -> - nil + [] + + %AshGraphql.Resource.Mutation{type: :validate} -> + [:create, :update] %AshGraphql.Resource.Mutation{type: type} -> - type + [type] end available_actions = Transformer.get_entities(dsl, [:actions]) || [] available_actions = - if type do + if Enum.empty?(types) do + available_actions + else Enum.filter(available_actions, fn action -> - action.type == type + action.type in types end) - else - available_actions end action = @@ -46,9 +49,9 @@ defmodule AshGraphql.Resource.Transformers.ValidateActions do raise Spark.Error.DslError, module: resource, message: """ - No such action #{query_or_mutation.action} of type #{type} on #{inspect(resource)} + No such action #{query_or_mutation.action} of types #{inspect(types)} on #{inspect(resource)} - Available #{type} actions: + Available #{inspect(types)} actions: #{Enum.map_join(available_actions, ", ", & &1.name)} """