ash_graphql/lib/ash_graphql.ex
2024-08-16 14:36:49 -04:00

842 lines
24 KiB
Elixir

defmodule AshGraphql do
@moduledoc """
AshGraphql is a GraphQL extension for the Ash framework.
For more information, see the [getting started guide](/documentation/tutorials/getting-started-with-graphql.md)
"""
defmacro mutation(do: block) do
empty? = !match?({:__block__, _, []}, block)
quote bind_quoted: [empty?: empty?, block: Macro.escape(block)], location: :keep do
require Absinthe.Schema
if empty? ||
Enum.any?(
@ash_resources,
fn resource ->
!Enum.empty?(AshGraphql.Resource.Info.mutations(resource, @all_domains))
end
) do
Code.eval_quoted(
quote do
Absinthe.Schema.mutation do
unquote(block)
end
end,
[],
__ENV__
)
end
end
end
defmacro __using__(opts) do
auto_import_types =
if Keyword.get(opts, :auto_import_absinthe_types?, true) do
quote do
import_types(Absinthe.Type.Custom)
import_types(AshGraphql.Types.JSON)
import_types(AshGraphql.Types.JSONString)
end
end
quote bind_quoted: [
domains: opts[:domains],
domain: opts[:domain],
generate_sdl_file: opts[:generate_sdl_file],
action_middleware: opts[:action_middleware] || [],
define_relay_types?: Keyword.get(opts, :define_relay_types?, true),
relay_ids?: Keyword.get(opts, :relay_ids?, false),
auto_import_types: Macro.escape(auto_import_types)
],
location: :keep,
generated: true do
require Ash.Domain.Info
import Absinthe.Schema,
except: [
mutation: 1
]
import AshGraphql,
only: [
mutation: 1
]
domains =
domain
|> List.wrap()
|> Kernel.++(List.wrap(domains))
|> Enum.uniq()
domains =
domains
|> Enum.map(fn
{domain, registry} ->
IO.warn("""
It is no longer required to list the registry along with a domain when using `AshGraphql`
use AshGraphql, domains: [{My.App.Domain, My.App.Registry}]
Can now be stated simply as
use AshGraphql, domains: [My.App.Domain]
""")
domain
domain ->
domain
end)
|> Enum.map(fn domain -> {domain, Ash.Domain.Info.resources(domain), false} end)
|> Enum.reduce({[], []}, fn {domain, resources, first?}, {acc, seen_resources} ->
resources = Enum.reject(resources, &(&1 in seen_resources))
{[{domain, resources, first?} | acc], seen_resources ++ resources}
end)
|> elem(0)
|> Enum.reverse()
|> List.update_at(0, fn {domain, resources, _} -> {domain, resources, true} end)
@generate_sdl_file generate_sdl_file
@doc false
def generate_sdl_file do
@generate_sdl_file
end
@doc false
def ash_graphql_schema?, do: true
@ash_resources Enum.flat_map(domains, &elem(&1, 1))
ash_resources = @ash_resources
@all_domains Enum.map(domains, &elem(&1, 0))
Enum.each(ash_resources, &Code.ensure_compiled!/1)
schema = __MODULE__
schema_env = __ENV__
for resource <- ash_resources do
resource
|> AshGraphql.Resource.global_unions(Enum.map(domains, &elem(&1, 0)))
|> Enum.map(&elem(&1, 1))
|> Enum.map(fn attribute ->
if function_exported?(attribute.type, :graphql_type, 1) do
attribute.type.graphql_type(attribute.constraints)
end
end)
|> Enum.uniq()
|> Enum.each(fn type_name ->
# sobelow_skip ["DOS.BinToAtom"]
def unquote(:"resolve_gql_union_#{type_name}")(%Ash.Union{type: type}, _) do
# sobelow_skip ["DOS.BinToAtom"]
:"#{unquote(type_name)}_#{type}"
end
def unquote(:"resolve_gql_union_#{type_name}")(value, _) do
value.__union_type__
end
end)
end
for {domain, resources, first?} <- domains do
defmodule Module.concat(domain, AshTypes) do
@moduledoc false
alias Absinthe.{Blueprint, Phase, Pipeline}
def pipeline(pipeline) do
Pipeline.insert_before(
pipeline,
Absinthe.Phase.Schema.ApplyDeclaration,
__MODULE__
)
end
@dialyzer {:nowarn_function, {:run, 2}}
def run(blueprint, _opts) do
domain = unquote(domain)
action_middleware = unquote(action_middleware)
all_domains = unquote(Enum.map(domains, &elem(&1, 0)))
domain_queries =
AshGraphql.Domain.queries(
domain,
all_domains,
unquote(resources),
action_middleware,
unquote(schema),
unquote(relay_ids?)
)
relay_queries =
if unquote(first?) and unquote(define_relay_types?) and unquote(relay_ids?) do
domains_with_resources = unquote(Enum.map(domains, &{elem(&1, 0), elem(&1, 1)}))
AshGraphql.relay_queries(
domains_with_resources,
all_domains,
unquote(schema),
__ENV__
)
else
[]
end
blueprint_with_queries =
(relay_queries ++ domain_queries)
|> Enum.reduce(blueprint, fn query, blueprint ->
Absinthe.Blueprint.add_field(blueprint, "RootQueryType", query)
end)
blueprint_with_mutations =
domain
|> AshGraphql.Domain.mutations(
all_domains,
unquote(resources),
action_middleware,
unquote(schema),
unquote(relay_ids?)
)
|> Enum.reduce(blueprint_with_queries, fn mutation, blueprint ->
Absinthe.Blueprint.add_field(blueprint, "RootMutationType", mutation)
end)
managed_relationship_types =
AshGraphql.Resource.managed_relationship_definitions(
Process.get(:managed_relationship_requirements, []),
unquote(schema)
)
|> Enum.uniq_by(& &1.identifier)
|> Enum.reject(fn type ->
existing_types =
case blueprint_with_mutations do
%{schema_definitions: [%{type_definitions: type_definitions}]} ->
type_definitions
_ ->
[]
end
Enum.any?(existing_types, fn existing_type ->
existing_type.identifier == type.identifier
end)
end)
domains = unquote(Enum.map(domains, &elem(&1, 0)))
type_definitions =
if unquote(first?) do
embedded_types =
AshGraphql.get_embedded_types(
unquote(ash_resources),
domains,
unquote(schema),
unquote(relay_ids?)
)
global_maps =
AshGraphql.global_maps(
unquote(ash_resources),
domains,
unquote(schema),
__ENV__
)
global_enums =
AshGraphql.global_enums(
unquote(ash_resources),
domains,
unquote(schema),
__ENV__
)
global_unions =
AshGraphql.global_unions(
unquote(ash_resources),
domains,
unquote(schema),
__ENV__
)
Enum.uniq_by(
AshGraphql.Domain.global_type_definitions(unquote(schema), __ENV__) ++
AshGraphql.Domain.type_definitions(
domain,
domains,
unquote(resources),
unquote(schema),
__ENV__,
true,
unquote(define_relay_types?),
unquote(relay_ids?)
) ++
global_maps ++
global_enums ++
global_unions ++
embedded_types,
& &1.identifier
)
else
AshGraphql.Domain.type_definitions(
domain,
domains,
unquote(resources),
unquote(schema),
__ENV__,
false,
false,
unquote(relay_ids?)
)
end
new_defs =
List.update_at(blueprint_with_mutations.schema_definitions, 0, fn schema_def ->
%{
schema_def
| type_definitions:
schema_def.type_definitions ++
type_definitions ++ managed_relationship_types
}
end)
{:ok, %{blueprint_with_mutations | schema_definitions: new_defs}}
end
end
if first? do
Code.eval_quoted(auto_import_types, [], __ENV__)
end
@pipeline_modifier Module.concat(domain, AshTypes)
end
end
end
def global_maps(resources, all_domains, schema, env) do
resources
|> Enum.flat_map(&AshGraphql.Resource.map_definitions(&1, all_domains, schema, env))
|> Enum.uniq_by(& &1.identifier)
end
def global_enums(resources, all_domains, schema, env) do
resources
|> Enum.flat_map(&all_attributes_and_arguments(&1, all_domains))
|> only_enum_types()
|> Enum.uniq()
|> Enum.map(fn type ->
{name, identifier} =
case type do
Ash.Type.DurationName ->
{"DurationName", :duration_name}
type ->
graphql_type = type.graphql_type([])
{graphql_type |> to_string() |> Macro.camelize(), graphql_type}
end
%Absinthe.Blueprint.Schema.EnumTypeDefinition{
module: schema,
name: name,
values:
Enum.map(type.values(), fn value ->
name =
if function_exported?(type, :graphql_rename_value, 1) do
type.graphql_rename_value(value)
else
value
end
description =
if function_exported?(type, :graphql_describe_enum_value, 1) do
type.graphql_describe_enum_value(value)
else
enum_type_description(type, value)
end
%Absinthe.Blueprint.Schema.EnumValueDefinition{
module: schema,
identifier: value,
__reference__: AshGraphql.Resource.ref(env),
description: description,
name: String.upcase(to_string(name)),
value: value
}
end),
identifier: identifier,
__reference__: AshGraphql.Resource.ref(env)
}
end)
|> Enum.uniq_by(& &1.identifier)
end
defp enum_type_description(type, value) do
if Spark.implements_behaviour?(type, Ash.Type.Enum) do
type.description(value)
else
nil
end
end
def global_unions(resources, all_domains, schema, env) do
resources
|> Enum.flat_map(fn resource ->
resource
|> AshGraphql.Resource.global_unions(all_domains)
|> Enum.flat_map(fn {type, attribute} ->
type_name = type.graphql_type(attribute.constraints)
input_type_name =
if function_exported?(type, :graphql_input_type, 1) do
type.graphql_input_type(attribute.constraints)
else
"#{type_name}_input"
end
AshGraphql.Resource.union_type_definitions(
resource,
attribute,
type_name,
schema,
env,
input_type_name
)
end)
end)
|> Enum.uniq_by(& &1.identifier)
end
@doc false
def all_attributes_and_arguments(
resource,
all_domains,
already_checked \\ [],
nested? \\ true,
return_new_checked? \\ false
) do
if resource in already_checked do
if return_new_checked? do
{[], already_checked}
else
[]
end
else
already_checked = [resource | already_checked]
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.concat(all_arguments(resource, all_domains))
|> Enum.concat(Ash.Resource.Info.calculations(resource))
|> Enum.concat(
resource
|> Ash.Resource.Info.actions()
|> Enum.filter(&(&1.type == :action && &1.returns))
|> Enum.map(fn action ->
%{type: action.returns, constraints: action.constraints, name: action.name}
end)
)
|> Enum.reduce({[], already_checked}, fn %{type: type} = attr, {acc, already_checked} ->
if nested? do
constraints = Map.get(attr, :constraints, [])
{nested, already_checked} =
nested_attrs(type, all_domains, constraints, already_checked)
{[attr | nested] ++ acc, already_checked}
else
{[attr | acc], already_checked}
end
end)
|> then(fn {attrs, checked} ->
attrs = Enum.filter(attrs, &AshGraphql.Resource.Info.show_field?(resource, &1.name))
if return_new_checked? do
{attrs, checked}
else
attrs
end
end)
end
end
def relay_queries(domains_with_resources, all_domains, schema, env) do
type_to_domain_and_resource_map =
domains_with_resources
|> Enum.flat_map(fn {domain, resources} ->
resources
|> Enum.flat_map(fn resource ->
type = AshGraphql.Resource.Info.type(resource)
if type do
[{type, {domain, resource}}]
else
[]
end
end)
end)
|> Enum.into(%{})
[
%Absinthe.Blueprint.Schema.FieldDefinition{
name: "node",
identifier: :node,
arguments: [
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "id",
identifier: :id,
type: %Absinthe.Blueprint.TypeReference.NonNull{
of_type: :id
},
description: "The Node unique identifier",
__reference__: AshGraphql.Resource.ref(env)
}
],
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve_node},
{type_to_domain_and_resource_map, all_domains}}
],
complexity: {AshGraphql.Graphql.Resolver, :query_complexity},
module: schema,
description: "Retrieves a Node from its global id",
type: %Absinthe.Blueprint.TypeReference.NonNull{of_type: :node},
__reference__: AshGraphql.Resource.ref(__ENV__)
}
]
end
defp nested_attrs({:array, type}, domain, constraints, already_checked) do
nested_attrs(type, domain, constraints[:items] || [], already_checked)
end
defp nested_attrs(Ash.Type.Union, domain, constraints, already_checked) do
Enum.reduce(
constraints[:types] || [],
{[], already_checked},
fn {_, config}, {attrs, already_checked} ->
case config[:type] do
{:array, type} ->
{new, already_checked} =
nested_attrs(type, domain, config[:constraints][:items] || [], already_checked)
{attrs ++ new, already_checked}
type ->
{new, already_checked} =
nested_attrs(type, domain, config[:constraints] || [], already_checked)
{attrs ++ new, already_checked}
end
end
)
end
defp nested_attrs(type, all_domains, constraints, already_checked) do
cond do
AshGraphql.Resource.embedded?(type) ->
type
|> unwrap_type()
|> all_attributes_and_arguments(all_domains, already_checked, true, true)
Ash.Type.NewType.new_type?(type) ->
constraints = Ash.Type.NewType.constraints(type, constraints)
type = Ash.Type.NewType.subtype_of(type)
nested_attrs(type, all_domains, constraints, already_checked)
true ->
{[], already_checked}
end
end
def get_embed(type) do
if Ash.Type.NewType.new_type?(type) do
Ash.Type.NewType.subtype_of(type)
else
type
end
end
@doc false
def only_union_types(attributes) do
Enum.flat_map(attributes, fn attribute ->
attribute
|> only_union_type()
|> List.wrap()
end)
end
defp only_union_type(%{type: {:array, type}, constraints: constraints} = attribute) do
only_union_type(%{attribute | type: type, constraints: constraints[:items] || []})
end
defp only_union_type(attribute) do
this_union_type =
case union_type(attribute.type) do
nil ->
nil
type ->
{type, attribute}
end
attribute = %{
attribute
| type:
attribute.type
|> unwrap_type()
|> Ash.Type.NewType.subtype_of(),
constraints: Ash.Type.NewType.constraints(attribute.type, attribute.constraints)
}
case unwrap_type(attribute.type) do
Ash.Type.Union ->
attribute.constraints[:types]
|> Kernel.||([])
|> Enum.flat_map(fn {_name, config} ->
case union_type(config[:type]) do
nil ->
[]
type ->
[{type, attribute}]
end
end)
type ->
case union_type(type) do
nil ->
[]
type ->
[{type, attribute}]
end
end
|> Enum.concat(List.wrap(this_union_type))
end
defp only_enum_types(attributes) do
attributes
|> Enum.filter(&AshGraphql.Resource.define_type?(&1.type, &1.constraints))
|> Enum.flat_map(fn attribute ->
attribute = %{
type:
attribute.type
|> unwrap_type()
|> Ash.Type.NewType.subtype_of(),
constraints: Ash.Type.NewType.constraints(attribute.type, attribute.constraints)
}
case unwrap_type(attribute.type) do
Ash.Type.Union ->
Enum.flat_map(attribute.constraints[:types] || [], fn {_name, config} ->
case enum_type(config[:type]) do
nil ->
[]
type ->
[type]
end
end)
type ->
case enum_type(type) do
nil ->
[]
type ->
[type]
end
end
end)
end
defp union_type({:array, type}) do
union_type(type)
end
defp union_type(type) do
if Ash.Type.NewType.new_type?(type) &&
Ash.Type.NewType.subtype_of(type) == Ash.Type.Union &&
function_exported?(type, :graphql_type, 1) do
type
end
end
# sobelow_skip ["DOS.BinToAtom"]
def get_embedded_types(all_resources, all_domains, schema, relay_ids?) do
all_resources
|> Enum.flat_map(fn resource ->
resource
|> all_attributes_and_arguments(all_domains)
|> Enum.map(&{resource, &1})
end)
|> Enum.flat_map(fn
{source_resource, attribute} ->
{type, constraints} =
case attribute.type do
{:array, type} ->
{type, attribute.constraints[:items] || []}
type ->
{type, attribute.constraints}
end
attribute = %{
attribute
| type:
type
|> Ash.Type.NewType.subtype_of(),
constraints: Ash.Type.NewType.constraints(type, constraints)
}
case attribute.type do
type when type in [Ash.Type.Map, Ash.Type.Keyword, Ash.Type.Struct] ->
if fields = attribute.constraints[:fields] do
Enum.flat_map(fields, fn {name, config} ->
if AshGraphql.Resource.embedded?(config[:type]) do
[
{source_resource,
%{
attribute
| type: config[:type],
constraints: config[:constraints],
name: :"#{attribute.name}_#{name}"
}}
]
else
[]
end
end)
else
[]
end
Ash.Type.Union ->
attribute.constraints[:types]
|> Kernel.||([])
|> Enum.flat_map(fn {name, config} ->
if AshGraphql.Resource.embedded?(config[:type]) do
[
{source_resource,
%{
attribute
| type: config[:type],
constraints: config[:constraints],
name: :"#{attribute.name}_#{name}"
}}
]
else
[]
end
end)
other ->
if AshGraphql.Resource.embedded?(other) do
[{source_resource, attribute}]
else
[]
end
end
end)
|> Enum.map(fn {source_resource, attribute} ->
type = unwrap_type(attribute.type)
Code.ensure_compiled!(type)
{source_resource, attribute, type}
end)
|> Enum.flat_map(fn {source_resource, attribute, embedded} ->
[{source_resource, attribute, embedded}] ++ get_nested_embedded_types(embedded)
end)
|> Enum.flat_map(fn {source_resource, attribute, embedded_type} ->
if AshGraphql.Resource.Info.type(embedded_type) do
Enum.filter(
[
AshGraphql.Resource.type_definition(
embedded_type,
Ash.EmbeddableType.ShadowDomain,
[Ash.EmbeddableType.ShadowDomain],
schema,
relay_ids?
),
AshGraphql.Resource.embedded_type_input(
source_resource,
attribute,
embedded_type,
schema
)
],
& &1
) ++
AshGraphql.Resource.enum_definitions(embedded_type, schema, __ENV__)
else
[]
end
end)
|> Enum.uniq_by(& &1.identifier)
end
defp all_arguments(resource, all_domains) do
action_arguments =
resource
|> Ash.Resource.Info.actions()
|> Enum.filter(&used_in_gql?(resource, &1, all_domains))
|> Enum.flat_map(& &1.arguments)
calculation_arguments =
resource
|> Ash.Resource.Info.public_calculations()
|> Enum.flat_map(& &1.arguments)
action_arguments ++ calculation_arguments
end
defp used_in_gql?(resource, %{name: name}, all_domains) do
if Ash.Resource.Info.embedded?(resource) do
# We should actually check if any resource refers to this action for this
true
else
mutations = AshGraphql.Resource.Info.mutations(resource, all_domains)
queries = AshGraphql.Resource.Info.queries(resource, all_domains)
Enum.any?(mutations, fn mutation ->
mutation.action == name || Map.get(mutation, :read_action) == name
end) || Enum.any?(queries, &(&1.action == name))
end
end
defp enum_type({:array, type}), do: enum_type(type)
defp enum_type(type) do
if is_atom(type) && ensure_compiled?(type) && function_exported?(type, :values, 0) &&
(function_exported?(type, :graphql_type, 0) || function_exported?(type, :graphql_type, 1)) do
type
end
end
defp ensure_compiled?(type) do
Code.ensure_compiled!(type)
rescue
_ ->
false
end
defp unwrap_type({:array, type}), do: unwrap_type(type)
defp unwrap_type(type), do: type
defp get_nested_embedded_types(embedded_type) do
embedded_type
|> Ash.Resource.Info.public_attributes()
|> Enum.filter(&AshGraphql.Resource.embedded?(&1.type))
|> Enum.map(fn attribute ->
{attribute, unwrap_type(attribute.type)}
end)
|> Enum.flat_map(fn {attribute, embedded} ->
[{embedded_type, attribute, embedded}] ++ get_nested_embedded_types(embedded)
end)
end
@deprecated "add_context is no longer necessary"
def add_context(ctx, _domains, _options \\ []) do
ctx
end
end