improvement: support metadata on read actions

metadata on read actions is merged with the rest of the fields on the query, so must have a name unique from the attributes/calculations/aggregates.

The system will warn you if there is metadata on the underlying action that is being ignored, and will tell you how to fix it.
This commit is contained in:
Zach Daniel 2023-01-28 12:32:21 -05:00
parent 839bdcb126
commit f0836f18b6
8 changed files with 280 additions and 64 deletions

View file

@ -95,7 +95,14 @@ defmodule AshGraphql.Graphql.Resolver do
{result, _} ->
resolution
|> Absinthe.Resolution.put_result(to_resolution(result, context, api))
|> Absinthe.Resolution.put_result(
to_resolution(
result
|> add_read_metadata(gql_query, Ash.Resource.Info.action(query.resource, action)),
context,
api
)
)
|> add_root_errors(api, result)
|> modify_resolution(modify, modify_args)
end
@ -113,7 +120,8 @@ 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}}
%{name: query_name, type: :read_one, action: action, modify_resolution: modify} =
gql_query}
) do
metadata = %{
api: api,
@ -171,6 +179,9 @@ defmodule AshGraphql.Graphql.Resolver do
{{:error, error}, [query, {:error, error}]}
end
result =
add_read_metadata(result, gql_query, Ash.Resource.Info.action(query.resource, action))
resolution
|> Absinthe.Resolution.put_result(to_resolution(result, context, api))
|> add_root_errors(api, result)
@ -195,7 +206,7 @@ defmodule AshGraphql.Graphql.Resolver do
relay?: relay?,
action: action,
modify_resolution: modify
}}
} = gql_query}
) do
metadata = %{
api: api,
@ -247,6 +258,9 @@ defmodule AshGraphql.Graphql.Resolver do
{{:error, error}, [query, {:error, error}]}
end
result =
add_read_metadata(result, gql_query, Ash.Resource.Info.action(query.resource, action))
resolution
|> Absinthe.Resolution.put_result(to_resolution(result, context, api))
|> add_root_errors(api, modify_args)
@ -1187,6 +1201,32 @@ defmodule AshGraphql.Graphql.Resolver do
end
end
defp add_read_metadata({:ok, result}, query, action) do
{:ok, add_read_metadata(result, query, action)}
end
defp add_read_metadata(nil, _, _), do: nil
defp add_read_metadata(result, query, action) when is_list(result) do
show_metadata = query.show_metadata || Enum.map(Map.get(action, :metadata, []), & &1.name)
Enum.map(result, fn record ->
do_add_read_metadata(record, show_metadata)
end)
end
defp add_read_metadata(result, query, action) do
show_metadata = query.show_metadata || Enum.map(Map.get(action, :metadata, []), & &1.name)
do_add_read_metadata(result, show_metadata)
end
defp do_add_read_metadata(record, show_metadata) do
Enum.reduce(show_metadata, record, fn key, record ->
Map.put(record, key, Map.get(record.__metadata__ || %{}, key))
end)
end
defp add_metadata(result, action_result, action) do
metadata = Map.get(action, :metadata, [])

View file

@ -8,10 +8,14 @@ defmodule AshGraphql.Resource.Query do
:allow_nil?,
:modify_resolution,
as_mutation?: false,
metadata_names: [],
metadata_types: [],
show_metadata: nil,
type_name: nil,
relay?: false
]
@get_schema [
@query_schema [
name: [
type: :atom,
doc: "The name to use for the query.",
@ -22,9 +26,50 @@ defmodule AshGraphql.Resource.Query do
doc: "The action to use for the query.",
required: true
],
type_name: [
type: :atom,
doc: """
Override the type name returned by this query. Must be set if the read action has `metadata`.
To ignore any action metadata, set this to the same type the resource uses, or set `show_metadata` to `[]`.
To show metadata in the response, choose a new name here, like `:user_with_token` to get a response type that
includes the additional fields.
"""
],
metadata_names: [
type: :keyword_list,
default: [],
doc: "Name overrides for metadata fields on the read action."
],
metadata_types: [
type: :keyword_list,
default: [],
doc: "Type overrides for metadata fields on the read action."
],
show_metadata: [
type: {:list, :atom},
doc: "The metadata attributes to show. Defaults to all."
],
as_mutation?: [
type: :boolean,
default: false,
doc: """
Places the query in the `mutations` key instead. The use cases for this are likely very minimal.
If you have a query that needs to modify the graphql context using `modify_resolution`, then you
should likely set this as well. A simple example might be a `log_in`, which could be a read
action on the user that accepts an email/password, and should then set some context in the graphql
inside of `modify_resolution`. Once in the context, you can see the guide referenced in `modify_resolution`
for more on setting the session or a cookie with an auth token.
"""
]
]
@get_schema [
identity: [
type: :atom,
doc: "The identity to use for looking up the record. Pass `false` to not use an identity.",
doc:
"The identity to use for looking up the record. Pass `false` to not use an identity.",
required: false
],
allow_nil?: [
@ -42,51 +87,20 @@ defmodule AshGraphql.Resource.Query do
*Important* if you are modifying the context, then you should also set `as_mutation?` to true and represent
this in your graphql as a mutation. See `as_mutation?` for more.
"""
],
as_mutation?: [
type: :boolean,
default: false,
doc: """
Places the query in the `mutations` key instead. The use cases for this are likely very minimal.
If you have a query that needs to modify the graphql context using `modify_resolution`, then you
should likely set this as well. A simple example might be a `log_in`, which could be a read
action on the user that accepts an email/password, and should then set some context in the graphql
inside of `modify_resolution`. Once in the context, you can see the guide referenced in `modify_resolution`
for more on setting the session or a cookie with an auth token.
"""
]
]
|> Spark.OptionsHelpers.merge_schemas(@query_schema, "Shared Query Options")
@read_one_schema [
name: [
type: :atom,
doc: "The name to use for the query.",
default: :read_one
],
action: [
type: :atom,
doc: "The action to use for the query.",
required: true
],
allow_nil?: [
type: :boolean,
default: true,
doc: "Whether or not the action can return nil."
]
]
|> Spark.OptionsHelpers.merge_schemas(@query_schema, "Shared Query Options")
@list_schema [
name: [
type: :atom,
doc: "The name to use for the query.",
default: :list
],
action: [
type: :atom,
doc: "The action to use for the query.",
required: true
],
relay?: [
type: :boolean,
default: false,
@ -100,6 +114,7 @@ defmodule AshGraphql.Resource.Query do
"""
]
]
|> Spark.OptionsHelpers.merge_schemas(@query_schema, "Shared Query Options")
def get_schema, do: @get_schema
def read_one_schema, do: @read_one_schema

View file

@ -294,6 +294,10 @@ defmodule AshGraphql.Resource do
AshGraphql.Resource.Transformers.ValidateCompatibleNames
]
@verifiers [
AshGraphql.Resource.Verifiers.VerifyQueryMetadata
]
@sections [@graphql]
@moduledoc """
@ -313,7 +317,7 @@ defmodule AshGraphql.Resource do
<!--- ash-hq-hide-stop --> <!--- -->
"""
use Spark.Dsl.Extension, sections: @sections, transformers: @transformers
use Spark.Dsl.Extension, sections: @sections, transformers: @transformers, verifiers: @verifiers
@deprecated "See `AshGraphql.Resource.Info.queries/1`"
defdelegate queries(resource), to: AshGraphql.Resource.Info
@ -866,7 +870,9 @@ defmodule AshGraphql.Resource do
end
# sobelow_skip ["DOS.StringToAtom"]
defp query_type(%{type: :list, relay?: relay?}, _resource, action, type) do
defp query_type(%{type: :list, relay?: relay?} = query, _resource, action, type) do
type = query.type_name || type
if action.pagination do
cond do
relay? ->
@ -890,6 +896,8 @@ defmodule AshGraphql.Resource do
end
defp query_type(query, _resource, _action, type) do
type = query.type_name || type
maybe_wrap_non_null(type, not query.allow_nil?)
end
@ -1197,6 +1205,7 @@ defmodule AshGraphql.Resource do
def type_definitions(resource, api, schema) do
List.wrap(calculation_input(resource, schema)) ++
List.wrap(type_definition(resource, api, schema)) ++
List.wrap(query_type_definitions(resource, api, schema)) ++
List.wrap(sort_input(resource, schema)) ++
List.wrap(filter_input(resource, schema)) ++
filter_field_types(resource, schema) ++
@ -2474,6 +2483,42 @@ defmodule AshGraphql.Resource do
type.identifier == :node
end
def query_type_definitions(resource, api, schema) do
resource_type = AshGraphql.Resource.Info.type(resource)
resource
|> AshGraphql.Resource.Info.queries()
|> Enum.filter(&(&1.type_name && &1.type_name != resource_type))
|> Enum.map(fn query ->
relay? = Map.get(query, :relay?)
interfaces =
if relay? 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),
identifier: query.type_name,
module: schema,
name: Macro.camelize(to_string(query.type_name)),
__reference__: ref(__ENV__),
is_type_of: is_type_of
}
end)
end
def type_definition(resource, api, schema) do
if generate_object?(resource) do
type = AshGraphql.Resource.Info.type(resource)
@ -2510,14 +2555,48 @@ defmodule AshGraphql.Resource do
end
end
defp fields(resource, api, schema) do
defp fields(resource, api, schema, query \\ nil) do
attributes(resource, schema) ++
metadata(query, resource, schema) ++
relationships(resource, api, schema) ++
aggregates(resource, schema) ++
calculations(resource, api, schema) ++
keyset(resource, schema)
end
defp metadata(nil, _resource, _schema) do
[]
end
defp metadata(query, resource, schema) do
action = Ash.Resource.Info.action(resource, query.action)
show_metadata = query.show_metadata || Enum.map(Map.get(action, :metadata, []), & &1.name)
action.metadata
|> Enum.filter(&(&1.name in show_metadata))
|> Enum.map(fn metadata ->
field_type =
case query.metadata_types[metadata.name] do
nil ->
metadata.type
|> field_type(metadata, resource)
|> maybe_wrap_non_null(not metadata.allow_nil?)
type ->
unwrap_literal_type(type)
end
%Absinthe.Blueprint.Schema.FieldDefinition{
description: metadata.description,
identifier: metadata.name,
module: schema,
name: to_string(query.metadata_names[metadata.name] || metadata.name),
type: field_type,
__reference__: ref(__ENV__)
}
end)
end
defp keyset(resource, schema) do
case AshGraphql.Resource.Info.keyset_field(resource) do
nil ->

