improvement: add options for remapping field/argument names

fix: use the dataloader for loading calculations, to allow for aliases
This commit is contained in:
Zach Daniel 2022-09-26 00:12:10 -04:00
parent 9095a5ae45
commit 92631f91b6
11 changed files with 359 additions and 34 deletions

View file

@ -1,5 +1,6 @@
spark_locals_without_parens = [
allow_nil?: 1,
argument_names: 1,
as_mutation?: 1,
attribute_input_types: 1,
attribute_types: 1,
@ -10,6 +11,7 @@ spark_locals_without_parens = [
depth_limit: 1,
destroy: 2,
destroy: 3,
field_names: 1,
generate_object?: 1,
get: 2,
get: 3,

View file

@ -122,7 +122,8 @@ defmodule AshGraphql.Dataloader do
"""
end
defp get_keys({assoc_field, opts}, %resource{} = record) when is_atom(assoc_field) do
defp get_keys({assoc_field, %{type: :relationship} = opts}, %resource{} = record)
when is_atom(assoc_field) do
validate_resource(resource)
pkey = Ash.Resource.Info.primary_key(resource)
id = Enum.map(pkey, &Map.get(record, &1))
@ -132,11 +133,18 @@ defmodule AshGraphql.Dataloader do
{{:assoc, resource, self(), assoc_field, queryable, opts}, id, record}
end
defp get_keys({calc, %{type: :calculation} = opts}, %resource{} = record) do
validate_resource(resource)
pkey = Ash.Resource.Info.primary_key(resource)
id = Enum.map(pkey, &Map.get(record, &1))
{{:calc, resource, self(), calc, opts}, id, record}
end
defp get_keys(key, item) do
raise """
Invalid: #{inspect(key)}
Invalid batch key: #{inspect(key)}
#{inspect(item)}
The batch key must either be a schema module, or an association name.
"""
end
@ -232,6 +240,20 @@ defmodule AshGraphql.Dataloader do
{key, Map.new(Enum.zip(ids, results))}
end
defp run_batch(
{{:calc, _, _pid, calc, %{args: args, api_opts: api_opts}} = key, records},
source
) do
{ids, records} = Enum.unzip(records)
results =
records
|> source.api.load!([{calc, args}], api_opts)
|> Enum.map(&Map.get(&1, calc))
{key, Map.new(Enum.zip(ids, results))}
end
defp tenant_from_records([%{__metadata__: %{tenant: tenant}}]) when not is_nil(tenant) do
tenant
end

View file

@ -774,8 +774,7 @@ defmodule AshGraphql.Graphql.Resolver do
|> fields(["result"])
|> names_only()
|> Enum.map(fn identifier ->
Ash.Resource.Info.aggregate(resource, identifier) ||
Ash.Resource.Info.calculation(resource, identifier)
Ash.Resource.Info.aggregate(resource, identifier)
end)
|> Enum.filter(& &1)
|> Enum.map(& &1.name)
@ -793,12 +792,6 @@ defmodule AshGraphql.Graphql.Resolver do
if aggregate do
aggregate.name
else
calculation = Ash.Resource.Info.calculation(resource, selection.schema_node.identifier)
if calculation do
{calculation.name, selection.argument_data || %{}}
end
end
end)
|> Enum.filter(& &1)
@ -1035,6 +1028,29 @@ defmodule AshGraphql.Graphql.Resolver do
end)
end
def resolve_calculation(
%{source: parent, arguments: args, context: %{loader: loader} = context} = resolution,
{api, _resource, calculation}
) do
api_opts = [
actor: Map.get(context, :actor),
authorize?: AshGraphql.Api.Info.authorize?(api),
verbose?: AshGraphql.Api.Info.debug?(api),
stacktraces?: AshGraphql.Api.Info.debug?(api) || AshGraphql.Api.Info.stacktraces?(api)
]
opts = [
api_opts: api_opts,
type: :calculation,
args: args,
tenant: Map.get(context, :tenant)
]
batch_key = {calculation.name, opts}
do_dataloader(resolution, loader, api, batch_key, opts, parent)
end
def resolve_assoc(
%{source: parent, arguments: args, context: %{loader: loader} = context} = resolution,
{api, relationship}
@ -1057,11 +1073,13 @@ defmodule AshGraphql.Graphql.Resolver do
opts = [
query: related_query,
api_opts: api_opts,
type: :relationship,
args: args,
resource: relationship.source,
tenant: Map.get(context, :tenant)
]
{batch_key, parent} = {{relationship.name, opts}, parent}
batch_key = {relationship.name, opts}
do_dataloader(resolution, loader, api, batch_key, args, parent)
{:error, error} ->

View file

@ -28,6 +28,16 @@ defmodule AshGraphql.Resource.Info do
Extension.get_opt(resource, [:graphql], :attribute_types, [])
end
@doc "Graphql field name (attribute/relationship/calculation/arguments) overrides for the resource"
def field_names(resource) do
Extension.get_opt(resource, [:graphql], :field_names, [])
end
@doc "Graphql argument name overrides for the resource"
def argument_names(resource) do
Extension.get_opt(resource, [:graphql], :argument_names, [])
end
@doc "Graphql type overrides for the resource"
def attribute_input_types(resource) do
Extension.get_opt(resource, [:graphql], :attribute_input_types, [])

View file

@ -1,6 +1,14 @@
defmodule AshGraphql.Resource.Mutation do
@moduledoc "Represents a configured mutation on a resource"
defstruct [:name, :action, :type, :identity, :read_action, :upsert?, :modify_resolution]
defstruct [
:name,
:action,
:type,
:identity,
:read_action,
:upsert?,
:modify_resolution
]
@create_schema [
name: [

View file

@ -222,6 +222,15 @@ defmodule AshGraphql.Resource do
required: true,
doc: "The type to use for this entity in the graphql schema"
],
field_names: [
type: :keyword_list,
doc: "A keyword list of name overrides for attributes."
],
argument_names: [
type: :keyword_list,
doc:
"A nested keyword list of action names, to argument name remappings. i.e `create: [arg_name: :new_name]`"
],
attribute_types: [
type: :keyword_list,
doc:
@ -259,7 +268,8 @@ defmodule AshGraphql.Resource do
@transformers [
AshGraphql.Resource.Transformers.RequireIdPkey,
AshGraphql.Resource.Transformers.ValidateActions
AshGraphql.Resource.Transformers.ValidateActions,
AshGraphql.Resource.Transformers.ValidateCompatibleNames
]
@sections [@graphql]
@ -705,6 +715,9 @@ defmodule AshGraphql.Resource do
&(&1.action == action.name)
)
field_names = AshGraphql.Resource.Info.field_names(resource)
argument_names = AshGraphql.Resource.Info.argument_names(resource)
attribute_fields =
if action.type == :destroy && !action.soft? do
[]
@ -727,11 +740,13 @@ defmodule AshGraphql.Resource do
|> field_type(attribute, resource, true)
|> maybe_wrap_non_null(explicitly_required || not allow_nil?)
name = field_names[attribute.name] || attribute.name
%Absinthe.Blueprint.Schema.FieldDefinition{
description: attribute.description,
identifier: attribute.name,
module: schema,
name: to_string(attribute.name),
name: to_string(name),
type: field_type,
__reference__: ref(__ENV__)
}
@ -742,6 +757,8 @@ defmodule AshGraphql.Resource do
action.arguments
|> Enum.reject(& &1.private?)
|> Enum.map(fn argument ->
name = argument_names[action.name][argument.name] || argument.name
case find_manage_change(argument, action, managed_relationships) do
nil ->
type =
@ -752,7 +769,7 @@ defmodule AshGraphql.Resource do
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: argument.name,
module: schema,
name: to_string(argument.name),
name: to_string(name),
type: type,
__reference__: ref(__ENV__)
}
@ -772,7 +789,7 @@ defmodule AshGraphql.Resource do
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: argument.name,
module: schema,
name: to_string(argument.name),
name: to_string(name),
type: maybe_wrap_non_null(type, argument_required?(argument)),
__reference__: ref(__ENV__)
}
@ -839,6 +856,12 @@ defmodule AshGraphql.Resource do
maybe_wrap_non_null(type, not query.allow_nil?)
end
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
%Absinthe.Blueprint.TypeReference.NonNull{
of_type: type
@ -1007,17 +1030,21 @@ defmodule AshGraphql.Resource do
# sobelow_skip ["DOS.StringToAtom"]
defp attribute_filter_field_type(resource, attribute) do
field_names = AshGraphql.Resource.Info.field_names(resource)
String.to_atom(
to_string(AshGraphql.Resource.Info.type(resource)) <>
"_filter_" <> to_string(attribute.name)
"_filter_" <> to_string(field_names[attribute.name] || attribute.name)
)
end
# sobelow_skip ["DOS.StringToAtom"]
defp calculation_filter_field_type(resource, calculation) do
field_names = AshGraphql.Resource.Info.field_names(resource)
String.to_atom(
to_string(AshGraphql.Resource.Info.type(resource)) <>
"_filter_" <> to_string(calculation.name)
"_filter_" <> to_string(field_names[calculation.name] || calculation.name)
)
end
@ -1856,6 +1883,8 @@ defmodule AshGraphql.Resource do
end
defp attribute_filter_fields(resource, schema) do
field_names = AshGraphql.Resource.Info.field_names(resource)
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.reject(fn
@ -1870,7 +1899,7 @@ defmodule AshGraphql.Resource do
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: attribute.name,
module: schema,
name: to_string(attribute.name),
name: to_string(field_names[attribute.name] || attribute.name),
type: attribute_filter_field_type(resource, attribute),
__reference__: ref(__ENV__)
}
@ -1879,6 +1908,8 @@ defmodule AshGraphql.Resource do
end
defp aggregate_filter_fields(resource, schema) do
field_names = AshGraphql.Resource.Info.field_names(resource)
if Ash.DataLayer.data_layer_can?(resource, :aggregate_filter) do
resource
|> Ash.Resource.Info.public_aggregates()
@ -1887,7 +1918,7 @@ defmodule AshGraphql.Resource do
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: aggregate.name,
module: schema,
name: to_string(aggregate.name),
name: to_string(field_names[aggregate.name] || aggregate.name),
type: attribute_filter_field_type(resource, aggregate),
__reference__: ref(__ENV__)
}
@ -1899,6 +1930,8 @@ defmodule AshGraphql.Resource do
end
defp calculation_filter_fields(resource, schema) do
field_names = AshGraphql.Resource.Info.field_names(resource)
if Ash.DataLayer.data_layer_can?(resource, :expression_calculation) do
resource
|> Ash.Resource.Info.public_calculations()
@ -1909,7 +1942,7 @@ defmodule AshGraphql.Resource do
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: calculation.name,
module: schema,
name: to_string(calculation.name),
name: to_string(field_names[calculation.name] || calculation.name),
type: calculation_filter_field_type(resource, calculation),
__reference__: ref(__ENV__)
}
@ -1920,6 +1953,8 @@ defmodule AshGraphql.Resource do
end
defp relationship_filter_fields(resource, schema) do
field_names = AshGraphql.Resource.Info.field_names(resource)
resource
|> Ash.Resource.Info.public_relationships()
|> Enum.filter(fn relationship ->
@ -1929,7 +1964,7 @@ defmodule AshGraphql.Resource do
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
name: to_string(field_names[relationship.name] || relationship.name),
type: resource_filter_type(relationship.destination),
__reference__: ref(__ENV__)
}
@ -2045,6 +2080,8 @@ defmodule AshGraphql.Resource do
defp unnest(other), do: other
defp sort_values(resource) do
field_names = AshGraphql.Resource.Info.field_names(resource)
attribute_sort_values =
resource
|> Ash.Resource.Info.public_attributes()
@ -2082,7 +2119,12 @@ defmodule AshGraphql.Resource do
end)
|> Enum.map(& &1.name)
attribute_sort_values ++ aggregate_sort_values
attribute_sort_values
|> Enum.concat(aggregate_sort_values)
|> Enum.map(fn name ->
field_names[name] || name
end)
|> Enum.uniq()
end
# sobelow_skip ["DOS.StringToAtom"]
@ -2241,10 +2283,12 @@ defmodule AshGraphql.Resource do
attributes(resource, schema) ++
relationships(resource, api, schema) ++
aggregates(resource, schema) ++
calculations(resource, schema)
calculations(resource, api, schema)
end
defp attributes(resource, schema) do
attribute_names = AshGraphql.Resource.Info.field_names(resource)
non_id_attributes =
resource
|> Ash.Resource.Info.public_attributes()
@ -2255,11 +2299,13 @@ defmodule AshGraphql.Resource do
|> field_type(attribute, resource)
|> maybe_wrap_non_null(not attribute.allow_nil?)
name = attribute_names[attribute.name] || attribute.name
%Absinthe.Blueprint.Schema.FieldDefinition{
description: attribute.description,
identifier: attribute.name,
module: schema,
name: to_string(attribute.name),
name: to_string(name),
type: field_type,
__reference__: ref(__ENV__)
}
@ -2376,6 +2422,8 @@ defmodule AshGraphql.Resource do
# sobelow_skip ["DOS.StringToAtom"]
defp relationships(resource, api, schema) do
field_names = AshGraphql.Resource.Info.field_names(resource)
resource
|> Ash.Resource.Info.public_relationships()
|> Enum.filter(fn relationship ->
@ -2383,6 +2431,8 @@ defmodule AshGraphql.Resource do
end)
|> Enum.map(fn
%{cardinality: :one} = relationship ->
name = field_names[relationship.name] || relationship.name
type =
relationship.destination
|> AshGraphql.Resource.Info.type()
@ -2391,7 +2441,7 @@ defmodule AshGraphql.Resource do
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
name: to_string(name),
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve_assoc}, {api, relationship}}
],
@ -2401,6 +2451,7 @@ defmodule AshGraphql.Resource do
}
%{cardinality: :many} = relationship ->
name = field_names[relationship.name] || relationship.name
read_action = Ash.Resource.Info.primary_action!(relationship.destination, :read)
type = AshGraphql.Resource.Info.type(relationship.destination)
@ -2416,7 +2467,7 @@ defmodule AshGraphql.Resource do
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
name: to_string(name),
complexity: {AshGraphql.Graphql.Resolver, :query_complexity},
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve_assoc}, {api, relationship}}
@ -2429,9 +2480,13 @@ defmodule AshGraphql.Resource do
end
defp aggregates(resource, schema) do
field_names = AshGraphql.Resource.Info.field_names(resource)
resource
|> Ash.Resource.Info.public_aggregates()
|> Enum.map(fn aggregate ->
name = field_names[aggregate.name] || aggregate.name
field_type =
with field when not is_nil(field) <- aggregate.field,
related when not is_nil(related) <-
@ -2454,17 +2509,20 @@ defmodule AshGraphql.Resource do
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: aggregate.name,
module: schema,
name: to_string(aggregate.name),
name: to_string(name),
type: type,
__reference__: ref(__ENV__)
}
end)
end
defp calculations(resource, schema) do
defp calculations(resource, api, schema) do
field_names = AshGraphql.Resource.Info.field_names(resource)
resource
|> Ash.Resource.Info.public_calculations()
|> Enum.map(fn calculation ->
name = field_names[calculation.name] || calculation.name
field_type = calculation_type(calculation, resource)
arguments = calculation_args(calculation, resource, schema)
@ -2474,7 +2532,10 @@ defmodule AshGraphql.Resource do
module: schema,
arguments: arguments,
complexity: 2,
name: to_string(calculation.name),
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve_calculation}, {api, resource, calculation}}
],
name: to_string(name),
type: field_type,
__reference__: ref(__ENV__)
}
@ -2683,11 +2744,13 @@ defmodule AshGraphql.Resource do
# sobelow_skip ["DOS.StringToAtom"]
defp atom_enum_type(resource, attribute_name) do
field_names = AshGraphql.Resource.Info.field_names(resource)
resource
|> AshGraphql.Resource.Info.type()
|> to_string()
|> Kernel.<>("_")
|> Kernel.<>(to_string(attribute_name))
|> Kernel.<>(to_string(field_names[attribute_name] || attribute_name))
|> String.to_atom()
end
end

View file

@ -31,7 +31,7 @@ defmodule AshGraphql.Resource.Transformers.ValidateActions do
resource = Transformer.get_persisted(dsl, :module)
raise Spark.Error.DslError,
module: __MODULE__,
module: resource,
message: """
No such action #{query_or_mutation.action} of type #{type} on #{inspect(resource)}

View file

@ -0,0 +1,119 @@
defmodule AshGraphql.Resource.Transformers.ValidateCompatibleNames do
@moduledoc "Ensures that all field names are valid or remapped to something valid exist"
use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer
def after_compile?, do: true
def transform(dsl) do
field_names = AshGraphql.Resource.Info.field_names(dsl)
argument_names = AshGraphql.Resource.Info.argument_names(dsl)
resource = Transformer.get_persisted(dsl, :module)
dsl
|> Ash.Resource.Info.public_attributes()
|> Enum.concat(Ash.Resource.Info.public_aggregates(dsl))
|> Enum.concat(Ash.Resource.Info.public_calculations(dsl))
|> Enum.concat(Ash.Resource.Info.public_relationships(dsl))
|> Enum.each(fn field ->
name = field_names[field.name] || field.name
if invalid_name?(name) do
raise_invalid_name_error(resource, field, name)
end
end)
dsl
|> Transformer.get_entities([:graphql, :queries])
|> Enum.concat(Transformer.get_entities(dsl, [:graphql, :mutations]))
|> Enum.map(& &1.action)
|> Enum.uniq()
|> Enum.each(fn action ->
action = Ash.Resource.Info.action(dsl, action)
Enum.each(action.arguments, fn argument ->
name = argument_names[action.name][argument.name] || argument.name
if invalid_name?(name) do
raise_invalid_argument_name_error(resource, action, argument.name, name)
end
end)
end)
{:ok, dsl}
end
defp invalid_name?(name) do
Regex.match?(~r/_+\d/, to_string(name))
end
defp raise_invalid_name_error(resource, field, name) do
path =
case field do
%Ash.Resource.Relationships.BelongsTo{} -> [:relationships, :belongs_to, field.name]
%Ash.Resource.Relationships.HasMany{} -> [:relationships, :has_many, field.name]
%Ash.Resource.Relationships.HasOne{} -> [:relationships, :has_one, field.name]
%Ash.Resource.Relationships.ManyToMany{} -> [:relationships, :many_to_many, field.name]
%Ash.Resource.Calculation{} -> [:calculations, field.name]
%Ash.Resource.Aggregate{} -> [:aggregates, field.name]
%Ash.Resource.Attribute{} -> [:attributes, field.name]
end
raise Spark.Error.DslError,
module: resource,
path: path,
message: """
Name #{name} is invalid.
Due to issues in the underlying tooling with camel/snake case conversion of names that
include underscores immediately preceding integers, a different name must be provided to
use in the graphql. To do so, add a mapping in your configured field_names, i.e
graphql do
...
field_names #{name}: :#{make_name_better(name)}
...
end
For more information on the underlying issue, see: https://github.com/absinthe-graphql/absinthe/issues/601
"""
end
defp raise_invalid_argument_name_error(resource, action, argument_name, name) do
path = [:actions, action.type, action.name, :argument, argument_name]
raise Spark.Error.DslError,
module: resource,
path: path,
message: """
Name #{name} is invalid.
Due to issues in the underlying tooling with camel/snake case conversion of names that
include underscores immediately preceding integers, a different name must be provided to
use in the graphql. To do so, add a mapping in your configured argument_names, i.e
graphql do
...
argument_names #{action.name}: [#{argument_name}: :#{make_name_better(name)}]
...
end
For more information on the underlying issue, see: https://github.com/absinthe-graphql/absinthe/issues/601
"""
end
defp make_name_better(name) do
name
|> to_string()
|> String.replace(~r/_+\d/, fn v ->
String.trim_leading(v, "_")
end)
end
end

View file

@ -115,7 +115,8 @@ defmodule AshGraphql.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:ash, ash_version("~> 2.0.0-rc.9")},
{:ash,
ash_version(github: "ash-project/ash", ref: "fe596db0b86ea58701e00523a0be408487bb8f27")},
{:absinthe_plug, "~> 1.4"},
{:absinthe, "~> 1.7"},
{:dataloader, "~> 1.0"},

View file

@ -6,6 +6,7 @@ defmodule AshGraphql.ReadTest do
setup do
on_exit(fn ->
Ash.DataLayer.Ets.stop(AshGraphql.Test.Post)
Ash.DataLayer.Ets.stop(AshGraphql.Test.Comment)
end)
end
@ -125,6 +126,56 @@ defmodule AshGraphql.ReadTest do
result
end
test "the same relationship can be fetched with different parameters" do
post =
AshGraphql.Test.Post
|> Ash.Changeset.for_create(:create, text: "foo", published: true)
|> AshGraphql.Test.Api.create!()
for _ <- 0..1 do
AshGraphql.Test.Comment
|> Ash.Changeset.for_create(:create, %{text: "stuff"})
|> Ash.Changeset.force_change_attribute(:post_id, post.id)
|> AshGraphql.Test.Api.create!()
end
resp =
"""
query PostLibrary($published: Boolean) {
postLibrary(published: $published) {
text
foo: comments(limit: 1){
text
}
bar: comments(limit: 2){
text
}
}
}
"""
|> Absinthe.run(AshGraphql.Test.Schema,
variables: %{
"published" => true
}
)
assert {:ok, result} = resp
refute Map.has_key?(result, :errors)
assert %{
data: %{
"postLibrary" => [
%{
"text" => "foo",
"foo" => [%{"text" => "stuff"}],
"bar" => [%{"text" => "stuff"}, %{"text" => "stuff"}]
}
]
}
} = result
end
test "complexity is calculated for relationships" do
query = """
query PostLibrary {
@ -198,6 +249,29 @@ defmodule AshGraphql.ReadTest do
result
end
test "the same calculation can be loaded twice with different arguments via aliases" do
AshGraphql.Test.Post
|> Ash.Changeset.for_create(:create, text: "bar", text1: "1", text2: "2", published: true)
|> AshGraphql.Test.Api.create!()
resp =
"""
query PostLibrary($published: Boolean) {
postLibrary(published: $published) {
foo: text1And2(separator: "foo")
bar: text1And2(separator: "bar")
}
}
"""
|> Absinthe.run(AshGraphql.Test.Schema)
assert {:ok, result} = resp
refute Map.has_key?(result, :errors)
assert %{data: %{"postLibrary" => [%{"foo" => "1foo2", "bar" => "1bar2"}]}} = result
end
test "a read with a non-id primary key fills in the id field" do
record =
AshGraphql.Test.NonIdPrimaryKey

View file

@ -54,6 +54,7 @@ defmodule AshGraphql.Test.Post do
attribute_types integer_as_string_in_api: :string
attribute_input_types integer_as_string_in_api: :string
field_names(text_1_and_2: :text1_and2)
queries do
get :get_post, :read
@ -208,6 +209,13 @@ defmodule AshGraphql.Test.Post do
calculations do
calculate(:static_calculation, :string, AshGraphql.Test.StaticCalculation)
calculate(:full_text, :string, FullTextCalculation)
calculate(:text_1_and_2, :string, expr(text1 <> ^arg(:separator) <> text2)) do
argument :separator, :string do
allow_nil? false
default(" ")
end
end
end
relationships do