ash_graphql/lib/resource/resource.ex
Zach Daniel 5e593ec40c improvement: old json -> json_string
improvement: new json does not stringify outpu
2021-06-15 11:16:10 -04:00

2371 lines
67 KiB
Elixir

defmodule AshGraphql.Resource do
alias Ash.Changeset.ManagedRelationshipHelpers
alias Ash.Dsl.Extension
alias Ash.Query.Aggregate
alias AshGraphql.Resource
alias AshGraphql.Resource.{ManagedRelationship, Mutation, Query}
@get %Ash.Dsl.Entity{
name: :get,
args: [:name, :action],
describe: "A query to fetch a record by primary key",
examples: [
"get :get_post, :read"
],
schema: Query.get_schema(),
target: Query,
auto_set_fields: [
type: :get
]
}
@read_one %Ash.Dsl.Entity{
name: :read_one,
args: [:name, :action],
describe: "A query to fetch a record",
examples: [
"read_one :current_user, :current_user"
],
schema: Query.read_one_schema(),
target: Query,
auto_set_fields: [
type: :read_one
]
}
@list %Ash.Dsl.Entity{
name: :list,
schema: Query.list_schema(),
args: [:name, :action],
describe: "A query to fetch a list of records",
examples: [
"list :list_posts, :read"
],
target: Query,
auto_set_fields: [
type: :list
]
}
@create %Ash.Dsl.Entity{
name: :create,
schema: Mutation.create_schema(),
args: [:name, :action],
describe: "A mutation to create a record",
examples: [
"create :create_post, :create"
],
target: Mutation,
auto_set_fields: [
type: :create
]
}
@update %Ash.Dsl.Entity{
name: :update,
schema: Mutation.update_schema(),
args: [:name, :action],
describe: "A mutation to update a record",
examples: [
"update :update_post, :update"
],
target: Mutation,
auto_set_fields: [
type: :update
]
}
@destroy %Ash.Dsl.Entity{
name: :destroy,
schema: Mutation.destroy_schema(),
args: [:name, :action],
describe: "A mutation to destroy a record",
examples: [
"destroy :destroy_post, :destroy"
],
target: Mutation,
auto_set_fields: [
type: :destroy
]
}
@queries %Ash.Dsl.Section{
name: :queries,
describe: """
Queries (read actions) to expose for the resource.
""",
examples: [
"""
queries do
get :get_post, :read
read_one :current_user, :current_user
list :list_posts, :read
end
"""
],
entities: [
@get,
@read_one,
@list
]
}
@managed_relationship %Ash.Dsl.Entity{
name: :managed_relationship,
schema: ManagedRelationship.schema(),
args: [:action, :argument],
target: ManagedRelationship,
describe: """
Instructs ash_graphql that a given argument with a `manage_relationship` change should have its input objects derived automatically from the potential actions to be called.
For example, given an action like:
```elixir
actions do
create :create do
argument :comments, {:array, :map}
change manage_relationship(:comments, type: :direct_control) # <- we look for this change with a matching argument name
end
end
```
You could add the following managed_relationship
```elixir
graphql do
...
managed_relationships do
managed_relationship :create_post, :comments
end
end
```
By default, the `{:array, :map}` would simply be a `json[]` type. If the argument name
is placed in this list, all of the potential actions that could be called will be combined
into a single input object. If there are type conflicts (for example, if the input could create
or update a record, and the create and update actions have an argument of the same name but with a different type),
a warning is emitted at compile time and the first one is used. If that is insufficient, you will need to do one of the following:
1.) provide the `:types` option to the `managed_relationship` constructor (see that option for more)
2.) define a custom type, with a custom input object (see the custom types guide), and use that custom type instead of `:map`
3.) change your actions to not have overlapping inputs with different types
"""
}
@managed_relationships %Ash.Dsl.Section{
name: :managed_relationships,
describe: """
Generates input objects for `manage_relationship` arguments on reosurce actions.
""",
examples: [
"""
managed_relationships do
manage_relationship :create_post, :comments
end
"""
],
entities: [
@managed_relationship
]
}
@mutations %Ash.Dsl.Section{
name: :mutations,
describe: """
Mutations (create/update/destroy actions) to expose for the resource.
""",
examples: [
"""
mutations do
create :create_post, :create
update :update_post, :update
destroy :destroy_post, :destroy
end
"""
],
entities: [
@create,
@update,
@destroy
]
}
@graphql %Ash.Dsl.Section{
name: :graphql,
describe: """
Configuration for a given resource in graphql
""",
examples: [
"""
graphql do
type :post
queries do
get :get_post, :read
list :list_posts, :read
end
mutations do
create :create_post, :create
update :update_post, :update
destroy :destroy_post, :destroy
end
end
"""
],
schema: [
type: [
type: :atom,
required: true,
doc: "The type to use for this entity in the graphql schema"
],
primary_key_delimiter: [
type: :string,
doc:
"If a composite primary key exists, this must be set to determine the `id` field value"
]
],
sections: [
@queries,
@mutations,
@managed_relationships
]
}
@transformers [
AshGraphql.Resource.Transformers.RequireIdPkey,
AshGraphql.Resource.Transformers.ValidateActions
]
@sections [@graphql]
@moduledoc """
This Ash resource extension adds configuration for exposing a resource in a graphql.
# Table of Contents
#{Ash.Dsl.Extension.doc_index(@sections)}
#{Ash.Dsl.Extension.doc(@sections)}
"""
use Extension, sections: @sections, transformers: @transformers
def queries(resource) do
Extension.get_entities(resource, [:graphql, :queries])
end
def mutations(resource) do
Extension.get_entities(resource, [:graphql, :mutations]) || []
end
def managed_relationships(resource) do
Extension.get_entities(resource, [:graphql, :managed_relationships]) || []
end
def type(resource) do
Extension.get_opt(resource, [:graphql], :type, nil)
end
def primary_key_delimiter(resource) do
Extension.get_opt(resource, [:graphql], :primary_key_delimiter, [], false)
end
def ref(env) do
%{module: __MODULE__, location: %{file: env.file, line: env.line}}
end
def encode_primary_key(%resource{} = record) do
case Ash.Resource.Info.primary_key(resource) do
[field] ->
Map.get(record, field)
keys ->
delimiter = primary_key_delimiter(resource)
[_ | concatenated_keys] =
keys
|> Enum.reverse()
|> Enum.reduce([], fn key, acc -> [delimiter, to_string(Map.get(record, key)), acc] end)
IO.iodata_to_binary(concatenated_keys)
end
end
def decode_primary_key(resource, value) do
case Ash.Resource.Info.primary_key(resource) do
[_field] ->
{:ok, value}
fields ->
delimiter = primary_key_delimiter(resource)
parts = String.split(value, delimiter)
if Enum.count(parts) == Enum.count(fields) do
{:ok, Enum.zip(fields, parts)}
else
{:error, "Invalid primary key"}
end
end
end
@doc false
def queries(api, resource, schema) do
type = Resource.type(resource)
if type do
resource
|> queries()
|> Enum.map(fn query ->
query_action = Ash.Resource.Info.action(resource, query.action, :read)
%Absinthe.Blueprint.Schema.FieldDefinition{
arguments: args(query.type, resource, query_action, schema, query.identity),
identifier: query.name,
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, query}}
],
module: schema,
name: to_string(query.name),
type: query_type(query, query_action, type),
__reference__: ref(__ENV__)
}
end)
else
[]
end
end
# sobelow_skip ["DOS.StringToAtom"]
@doc false
def mutations(api, resource, schema) do
resource
|> mutations()
|> Enum.map(fn
%{type: :destroy} = mutation ->
action = Ash.Resource.Info.action(resource, mutation.action)
if action.soft? do
update_mutation(resource, schema, mutation, schema, api)
else
%Absinthe.Blueprint.Schema.FieldDefinition{
arguments: mutation_args(mutation, resource, schema),
identifier: mutation.name,
middleware: [
{{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation}}
],
module: schema,
name: to_string(mutation.name),
type: String.to_atom("#{mutation.name}_result"),
__reference__: ref(__ENV__)
}
end
%{type: :create} = mutation ->
action = Ash.Resource.Info.action(resource, mutation.action)
args =
case mutation_fields(
resource,
schema,
action,
mutation.type
) do
[] ->
[]
_ ->
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
identifier: :input,
module: schema,
name: "input",
placement: :argument_definition,
type: String.to_atom("#{mutation.name}_input")
}
]
end
%Absinthe.Blueprint.Schema.FieldDefinition{
arguments: args,
identifier: mutation.name,
middleware: [
{{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation}}
],
module: schema,
name: to_string(mutation.name),
type: String.to_atom("#{mutation.name}_result"),
__reference__: ref(__ENV__)
}
mutation ->
update_mutation(resource, schema, mutation, schema, api)
end)
end
# sobelow_skip ["DOS.StringToAtom"]
defp update_mutation(resource, schema, mutation, schema, api) do
action = Ash.Resource.Info.action(resource, mutation.action)
args =
case mutation_fields(
resource,
schema,
action,
mutation.type
) do
[] ->
mutation_args(mutation, resource, schema)
_ ->
mutation_args(mutation, resource, schema) ++
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
identifier: :input,
module: schema,
name: "input",
placement: :argument_definition,
type: String.to_atom("#{mutation.name}_input"),
__reference__: ref(__ENV__)
}
]
end
%Absinthe.Blueprint.Schema.FieldDefinition{
arguments: args,
identifier: mutation.name,
middleware: [
{{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation}}
],
module: schema,
name: to_string(mutation.name),
type: String.to_atom("#{mutation.name}_result"),
__reference__: ref(__ENV__)
}
end
defp mutation_args(%{identity: false}, _resource, _schema) do
[]
end
defp mutation_args(%{identity: identity}, resource, _schema) when not is_nil(identity) do
resource
|> Ash.Resource.Info.identities()
|> Enum.find(&(&1.name == identity))
|> Map.get(:keys)
|> Enum.map(fn key ->
attribute = Ash.Resource.Info.attribute(resource, key)
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: to_string(key),
identifier: key,
type: %Absinthe.Blueprint.TypeReference.NonNull{
of_type: field_type(attribute.type, attribute, resource)
},
description: attribute.description || "",
__reference__: ref(__ENV__)
}
end)
end
defp mutation_args(_, _, schema) do
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
identifier: :id,
module: schema,
name: "id",
placement: :argument_definition,
type: :id,
__reference__: ref(__ENV__)
}
]
end
@doc false
# sobelow_skip ["DOS.StringToAtom"]
def mutation_types(resource, schema) do
resource
|> mutations()
|> Enum.flat_map(fn mutation ->
mutation = %{
mutation
| action: Ash.Resource.Info.action(resource, mutation.action, mutation.type)
}
description =
if mutation.type == :destroy do
"The record that was successfully deleted"
else
"The successful result of the mutation"
end
result = %Absinthe.Blueprint.Schema.ObjectTypeDefinition{
description: "The result of the #{inspect(mutation.name)} mutation",
fields: [
%Absinthe.Blueprint.Schema.FieldDefinition{
description: description,
identifier: :result,
module: schema,
name: "result",
type: Resource.type(resource),
__reference__: ref(__ENV__)
},
%Absinthe.Blueprint.Schema.FieldDefinition{
description: "Any errors generated, if the mutation failed",
identifier: :errors,
module: schema,
name: "errors",
type: %Absinthe.Blueprint.TypeReference.List{
of_type: :mutation_error
},
__reference__: ref(__ENV__)
}
],
identifier: String.to_atom("#{mutation.name}_result"),
module: schema,
name: Macro.camelize("#{mutation.name}_result"),
__reference__: ref(__ENV__)
}
case mutation_fields(
resource,
schema,
mutation.action,
mutation.type
) do
[] ->
[result]
fields ->
input = %Absinthe.Blueprint.Schema.InputObjectTypeDefinition{
fields: fields,
identifier: String.to_atom("#{mutation.name}_input"),
module: schema,
name: Macro.camelize("#{mutation.name}_input"),
__reference__: ref(__ENV__)
}
[input, result]
end
end)
end
@doc false
# sobelow_skip ["DOS.StringToAtom"]
def embedded_type_input(source_resource, attribute, resource, schema) do
create_action =
case attribute.constraints[:create_action] do
nil ->
Ash.Resource.Info.primary_action!(resource, :create)
name ->
Ash.Resource.Info.action(resource, name, :create)
end
update_action =
case attribute.constraints[:update_action] do
nil ->
Ash.Resource.Info.primary_action!(resource, :update)
name ->
Ash.Resource.Info.action(resource, name, :update)
end
fields =
mutation_fields(resource, schema, create_action, :create) ++
mutation_fields(resource, schema, update_action, :update)
fields =
fields
|> Enum.group_by(& &1.identifier)
# We only want one field per id. Right now we just take the first one
# If there are overlaps, and the field isn't `NonNull` in *all* cases, then
# we pick one and mark it explicitly as nullable (we unwrap the `NonNull`)
|> Enum.map(fn {_id, fields} ->
if Enum.all?(
fields,
&match?(%Absinthe.Blueprint.TypeReference.NonNull{}, &1.type)
) do
Enum.at(fields, 0)
else
fields
|> Enum.at(0)
|> case do
%{type: %Absinthe.Blueprint.TypeReference.NonNull{of_type: type}} = field ->
%{field | type: type}
field ->
field
end
end
end)
name = "#{AshGraphql.Resource.type(source_resource)}_#{attribute.name}_input"
%Absinthe.Blueprint.Schema.InputObjectTypeDefinition{
fields: fields,
identifier: String.to_atom(name),
module: schema,
name: Macro.camelize(name),
__reference__: ref(__ENV__)
}
end
defp mutation_fields(resource, schema, action, type) do
managed_relationships =
Enum.filter(
AshGraphql.Resource.managed_relationships(resource),
&(&1.action == action.name)
)
attribute_fields =
if action.type == :destroy && !action.soft? do
[]
else
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.filter(fn attribute ->
is_nil(action.accept) || attribute.name in action.accept
end)
|> Enum.filter(& &1.writable?)
|> Enum.map(fn attribute ->
allow_nil? =
attribute.allow_nil? || attribute.default != nil || type == :update ||
attribute.generated? ||
(type == :create && attribute.name in action.allow_nil_input)
explicitly_required = attribute.name in action.require_attributes
field_type =
attribute.type
|> field_type(attribute, resource, true)
|> maybe_wrap_non_null(explicitly_required || not allow_nil?)
%Absinthe.Blueprint.Schema.FieldDefinition{
description: attribute.description,
identifier: attribute.name,
module: schema,
name: to_string(attribute.name),
type: field_type,
__reference__: ref(__ENV__)
}
end)
end
argument_fields =
action.arguments
|> Enum.reject(& &1.private?)
|> Enum.map(fn argument ->
case find_manage_change(argument, action, managed_relationships) do
nil ->
type =
argument.type
|> field_type(argument, resource, true)
|> maybe_wrap_non_null(argument_required?(argument))
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: argument.name,
module: schema,
name: to_string(argument.name),
type: type,
__reference__: ref(__ENV__)
}
_manage_opts ->
managed = Enum.find(managed_relationships, &(&1.argument == argument.name))
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)
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: argument.name,
module: schema,
name: to_string(argument.name),
type: maybe_wrap_non_null(type, argument_required?(argument)),
__reference__: ref(__ENV__)
}
end
end)
attribute_fields ++ argument_fields
end
defp wrap_arrays({:array, arg_type}, type, constraints) do
%Absinthe.Blueprint.TypeReference.List{
of_type:
maybe_wrap_non_null(
wrap_arrays(arg_type, type, constraints[:items] || []),
!constraints[:nil_items?] || Ash.Type.embedded_type?(type)
)
}
end
defp wrap_arrays(_, type, _), do: type
# sobelow_skip ["DOS.StringToAtom"]
defp default_managed_type_name(resource, action, argument) do
String.to_atom(
to_string(action.type) <>
"_" <>
to_string(AshGraphql.Resource.type(resource)) <>
"_" <> to_string(argument.name) <> "_input"
)
end
defp find_manage_change(argument, action, managed_relationships) do
if argument.name in Enum.map(managed_relationships, & &1.argument) do
Enum.find_value(action.changes, fn
%{change: {Ash.Resource.Change.ManageRelationship, opts}} ->
opts[:argument] == argument.name && opts
_ ->
nil
end)
end
end
# sobelow_skip ["DOS.StringToAtom"]
defp query_type(%{type: :list}, action, type) do
if action.pagination do
String.to_atom("page_of_#{type}")
else
%Absinthe.Blueprint.TypeReference.NonNull{
of_type: %Absinthe.Blueprint.TypeReference.List{
of_type: %Absinthe.Blueprint.TypeReference.NonNull{
of_type: type
}
}
}
end
end
defp query_type(query, _action, type) do
maybe_wrap_non_null(type, not query.allow_nil?)
end
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 args(action_type, resource, action, schema, identity \\ nil)
defp args(:get, resource, action, schema, nil) do
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "id",
identifier: :id,
type: %Absinthe.Blueprint.TypeReference.NonNull{of_type: :id},
description: "The id of the record",
__reference__: ref(__ENV__)
}
] ++ read_args(resource, action, schema)
end
defp args(:get, resource, action, schema, identity) do
if identity do
resource
|> Ash.Resource.Info.identities()
|> Enum.find(&(&1.name == identity))
|> Map.get(:keys)
|> Enum.map(fn key ->
attribute = Ash.Resource.Info.attribute(resource, key)
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: to_string(key),
identifier: key,
type: %Absinthe.Blueprint.TypeReference.NonNull{
of_type: field_type(attribute.type, attribute, resource, true)
},
description: attribute.description || "",
__reference__: ref(__ENV__)
}
end)
else
[]
end
|> Enum.concat(read_args(resource, action, schema))
end
defp args(:read_one, resource, action, schema, _) do
args =
case resource_filter_fields(resource, schema) do
[] ->
[]
_ ->
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "filter",
identifier: :filter,
type: resource_filter_type(resource),
description: "A filter to limit the results",
__reference__: ref(__ENV__)
}
]
end
args ++ read_args(resource, action, schema)
end
defp args(:list, resource, action, schema, _) do
args =
case resource_filter_fields(resource, schema) do
[] ->
[]
_ ->
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "filter",
identifier: :filter,
type: resource_filter_type(resource),
description: "A filter to limit the results",
__reference__: ref(__ENV__)
}
]
end
args =
case sort_values(resource) do
[] ->
args
_ ->
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "sort",
identifier: :sort,
type: %Absinthe.Blueprint.TypeReference.List{
of_type: resource_sort_type(resource)
},
description: "How to sort the records in the response",
__reference__: ref(__ENV__)
}
| args
]
end
args ++ pagination_args(action) ++ read_args(resource, action, schema)
end
defp args(:list_related, resource, action, schema, identity) do
args(:list, resource, action, schema, identity) ++
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "limit",
identifier: :limit,
type: :integer,
description: "The number of records to return.",
__reference__: ref(__ENV__)
},
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "offset",
identifier: :offset,
type: :integer,
description: "The number of records to skip.",
__reference__: ref(__ENV__)
}
]
end
defp read_args(resource, action, schema) do
action.arguments
|> Enum.reject(& &1.private?)
|> Enum.map(fn argument ->
type =
argument.type
|> field_type(argument, resource, true)
|> maybe_wrap_non_null(argument_required?(argument))
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: argument.name,
module: schema,
name: to_string(argument.name),
type: type,
__reference__: ref(__ENV__)
}
end)
end
defp pagination_args(action) do
if action.pagination do
max_message =
if action.pagination.max_page_size do
" Maximum #{action.pagination.max_page_size}"
else
""
end
limit_type =
maybe_wrap_non_null(
:integer,
action.pagination.required? && is_nil(action.pagination.default_limit)
)
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "limit",
identifier: :limit,
type: limit_type,
default_value: action.pagination.default_limit,
description: "The number of records to return." <> max_message,
__reference__: ref(__ENV__)
}
] ++ keyset_pagination_args(action) ++ offset_pagination_args(action)
else
[]
end
end
# sobelow_skip ["DOS.StringToAtom"]
defp resource_sort_type(resource) do
String.to_atom(to_string(AshGraphql.Resource.type(resource)) <> "_sort_input")
end
# sobelow_skip ["DOS.StringToAtom"]
defp resource_filter_type(resource) do
String.to_atom(to_string(AshGraphql.Resource.type(resource)) <> "_filter_input")
end
# sobelow_skip ["DOS.StringToAtom"]
defp attribute_filter_field_type(resource, attribute) do
String.to_atom(
to_string(AshGraphql.Resource.type(resource)) <> "_filter_" <> to_string(attribute.name)
)
end
# sobelow_skip ["DOS.StringToAtom"]
defp calculation_filter_field_type(resource, calculation) do
String.to_atom(
to_string(AshGraphql.Resource.type(resource)) <>
"_filter_" <> to_string(calculation.name)
)
end
defp keyset_pagination_args(action) do
if action.pagination.keyset? do
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "before",
identifier: :before,
type: :string,
description: "Show records before the specified keyset.",
__reference__: ref(__ENV__)
},
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "after",
identifier: :after,
type: :string,
description: "Show records after the specified keyset.",
__reference__: ref(__ENV__)
}
]
else
[]
end
end
defp offset_pagination_args(action) do
if action.pagination.offset? do
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "offset",
identifier: :offset,
type: :integer,
description: "The number of records to skip.",
__reference__: ref(__ENV__)
}
]
else
[]
end
end
@doc false
def type_definitions(resource, api, schema) do
List.wrap(calculation_input(resource, schema)) ++
List.wrap(type_definition(resource, api, schema)) ++
List.wrap(sort_input(resource, schema)) ++
List.wrap(filter_input(resource, schema)) ++
filter_field_types(resource, schema) ++
List.wrap(page_of(resource, schema)) ++
enum_definitions(resource, schema) ++
managed_relationship_definitions(resource, schema)
end
def no_graphql_types(resource, schema) do
enum_definitions(resource, schema, true) ++
managed_relationship_definitions(resource, schema)
end
defp managed_relationship_definitions(resource, schema) do
resource
|> Ash.Resource.Info.actions()
|> Enum.flat_map(fn action ->
resource
|> AshGraphql.Resource.managed_relationships()
|> Enum.filter(&(&1.action == action.name))
|> Enum.map(fn managed_relationship ->
argument =
Enum.find(action.arguments, &(&1.name == managed_relationship.argument)) ||
raise """
No such argument #{managed_relationship.argument}, in `managed_relationship`
"""
opts =
find_manage_change(argument, action, [managed_relationship]) ||
raise """
There is no corresponding `change manage_change(...)` for the given argument and action
combination.
"""
managed_relationship_input(
resource,
action,
opts,
argument,
managed_relationship,
schema
)
end)
end)
end
defp managed_relationship_input(resource, action, opts, argument, managed_relationship, schema) do
relationship =
Ash.Resource.Info.relationship(resource, opts[:relationship]) ||
raise """
No relationship found when building managed relationship input: #{opts[:relationship]}
"""
manage_opts_schema =
if opts[:opts][:type] do
defaults = Ash.Changeset.manage_relationship_opts(opts[:opts][:type])
Enum.reduce(defaults, Ash.Changeset.manage_relationship_schema(), fn {key, value},
manage_opts ->
Ash.OptionsHelpers.set_default!(manage_opts, key, value)
end)
else
Ash.Changeset.manage_relationship_schema()
end
manage_opts = Ash.OptionsHelpers.validate!(opts[:opts], manage_opts_schema)
fields =
on_match_fields(manage_opts, relationship, schema) ++
on_no_match_fields(manage_opts, relationship, schema) ++
on_lookup_fields(manage_opts, relationship, schema) ++
manage_pkey_fields(manage_opts, managed_relationship, relationship, schema)
type = managed_relationship.type_name || default_managed_type_name(resource, action, argument)
fields = check_for_conflicts!(fields, managed_relationship, resource)
%Absinthe.Blueprint.Schema.InputObjectTypeDefinition{
identifier: type,
fields: fields,
module: schema,
name: type |> to_string() |> Macro.camelize(),
__reference__: ref(__ENV__)
}
end
defp check_for_conflicts!(fields, managed_relationship, resource) do
{ok, errors} =
fields
|> Enum.map(fn {resource, action, field} ->
%{field: field, source: %{resource: resource, action: action}}
end)
|> Enum.group_by(& &1.field.identifier)
|> Enum.map(fn {identifier, data} ->
case Keyword.fetch(managed_relationship.types || [], identifier) do
{:ok, nil} ->
nil
{:ok, type} ->
type = unwrap_managed_relationship_type(type)
{:ok, %{Enum.at(data, 0).field | type: type}}
:error ->
get_conflicts(data)
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.split_with(&match?({:ok, _}, &1))
unless Enum.empty?(errors) do
raise_conflicts!(Enum.map(errors, &elem(&1, 1)), managed_relationship, resource)
end
Enum.map(ok, &elem(&1, 1))
end
defp raise_conflicts!(conflicts, managed_relationship, resource) do
raise """
#{inspect(resource)}: #{managed_relationship.action}.#{managed_relationship.argument}
Error while deriving managed relationship input object type: type conflict.
Because multiple actions could be called, and those actions may have different
derived types, you will need to override the graphql schema to specify the type
for the following fields. This can be done by specifying the `types` option on your
`managed_relationship` inside of the `managed_relationships` in your resource's
`graphql` configuration.
#{Enum.map_join(conflicts, "\n\n", &conflict_message(&1, managed_relationship))}
"""
end
defp conflict_message(
{_reducing_type, _type, [%{field: %{name: name}} | _] = fields},
managed_relationship
) do
formatted_types =
fields
|> Enum.map(fn
%{source: %{action: :__primary_key}} = field ->
"#{inspect(format_type(field.field.type))} - from #{inspect(field.source.resource)}'s lookup by primary key"
%{source: %{action: {:identity, identity}}} = field ->
"#{inspect(format_type(field.field.type))} - from #{inspect(field.source.resource)}'s identity: #{
identity
}"
field ->
"#{inspect(format_type(field.field.type))} - from #{inspect(field.source.resource)}.#{
field.source.action
}"
end)
|> Enum.uniq()
"""
Possible types for #{managed_relationship.action}.#{managed_relationship.argument}.#{name}:
#{Enum.map(formatted_types, &" * #{&1}\n")}
"""
end
defp unwrap_managed_relationship_type({:non_null, type}) do
%Absinthe.Blueprint.TypeReference.NonNull{of_type: unwrap_managed_relationship_type(type)}
end
defp unwrap_managed_relationship_type({:array, type}) do
%Absinthe.Blueprint.TypeReference.List{of_type: unwrap_managed_relationship_type(type)}
end
defp unwrap_managed_relationship_type(type) do
type
end
defp format_type(%Absinthe.Blueprint.TypeReference.NonNull{of_type: type}) do
{:non_null, format_type(type)}
end
defp format_type(%Absinthe.Blueprint.TypeReference.List{of_type: type}) do
{:array, format_type(type)}
end
defp format_type(type) do
type
end
defp get_conflicts([field]) do
{:ok, field.field}
end
defp get_conflicts([field | _] = fields) do
case reduce_types(fields) do
{:ok, res} ->
{:ok, %{field.field | type: res}}
{:error, {reducing_type, type}} ->
{:error, {reducing_type, type, fields}}
end
end
defp reduce_types(fields) do
Enum.reduce_while(fields, {:ok, nil}, fn field, {:ok, type} ->
if type do
case match_types(type, field.field.type) do
{:ok, value} ->
{:cont, {:ok, value}}
:error ->
{:halt, {:error, {type, field.field.type}}}
end
else
{:cont, {:ok, field.field.type}}
end
end)
end
defp match_types(
%Absinthe.Blueprint.TypeReference.NonNull{
of_type: type
},
type
) do
{:ok, type}
end
defp match_types(
type,
%Absinthe.Blueprint.TypeReference.NonNull{
of_type: type
}
) do
{:ok, type}
end
defp match_types(
type,
type
) do
{:ok, type}
end
defp match_types(_, _) do
:error
end
defp on_lookup_fields(opts, relationship, schema) do
case ManagedRelationshipHelpers.on_lookup_update_action(opts, relationship) do
{:destination, nil} ->
[]
{:destination, action} ->
action = Ash.Resource.Info.action(relationship.through, action)
relationship.destination
|> mutation_fields(schema, action, action.type)
|> Enum.map(fn field ->
{relationship.destination, action.name, field}
end)
{:source, nil} ->
[]
{:source, action} ->
action = Ash.Resource.Info.action(relationship.source, action)
relationship.source
|> mutation_fields(schema, action, action.type)
|> Enum.map(fn field ->
{relationship.source, action.name, field}
end)
{:join, nil, _} ->
[]
{:join, action, fields} ->
action = Ash.Resource.Info.action(relationship.through, action)
if fields == :all do
mutation_fields(relationship.through, schema, action, action.type)
else
relationship.through
|> mutation_fields(schema, action, action.type)
|> Enum.filter(&(&1.identifier in fields))
end
|> Enum.map(fn field ->
{relationship.through, action.name, field}
end)
nil ->
[]
end
end
defp on_match_fields(opts, relationship, schema) do
opts
|> ManagedRelationshipHelpers.on_match_destination_actions(relationship)
|> List.wrap()
|> Enum.flat_map(fn
{:destination, nil} ->
[]
{:destination, action_name} ->
action = Ash.Resource.Info.action(relationship.destination, action_name)
relationship.destination
|> mutation_fields(schema, action, action.type)
|> Enum.map(fn field ->
{relationship.destination, action.name, field}
end)
{:join, nil, _} ->
[]
{:join, action_name, fields} ->
action = Ash.Resource.Info.action(relationship.through, action_name)
if fields == :all do
mutation_fields(relationship.through, schema, action, action.type)
else
relationship.through
|> mutation_fields(schema, action, action.type)
|> Enum.filter(&(&1.identifier in fields))
end
|> Enum.map(fn field ->
{relationship.through, action.name, field}
end)
end)
end
defp on_no_match_fields(opts, relationship, schema) do
opts
|> ManagedRelationshipHelpers.on_no_match_destination_actions(relationship)
|> List.wrap()
|> Enum.flat_map(fn
{:destination, nil} ->
[]
{:destination, action_name} ->
action = Ash.Resource.Info.action(relationship.destination, action_name)
relationship.destination
|> mutation_fields(schema, action, action.type)
|> Enum.map(fn field ->
{relationship.destination, action.name, field}
end)
{:join, nil, _} ->
[]
{:join, action_name, fields} ->
action = Ash.Resource.Info.action(relationship.through, action_name)
if fields == :all do
mutation_fields(relationship.through, schema, action, action.type)
else
relationship.through
|> mutation_fields(schema, action, action.type)
|> Enum.filter(&(&1.identifier in fields))
end
|> Enum.map(fn field ->
{relationship.through, action.name, field}
end)
end)
end
defp manage_pkey_fields(opts, managed_relationship, relationship, schema) do
resource = relationship.destination
could_lookup? = ManagedRelationshipHelpers.could_lookup?(opts)
could_match? = ManagedRelationshipHelpers.could_update?(opts)
needs_pkey? = opts[:on_no_match] == :match
if could_lookup? || (could_match? && needs_pkey?) do
pkey_fields =
if managed_relationship.lookup_with_primary_key? do
resource
|> pkey_fields(schema, false)
|> Enum.map(fn field ->
{resource, :__primary_key, field}
end)
else
[]
end
resource
|> Ash.Resource.Info.identities()
|> Enum.filter(fn identity ->
is_nil(managed_relationship.lookup_identities) ||
identity.name in managed_relationship.lookup_identities
end)
|> Enum.flat_map(fn identity ->
identity
|> Map.get(:keys)
|> Enum.map(fn key ->
{identity.name, key}
end)
end)
|> Enum.uniq_by(&elem(&1, 1))
|> Enum.map(fn {identity_name, key} ->
attribute = Ash.Resource.Info.attribute(resource, key)
field = %Absinthe.Blueprint.Schema.InputValueDefinition{
name: to_string(key),
identifier: key,
type: field_type(attribute.type, attribute, resource),
description: attribute.description || "",
__reference__: ref(__ENV__)
}
{resource, {:identity, identity_name}, field}
end)
|> Enum.concat(pkey_fields)
else
[]
end
end
defp filter_field_types(resource, schema) do
filter_attribute_types(resource, schema) ++ filter_aggregate_types(resource, schema)
end
defp filter_attribute_types(resource, schema) do
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.flat_map(&filter_type(&1, resource, schema))
end
defp filter_aggregate_types(resource, schema) do
resource
|> Ash.Resource.Info.public_aggregates()
|> Enum.flat_map(&filter_type(&1, resource, schema))
end
defp attribute_or_aggregate_type(%Ash.Resource.Attribute{type: type}, _resource),
do: type
defp attribute_or_aggregate_type(
%Ash.Resource.Aggregate{kind: kind, field: field, relationship_path: relationship_path},
resource
) do
field_type =
with field when not is_nil(field) <- field,
related when not is_nil(related) <-
Ash.Resource.Info.related(resource, relationship_path),
attr when not is_nil(attr) <- Ash.Resource.Info.attribute(related, field) do
attr.type
end
{:ok, aggregate_type} = Ash.Query.Aggregate.kind_to_type(kind, field_type)
aggregate_type
end
defp filter_type(attribute_or_aggregate, resource, schema) do
type = attribute_or_aggregate_type(attribute_or_aggregate, resource)
array_type? = match?({:array, _}, type)
fields =
Ash.Filter.builtin_operators()
|> Enum.filter(& &1.predicate?)
|> restrict_for_lists(type)
|> Enum.flat_map(fn operator ->
filter_fields(operator, type, array_type?, schema, attribute_or_aggregate, resource)
end)
if fields == [] do
[]
else
identifier = attribute_filter_field_type(resource, attribute_or_aggregate)
[
%Absinthe.Blueprint.Schema.InputObjectTypeDefinition{
identifier: identifier,
fields: fields,
module: schema,
name: identifier |> to_string() |> Macro.camelize(),
__reference__: ref(__ENV__)
}
]
end
end
defp filter_fields(operator, type, array_type?, schema, attribute_or_aggregate, resource) do
expressable_types =
Enum.filter(operator.types(), fn
[:any, {:array, type}] when is_atom(type) ->
true
[{:array, inner_type}, :same] when is_atom(inner_type) and array_type? ->
true
:same ->
true
:any ->
true
[:any, type] when is_atom(type) ->
true
_ ->
false
end)
if Enum.any?(expressable_types, &(&1 == :same)) do
[
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: operator.name(),
module: schema,
name: to_string(operator.name()),
type: field_type(type, attribute_or_aggregate, resource, true),
__reference__: ref(__ENV__)
}
]
else
type =
case Enum.at(expressable_types, 0) do
[{:array, :any}, :same] ->
{:unwrap, type}
[_, {:array, :same}] ->
{:array, type}
[_, :same] ->
type
[_, :any] ->
Ash.Type.String
[_, type] when is_atom(type) ->
Ash.Type.get_type(type)
_ ->
nil
end
if type do
{type, attribute_or_aggregate} =
case type do
{:unwrap, type} ->
{:array, type} = type
constraints = Map.get(attribute_or_aggregate, :constraints) || []
{type,
%{attribute_or_aggregate | type: type, constraints: constraints[:items] || []}}
type ->
{type, attribute_or_aggregate}
end
if Ash.Type.embedded_type?(type) do
[]
else
attribute_or_aggregate = constraints_to_item_constraints(type, attribute_or_aggregate)
[
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: operator.name(),
module: schema,
name: to_string(operator.name()),
type: field_type(type, attribute_or_aggregate, resource, true),
__reference__: ref(__ENV__)
}
]
end
else
[]
end
end
rescue
_ ->
[]
end
defp restrict_for_lists(operators, {:array, _}) do
list_predicates = [Ash.Query.Operator.IsNil, Ash.Query.Operator.Has]
Enum.filter(operators, &(&1 in list_predicates))
end
defp restrict_for_lists(operators, _), do: operators
defp constraints_to_item_constraints(
{:array, _},
%Ash.Resource.Attribute{
constraints: constraints,
allow_nil?: allow_nil?
} = attribute
) do
%{
attribute
| constraints: [
items: constraints,
nil_items?: allow_nil? || Ash.Type.embedded_type?(attribute.type)
]
}
end
defp constraints_to_item_constraints(_, attribute_or_aggregate), do: attribute_or_aggregate
defp sort_input(resource, schema) do
case sort_values(resource) do
[] ->
nil
_ ->
%Absinthe.Blueprint.Schema.InputObjectTypeDefinition{
fields: [
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: :order,
module: schema,
name: "order",
default_value: :asc,
type: :sort_order,
__reference__: ref(__ENV__)
},
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: :field,
module: schema,
name: "field",
type: %Absinthe.Blueprint.TypeReference.NonNull{
of_type: resource_sort_field_type(resource)
},
__reference__: ref(__ENV__)
}
],
identifier: resource_sort_type(resource),
module: schema,
name: resource |> resource_sort_type() |> to_string() |> Macro.camelize(),
__reference__: ref(__ENV__)
}
end
end
defp filter_input(resource, schema) do
case resource_filter_fields(resource, schema) do
[] ->
nil
fields ->
%Absinthe.Blueprint.Schema.InputObjectTypeDefinition{
identifier: resource_filter_type(resource),
module: schema,
name: resource |> resource_filter_type() |> to_string() |> Macro.camelize(),
fields: fields,
__reference__: ref(__ENV__)
}
end
end
# sobelow_skip ["DOS.StringToAtom"]
defp calculation_input(resource, schema) do
resource
|> Ash.Resource.Info.public_calculations()
|> Enum.filter(fn %{calculation: {module, _}} ->
Code.ensure_compiled(module)
:erlang.function_exported(module, :expression, 2)
end)
|> Enum.flat_map(fn calculation ->
field_type = calculation_type(calculation, resource)
arguments = calculation_args(calculation, resource, schema)
array_type? = match?({:array, _}, field_type)
filter_fields =
Ash.Filter.builtin_operators()
|> Enum.filter(& &1.predicate?)
|> restrict_for_lists(field_type)
|> Enum.flat_map(
&filter_fields(
&1,
calculation.type,
array_type?,
schema,
calculation,
resource
)
)
filter_input = %Absinthe.Blueprint.Schema.InputObjectTypeDefinition{
fields: arguments,
identifier:
String.to_atom(
to_string(calculation_filter_field_type(resource, calculation)) <> "_input"
),
module: schema,
name:
Macro.camelize(
to_string(calculation_filter_field_type(resource, calculation)) <> "_input"
),
__reference__: ref(__ENV__)
}
filter_input_field = %Absinthe.Blueprint.Schema.FieldDefinition{
identifier: :input,
module: schema,
name: "input",
type:
String.to_atom(
to_string(calculation_filter_field_type(resource, calculation)) <> "_input"
),
__reference__: ref(__ENV__)
}
[
filter_input,
%Absinthe.Blueprint.Schema.InputObjectTypeDefinition{
fields: [filter_input_field | filter_fields],
identifier: calculation_filter_field_type(resource, calculation),
module: schema,
name: Macro.camelize(to_string(calculation_filter_field_type(resource, calculation))),
__reference__: ref(__ENV__)
}
]
end)
end
defp resource_filter_fields(resource, schema) do
boolean_filter_fields(resource, schema) ++
attribute_filter_fields(resource, schema) ++
relationship_filter_fields(resource, schema) ++
aggregate_filter_fields(resource, schema) ++ calculation_filter_fields(resource, schema)
end
defp attribute_filter_fields(resource, schema) do
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.reject(fn
{:array, _} ->
true
_ ->
false
end)
|> Enum.reject(&Ash.Type.embedded_type?/1)
|> Enum.flat_map(fn attribute ->
[
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: attribute.name,
module: schema,
name: to_string(attribute.name),
type: attribute_filter_field_type(resource, attribute),
__reference__: ref(__ENV__)
}
]
end)
end
defp aggregate_filter_fields(resource, schema) do
if Ash.DataLayer.data_layer_can?(resource, :aggregate_filter) do
resource
|> Ash.Resource.Info.public_aggregates()
|> Enum.flat_map(fn aggregate ->
[
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: aggregate.name,
module: schema,
name: to_string(aggregate.name),
type: attribute_filter_field_type(resource, aggregate),
__reference__: ref(__ENV__)
}
]
end)
else
[]
end
end
defp calculation_filter_fields(resource, schema) do
if Ash.DataLayer.data_layer_can?(resource, :expression_calculation) do
resource
|> Ash.Resource.Info.public_calculations()
|> Enum.filter(fn %{calculation: {module, _}} ->
:erlang.function_exported(module, :expression, 2)
end)
|> Enum.map(fn calculation ->
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: calculation.name,
module: schema,
name: to_string(calculation.name),
type: calculation_filter_field_type(resource, calculation),
__reference__: ref(__ENV__)
}
end)
else
[]
end
end
defp relationship_filter_fields(resource, schema) do
resource
|> Ash.Resource.Info.public_relationships()
|> Enum.filter(fn relationship ->
AshGraphql.Resource.type(relationship.destination)
end)
|> Enum.map(fn relationship ->
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
type: resource_filter_type(relationship.destination),
__reference__: ref(__ENV__)
}
end)
end
defp boolean_filter_fields(resource, schema) do
if Ash.DataLayer.can?(:boolean_filter, resource) do
[
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: :and,
module: schema,
name: "and",
type: %Absinthe.Blueprint.TypeReference.List{
of_type: %Absinthe.Blueprint.TypeReference.NonNull{
of_type: resource_filter_type(resource)
}
},
__reference__: ref(__ENV__)
},
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: :or,
module: schema,
name: "or",
__reference__: ref(__ENV__),
type: %Absinthe.Blueprint.TypeReference.List{
of_type: %Absinthe.Blueprint.TypeReference.NonNull{
of_type: resource_filter_type(resource)
}
}
}
]
else
[]
end
end
# sobelow_skip ["DOS.StringToAtom"]
defp resource_sort_field_type(resource) do
type = AshGraphql.Resource.type(resource)
String.to_atom(to_string(type) <> "_sort_field")
end
def enum_definitions(resource, schema, only_auto? \\ false) do
atom_enums =
resource
|> get_auto_enums()
|> Enum.filter(&is_list(&1.constraints[:one_of]))
|> Enum.map(fn attribute ->
type_name = atom_enum_type(resource, attribute.name)
%Absinthe.Blueprint.Schema.EnumTypeDefinition{
module: schema,
name: type_name |> to_string() |> Macro.camelize(),
values:
Enum.map(attribute.constraints[:one_of], fn value ->
%Absinthe.Blueprint.Schema.EnumValueDefinition{
module: schema,
identifier: value,
name: String.upcase(to_string(value)),
value: value
}
end),
identifier: type_name,
__reference__: ref(__ENV__)
}
end)
if only_auto? do
atom_enums
else
sort_values = sort_values(resource)
sort_order = %Absinthe.Blueprint.Schema.EnumTypeDefinition{
module: schema,
name: resource |> resource_sort_field_type() |> to_string() |> Macro.camelize(),
identifier: resource_sort_field_type(resource),
__reference__: ref(__ENV__),
values:
Enum.map(sort_values, fn sort_value ->
%Absinthe.Blueprint.Schema.EnumValueDefinition{
module: schema,
identifier: sort_value,
name: String.upcase(to_string(sort_value)),
value: sort_value
}
end)
}
[sort_order | atom_enums]
end
end
defp get_auto_enums(resource) do
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.map(fn attribute ->
unnest(attribute)
end)
|> Enum.filter(&(&1.type == Ash.Type.Atom))
end
defp unnest(%{type: {:array, type}, constraints: constraints} = attribute) do
%{attribute | type: type, constraints: constraints[:items] || []}
end
defp unnest(other), do: other
defp sort_values(resource) do
attribute_sort_values =
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.reject(fn
%{type: {:array, _}} ->
true
_ ->
false
end)
|> Enum.reject(&Ash.Type.embedded_type?(&1.type))
|> Enum.map(& &1.name)
aggregate_sort_values =
resource
|> Ash.Resource.Info.public_aggregates()
|> Enum.reject(fn aggregate ->
field_type =
with field when not is_nil(field) <- aggregate.field,
related when not is_nil(related) <-
Ash.Resource.Info.related(resource, aggregate.relationship_path),
attr when not is_nil(attr) <- Ash.Resource.Info.attribute(related, aggregate.field) do
attr.type
end
case Ash.Query.Aggregate.kind_to_type(aggregate.kind, field_type) do
{:ok, {:array, _}} ->
true
{:ok, type} ->
Ash.Type.embedded_type?(type)
_ ->
true
end
end)
|> Enum.map(& &1.name)
attribute_sort_values ++ aggregate_sort_values
end
# sobelow_skip ["DOS.StringToAtom"]
defp page_of(resource, schema) do
type = Resource.type(resource)
paginatable? =
resource
|> Ash.Resource.Info.actions()
|> Enum.any?(fn action ->
action.type == :read && action.pagination
end)
if paginatable? do
%Absinthe.Blueprint.Schema.ObjectTypeDefinition{
description: "A page of #{inspect(type)}",
fields: [
%Absinthe.Blueprint.Schema.FieldDefinition{
description: "The records contained in the page",
identifier: :results,
module: schema,
name: "results",
__reference__: ref(__ENV__),
type: %Absinthe.Blueprint.TypeReference.List{
of_type: %Absinthe.Blueprint.TypeReference.NonNull{
of_type: type
}
}
},
%Absinthe.Blueprint.Schema.FieldDefinition{
description: "The count of records",
identifier: :count,
module: schema,
name: "count",
type: :integer,
__reference__: ref(__ENV__)
}
],
identifier: String.to_atom("page_of_#{type}"),
module: schema,
name: Macro.camelize("page_of_#{type}"),
__reference__: ref(__ENV__)
}
else
nil
end
end
def type_definition(resource, api, schema) do
type = Resource.type(resource)
%Absinthe.Blueprint.Schema.ObjectTypeDefinition{
description: Ash.Resource.Info.description(resource),
fields: fields(resource, api, schema),
identifier: type,
module: schema,
name: Macro.camelize(to_string(type)),
__reference__: ref(__ENV__)
}
end
defp fields(resource, api, schema) do
attributes(resource, schema) ++
relationships(resource, api, schema) ++
aggregates(resource, schema) ++
calculations(resource, schema)
end
defp attributes(resource, schema) do
non_id_attributes =
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.reject(& &1.primary_key?)
|> Enum.map(fn attribute ->
field_type =
attribute.type
|> field_type(attribute, resource)
|> maybe_wrap_non_null(not attribute.allow_nil?)
%Absinthe.Blueprint.Schema.FieldDefinition{
description: attribute.description,
identifier: attribute.name,
module: schema,
name: to_string(attribute.name),
type: field_type,
__reference__: ref(__ENV__)
}
end)
pkey_fields = pkey_fields(resource, schema)
non_id_attributes ++ pkey_fields
end
defp pkey_fields(resource, schema, require? \\ true) do
case Ash.Resource.Info.primary_key(resource) do
[field] ->
attribute = Ash.Resource.Info.attribute(resource, field)
if attribute.private? do
[]
else
field_type =
attribute.type
|> field_type(attribute, resource)
|> maybe_wrap_non_null(require? && not attribute.allow_nil?)
[
%Absinthe.Blueprint.Schema.FieldDefinition{
description: attribute.description,
identifier: attribute.name,
module: schema,
name: to_string(attribute.name),
type: field_type,
__reference__: ref(__ENV__)
}
]
end
fields ->
added_pkey_fields =
if :id in fields do
[]
else
for field <- fields do
attribute = Ash.Resource.Info.attribute(resource, field)
field_type =
attribute.type
|> field_type(attribute, resource)
|> maybe_wrap_non_null(require? && not attribute.allow_nil?)
%Absinthe.Blueprint.Schema.FieldDefinition{
description: attribute.description,
identifier: attribute.name,
module: schema,
name: to_string(attribute.name),
type: field_type,
__reference__: ref(__ENV__)
}
end
end
[
%Absinthe.Blueprint.Schema.FieldDefinition{
description: "The primary key of the resource",
identifier: :id,
module: schema,
name: "id",
type: :id,
__reference__: ref(__ENV__)
}
] ++ added_pkey_fields
end
end
defp argument_required?(%{allow_nil?: true}), do: false
defp argument_required?(%{default: default}) when not is_nil(default), do: false
defp argument_required?(_), do: true
# sobelow_skip ["DOS.StringToAtom"]
defp relationships(resource, api, schema) do
resource
|> Ash.Resource.Info.public_relationships()
|> Enum.filter(fn relationship ->
Resource in Ash.Resource.Info.extensions(relationship.destination)
end)
|> Enum.map(fn
%{cardinality: :one} = relationship ->
type =
relationship.destination
|> Resource.type()
|> maybe_wrap_non_null(relationship.type == :belongs_to && relationship.required?)
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve_assoc}, {api, relationship}}
],
arguments: [],
type: type,
__reference__: ref(__ENV__)
}
%{cardinality: :many} = relationship ->
read_action = Ash.Resource.Info.primary_action!(relationship.destination, :read)
type = Resource.type(relationship.destination)
query_type = %Absinthe.Blueprint.TypeReference.NonNull{
of_type: %Absinthe.Blueprint.TypeReference.List{
of_type: %Absinthe.Blueprint.TypeReference.NonNull{
of_type: type
}
}
}
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve_assoc}, {api, relationship}}
],
arguments: args(:list_related, relationship.destination, read_action, schema),
type: query_type,
__reference__: ref(__ENV__)
}
end)
end
defp aggregates(resource, schema) do
resource
|> Ash.Resource.Info.public_aggregates()
|> Enum.map(fn aggregate ->
field_type =
with field when not is_nil(field) <- aggregate.field,
related when not is_nil(related) <-
Ash.Resource.Info.related(resource, aggregate.relationship_path),
attr when not is_nil(attr) <- Ash.Resource.Info.attribute(related, aggregate.field) do
attr.type
end
{:ok, type} = Aggregate.kind_to_type(aggregate.kind, field_type)
type =
if is_nil(Ash.Query.Aggregate.default_value(aggregate.kind)) do
field_type(type, aggregate, resource)
else
%Absinthe.Blueprint.TypeReference.NonNull{
of_type: field_type(type, aggregate, resource)
}
end
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: aggregate.name,
module: schema,
name: to_string(aggregate.name),
type: type,
__reference__: ref(__ENV__)
}
end)
end
defp calculations(resource, schema) do
resource
|> Ash.Resource.Info.public_calculations()
|> Enum.map(fn calculation ->
field_type = calculation_type(calculation, resource)
arguments = calculation_args(calculation, resource, schema)
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: calculation.name,
module: schema,
arguments: arguments,
name: to_string(calculation.name),
type: field_type,
__reference__: ref(__ENV__)
}
end)
end
defp calculation_type(calculation, resource) do
calculation.type
|> Ash.Type.get_type()
|> field_type(nil, resource)
|> maybe_wrap_non_null(not calculation.allow_nil?)
end
defp calculation_args(calculation, resource, schema) do
Enum.map(calculation.arguments, fn argument ->
type =
argument.type
|> field_type(argument, resource, true)
|> maybe_wrap_non_null(argument_required?(argument))
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: argument.name,
module: schema,
name: to_string(argument.name),
type: type,
__reference__: ref(__ENV__)
}
end)
end
defp field_type(type, field, resource, input? \\ false)
defp field_type(
{:array, type},
%Ash.Resource.Aggregate{kind: :list} = aggregate,
resource,
input?
) do
with related when not is_nil(related) <-
Ash.Resource.Info.related(resource, aggregate.relationship_path),
attr when not is_nil(related) <- Ash.Resource.Info.attribute(related, aggregate.field) do
if attr.allow_nil? do
%Absinthe.Blueprint.TypeReference.List{
of_type: field_type(type, aggregate, resource, input?)
}
else
%Absinthe.Blueprint.TypeReference.List{
of_type: %Absinthe.Blueprint.TypeReference.NonNull{
of_type: field_type(type, aggregate, resource, input?)
}
}
end
end
end
defp field_type({:array, type}, %Ash.Resource.Aggregate{} = aggregate, resource, input?) do
%Absinthe.Blueprint.TypeReference.List{
of_type: field_type(type, aggregate, resource, input?)
}
end
defp field_type({:array, type}, nil, resource, input?) do
field_type = field_type(type, nil, resource, input?)
%Absinthe.Blueprint.TypeReference.List{
of_type: field_type
}
end
defp field_type({:array, type}, attribute, resource, input?) do
new_constraints = attribute.constraints[:items] || []
new_attribute = %{attribute | constraints: new_constraints, type: type}
field_type =
type
|> field_type(new_attribute, resource, input?)
|> maybe_wrap_non_null(
!attribute.constraints[:nil_items?] || Ash.Type.embedded_type?(attribute.type)
)
%Absinthe.Blueprint.TypeReference.List{
of_type: field_type
}
end
# sobelow_skip ["DOS.BinToAtom"]
defp field_type(type, attribute, resource, input?) do
if Ash.Type.builtin?(type) do
do_field_type(type, attribute, resource)
else
if Ash.Type.embedded_type?(type) do
if input? do
:"#{AshGraphql.Resource.type(resource)}_#{attribute.name}_input"
else
case type(type) do
nil ->
Application.get_env(:ash_graphql, :json_type) || :json_string
type ->
type
end
end
else
if :erlang.function_exported(type, :values, 0) do
if :erlang.function_exported(type, :graphql_type, 0) do
type.graphql_type()
else
:string
end
else
function =
if input? do
:graphql_input_type
else
:graphql_type
end
if :erlang.function_exported(type, function, 1) do
apply(type, function, [attribute.constraints])
else
raise """
Could not determine graphql type for #{inspect(type)}, please define: #{function}/1!
"""
end
end
end
end
end
defp do_field_type(
Ash.Type.Atom,
%Ash.Resource.Attribute{constraints: constraints, name: name},
resource
) do
if is_list(constraints[:one_of]) do
atom_enum_type(resource, name)
else
:string
end
end
defp do_field_type(Ash.Type.Boolean, _, _), do: :boolean
defp do_field_type(Ash.Type.Atom, _, _), do: :string
defp do_field_type(Ash.Type.CiString, _, _), do: :string
defp do_field_type(Ash.Type.Date, _, _), do: :date
defp do_field_type(Ash.Type.Decimal, _, _), do: :decimal
defp do_field_type(Ash.Type.Integer, _, _), do: :integer
defp do_field_type(Ash.Type.Map, _, _),
do: Application.get_env(:ash_graphql, :json_type) || :json_string
defp do_field_type(Ash.Type.String, _, _), do: :string
defp do_field_type(Ash.Type.Term, _, _), do: :string
defp do_field_type(Ash.Type.UtcDatetime, _, _), do: :naive_datetime
defp do_field_type(Ash.Type.UtcDatetimeUsec, _, _), do: :naive_datetime
defp do_field_type(Ash.Type.UUID, _, _), do: :string
defp do_field_type(Ash.Type.Float, _, _), do: :float
# sobelow_skip ["DOS.StringToAtom"]
defp atom_enum_type(resource, attribute_name) do
resource
|> AshGraphql.Resource.type()
|> to_string()
|> Kernel.<>("_")
|> Kernel.<>(to_string(attribute_name))
|> String.to_atom()
end
end