View file

@ -0,0 +1,41 @@
defmodule AshGraphql.Resource.Verifiers.VerifyQueryMetadata do
@moduledoc "Ensures that queries for actions with metadata have a type set"
use Spark.Dsl.Verifier
alias Spark.Dsl.Transformer
def verify(dsl) do
dsl
|> AshGraphql.Resource.Info.queries()
|> Enum.each(fn query ->
action = Ash.Resource.Info.action(dsl, query.action)
show_metadata = query.show_metadata || Enum.map(Map.get(action, :metadata, []), & &1.name)
metadata =
action
|> Map.get(:metadata, [])
|> Enum.filter(&(&1.name in show_metadata))
if action && !Enum.empty?(metadata) && is_nil(query.type_name) do
resource = Transformer.get_persisted(dsl, :module)
raise Spark.Error.DslError,
module: resource,
message: """
Queries for actions with metadata must have a type configured on the query.
The #{query.action} action on #{inspect(resource)} has the following metadata fields:
#{Enum.map_join(action.metadata, "\n", &"* #{&1.name}")}
To generate a new type and include the metadata in that type, provide a new type
name, for example `type :user_with_token`.
To ignore the generated metadata, use the same type as the default.
"""
end
end)
:ok
end
end

View file

@ -134,7 +134,7 @@ defmodule AshGraphql.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:ash, ash_version("~> 2.4")},
{:ash, ash_version("~> 2.5.11")},
{:absinthe_plug, "~> 1.4"},
{:absinthe, "~> 1.7"},
{:dataloader, "~> 1.0"},

View file

@ -1,7 +1,7 @@
%{
"absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.5", "be913e77df1947ffb654a1cf1a90e28d84dc23241f6404053750bae513ccd52b", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6c366615d9422444774206aff3448bb9cfb4e849e0c9a94a275085097bc67509"},
"ash": {:hex, :ash, "2.5.4", "fc93280ee500ec1d06df9c18a47dba963c90cacc9f3b43a6de2d04489f5408a1", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 0.3.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a0196dc73d1b9dcd4fc4e59ba1359e42cfa0252b06fc4a23a043f82c5b5f3d19"},
"ash": {:hex, :ash, "2.5.11", "eeadeba560681d53be5438a15434c121f0dc8b108cdf05e975a40fe809865df4", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 0.3.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf2b5e337650b7bdc805af2a6038f5126a344c0439320606f5f8db9e11962071"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"},
@ -36,7 +36,7 @@
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
"sobelow": {:hex, :sobelow, "0.11.0", "cdc17e3a9f1ea78dc55dbe0a03121cb6767fef737c6d9f1e62ee7e78730abccc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c57807bfe6f231338b657781f89ef0320b66a0dbe779aa911d6ed27cfa14ae6e"},
"sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"},
"spark": {:hex, :spark, "0.3.5", "99905e681156050a713218e2b57956870b88b660dff57e7ee061b0245fc5dd50", [:mix], [{:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f272fe581f37123bf90568974937dbc18ce575fc43580a5a658965b35b993520"},
"spark": {:hex, :spark, "0.3.8", "002b2855be1bc37c4c31be49cb409f08626606c71c97615e6ce57c3ab3da654a", [:mix], [{:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "78e7491bf18ef2c2ac43684c69f0e8c4b4447ac5af3ad66e6448a9d9e0f5c411"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},

View file

@ -38,6 +38,29 @@ defmodule AshGraphql.ReadTest do
assert %{data: %{"postScore" => [%{"text" => "foo"}]}} = result
end
test "metadata fields are rendered" do
AshGraphql.Test.User
|> Ash.Changeset.for_create(:create,
name: "My Name"
)
|> AshGraphql.Test.Api.create!()
resp =
"""
query CurrentUserWithMetadata {
currentUserWithMetadata {
bar
}
}
"""
|> Absinthe.run(AshGraphql.Test.Schema)
assert {:ok, result} = resp
refute Map.has_key?(result, :errors)
assert %{data: %{"currentUserWithMetadata" => %{"bar" => "bar"}}} = result
end
test "a read with arguments works" do
AshGraphql.Test.Post
|> Ash.Changeset.for_create(:create, text: "foo", published: true)

View file

@ -10,7 +10,12 @@ defmodule AshGraphql.Test.User do
type :user
queries do
read_one :current_user, :current_user
read_one(:current_user, :current_user)
read_one :current_user_with_metadata, :current_user_with_metadata do
type_name :user_with_bar
metadata_names(foo: :bar)
end
end
mutations do
@ -27,6 +32,19 @@ defmodule AshGraphql.Test.User do
read :current_user do
filter(id: actor(:id))
end
read :current_user_with_metadata do
metadata(:foo, :string)
prepare(fn query, _ ->
Ash.Query.after_action(query, fn _query, results ->
{:ok,
Enum.map(results, fn result ->
Ash.Resource.put_metadata(result, :foo, "bar")
end)}
end)
end)
end
end
attributes do