feat: initial POC release

This commit is contained in:
Zach Daniel 2020-08-13 17:39:59 -04:00
parent 759c92553c
commit cd16030324
No known key found for this signature in database
GPG key ID: C377365383138D4B
14 changed files with 1113 additions and 675 deletions

View file

@ -1,23 +1,137 @@
defmodule AshGraphql.Api do
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
@extensions AshGraphql.Api
@authorize Keyword.get(opts, :authorize?, true)
@max_complexity Keyword.get(opts, :max_complexity, 50)
end
@moduledoc "The entrypoint for adding graphql behavior to an Ash API"
@graphql %Ash.Dsl.Section{
name: :graphql,
describe: """
Global configuration for graphql
""",
schema: [
authorize?: [
type: :boolean,
doc: "Whether or not to perform authorization for this API",
default: true
]
]
}
use Ash.Dsl.Extension, sections: [@graphql]
def authorize?(api) do
Extension.get_opt(api, :api, :authorize?, true)
end
def before_compile_hook(_env) do
quote do
use AshGraphql.Api.Schema, resources: @resources, api: __MODULE__
@doc false
def queries(api, schema) do
api
|> Ash.Api.resources()
|> Enum.flat_map(&AshGraphql.Resource.queries(api, &1, schema))
end
def graphql_authorize? do
@authorize
end
@doc false
def mutations(api, schema) do
api
|> Ash.Api.resources()
|> Enum.filter(fn resource ->
AshGraphql.Resource in Ash.Resource.extensions(resource)
end)
|> Enum.flat_map(&AshGraphql.Resource.mutations(api, &1, schema))
end
def graphql_max_complexity() do
@max_complexity
end
end
@doc false
def type_definitions(api, schema) do
resource_types =
api
|> Ash.Api.resources()
|> Enum.filter(fn resource ->
AshGraphql.Resource in Ash.Resource.extensions(resource)
end)
|> Enum.flat_map(fn resource ->
AshGraphql.Resource.type_definitions(resource, schema) ++
AshGraphql.Resource.mutation_types(resource, schema)
end)
[mutation_error(schema), relationship_change(schema)] ++ resource_types
end
defp relationship_change(schema) do
%Absinthe.Blueprint.Schema.ObjectTypeDefinition{
description: "A set of changes to apply to a relationship",
fields: relationship_change_fields(schema),
identifier: :relationship_change,
module: schema,
name: "RelationshipChange"
}
end
defp relationship_change_fields(schema) do
[
%Absinthe.Blueprint.Schema.FieldDefinition{
description: "Ids to add to the relationship",
identifier: :add,
module: schema,
name: "add",
type: %Absinthe.Blueprint.TypeReference.List{
of_type: :id
}
},
%Absinthe.Blueprint.Schema.FieldDefinition{
description: "Ids to remove from the relationship",
identifier: :remove,
module: schema,
name: "remove",
type: %Absinthe.Blueprint.TypeReference.List{
of_type: :id
}
},
%Absinthe.Blueprint.Schema.FieldDefinition{
description:
"Ids to replace the relationship with. Takes precendence over removal and addition",
identifier: :replace,
module: schema,
name: "replace",
type: %Absinthe.Blueprint.TypeReference.List{
of_type: :id
}
}
]
end
defp mutation_error(schema) do
%Absinthe.Blueprint.Schema.ObjectTypeDefinition{
description: "An error generated by a failed mutation",
fields: error_fields(schema),
identifier: :mutation_error,
module: schema,
name: "MutationError"
}
end
defp error_fields(schema) do
[
%Absinthe.Blueprint.Schema.FieldDefinition{
description: "The human readable error message",
identifier: :message,
module: schema,
name: "message",
type: :string
},
%Absinthe.Blueprint.Schema.FieldDefinition{
description: "An error code for the given error",
identifier: :code,
module: schema,
name: "code",
type: :string
},
%Absinthe.Blueprint.Schema.FieldDefinition{
description: "The field or fields that produced the error",
identifier: :fields,
module: schema,
name: "fields",
type: %Absinthe.Blueprint.TypeReference.List{
of_type: :string
}
}
]
end
end

View file

@ -1,532 +0,0 @@
defmodule AshGraphql.Api.Schema do
defmacro __using__(opts) do
quote bind_quoted: [api: opts[:api]] do
defmodule __MODULE__.Schema do
# use Absinthe.Schema
@api api
def __absinthe_lookup__(type) do
__absinthe_type__(type)
end
def __absinthe_types__() do
AshGraphql.Api.Schema.absinthe_types(@api)
end
def __absinthe_type__(type) do
AshGraphql.Api.Schema.absinthe_type(@api, type)
end
def __absinthe_directives__() do
AshGraphql.Api.Schema.Base.__absinthe_directives__()
end
def __absinthe_directive__(dir) do
AshGraphql.Api.Schema.Base.__absinthe_directive__(dir)
end
def context(context) do
context
end
def plugins() do
[Absinthe.Middleware.Batch, Absinthe.Middleware.Async]
end
end
end
end
defmodule Base do
use Absinthe.Schema
query do
end
# mutation do
# end
# subscription do
# end
end
def absinthe_types(api, _), do: absinthe_types(api)
def absinthe_types(api) do
base_types = %{
__directive: "__Directive",
__directive_location: "__DirectiveLocation",
__enumvalue: "__EnumValue",
__field: "__Field",
__inputvalue: "__InputValue",
__schema: "__Schema",
__type: "__Type",
boolean: "Boolean",
id: "ID",
query: "RootQueryType",
# mutation: "RootMutationType",
# subscription: "RootSusbcriptionType",
string: "String"
}
api
|> resource_types()
|> Enum.reduce(base_types, fn %{identifier: identifier, name: name}, acc ->
Map.put(acc, identifier, name)
end)
end
def absinthe_type(api, :query) do
%Absinthe.Type.Object{
__private__: [__absinthe_referenced__: true],
__reference__: %{
location: %{file: "nofile", line: 1},
module: Module.concat(api, Schema)
},
definition: Module.concat(api, Schema),
description: nil,
fields:
%{
__schema: schema_type(),
__type: type_type(),
__typename: type_name_type()
}
|> add_query_fields(api),
identifier: :query,
interfaces: [],
is_type_of: :object,
name: "RootQueryType"
}
end
def absinthe_type(api, type) do
api
|> resource_types()
|> Enum.find(fn %{identifier: identifier} ->
identifier == type
end)
|> case do
nil ->
AshGraphql.Api.Schema.Base.__absinthe_type__(type)
type ->
type
end
end
defp resource_types(api) do
api
|> Ash.resources()
|> Enum.filter(&(AshGraphql.GraphqlResource in &1.extensions))
|> Enum.flat_map(&resource_types(api, &1))
end
defp add_query_fields(acc, api) do
api
|> Ash.resources()
|> Enum.filter(&(AshGraphql.GraphqlResource in &1.extensions))
|> Enum.flat_map(&query_fields(api, &1))
|> Enum.reduce(acc, fn query_field, acc ->
Map.put(acc, query_field.identifier, query_field)
end)
end
defp query_fields(api, resource) do
resource
|> AshGraphql.fields()
|> Enum.map(fn field ->
get_one_identifier = AshGraphql.type(resource)
case field.type do
:get ->
%Absinthe.Type.Field{
__private__: [],
__reference__: %{
location: %{file: "nofile", line: 1},
module: AshExample.Api.Schema
},
args: %{
id: %Absinthe.Type.Argument{
__reference__: nil,
default_value: nil,
definition: nil,
deprecation: nil,
description: nil,
identifier: :id,
name: "id",
type: %Absinthe.Type.NonNull{of_type: :id}
}
},
# TODO: DO THIS
complexity: 2,
config: %{},
default_value: nil,
definition: AshExample.Api.Schema,
deprecation: nil,
description: nil,
identifier: field.name,
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, :get, field.action}}
],
name: Atom.to_string(field.name),
triggers: [],
type: get_one_identifier
}
:read ->
%Absinthe.Type.Field{
__private__: [],
__reference__: %{
location: %{file: "nofile", line: 1},
module: AshExample.Api.Schema
},
args: %{
limit: %Absinthe.Type.Argument{
identifier: :limit,
type: :integer,
name: "limit"
},
offset: %Absinthe.Type.Argument{
identifier: :offset,
default_value: 0,
type: :integer,
name: "offset"
}
# TODO: Generate types for the filter, sort, and paginate args
# Also figure out graphql pagination
# filter: %Absinthe.Type.Argument
# id: %Absinthe.Type.Argument{
# __reference__: nil,
# default_value: nil,
# definition: nil,
# deprecation: nil,
# description: nil,
# identifier: :id,
# name: "id",
# type: %Absinthe.Type.NonNull{of_type: :id}
# }
},
complexity: 1,
config: %{},
default_value: nil,
definition: AshExample.Api.Schema,
deprecation: nil,
description: nil,
identifier: field.name,
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, :read, field.action}}
],
name: Atom.to_string(field.name),
triggers: [],
type: String.to_atom("page_of_#{get_one_identifier}")
}
end
end)
# for field <- AshGraphql.fields(resource) do
# end
# resource
# |> Ash.actions()
# |> Enum.flat_map(fn action ->
# case action do
# %{type: :read, primary?: true} ->
# read_action(api, resource, action)
# _ ->
# # TODO: Only support reads
# []
# end
# end)
end
# defp resource_types(api, resource) do
# resource_type(api, resource)
# # |> add_get_type(api, resource)
# end
defp resource_types(api, resource) do
# NOT DONE
pkey_field = :id
# pkey_field =
# case Ash.primary_key(resource) do
# [field] ->
# field
# primary_key ->
# raise "Invalid primary key #{primary_key} for graphql resource"
# end
get_one_identifier = AshGraphql.type(resource)
[
%Absinthe.Type.Object{
__private__: [__absinthe_referenced__: true],
__reference__: %{
location: %{file: "nofile", line: 1},
module: AshExample.Api.Schema
},
definition: AshExample.Api.Schema,
description: Ash.describe(resource),
fields:
%{
:__typename => type_name_type(),
pkey_field => id_type(pkey_field)
}
|> add_fields(api, resource),
identifier: get_one_identifier,
interfaces: [],
is_type_of: :object,
name: String.capitalize(Atom.to_string(get_one_identifier))
},
page_of(get_one_identifier)
]
end
defp add_fields(fields, _api, _resource) do
fields
# name: %Absinthe.Type.Field{
# __private__: [],
# __reference__: %{
# location: %{file: "nofile", line: 1},
# module: AshExample.Api.Schema
# },
# args: %{},
# complexity:
# {:ref, AshExample.Api.Schema,
# {Absinthe.Blueprint.Schema.FieldDefinition, {:item, :name}}},
# config:
# {:ref, AshExample.Api.Schema,
# {Absinthe.Blueprint.Schema.FieldDefinition, {:item, :name}}},
# default_value: nil,
# definition: AshExample.Api.Schema,
# deprecation: nil,
# description: nil,
# identifier: :name,
# middleware: [{Absinthe.Middleware.MapGet, :name}],
# name: "name",
# triggers:
# {:ref, AshExample.Api.Schema,
# {Absinthe.Blueprint.Schema.FieldDefinition, {:item, :name}}},
# type: :string
# }
end
defp page_of(get_one_identifier) do
%Absinthe.Type.Object{
__private__: [__absinthe_referenced__: true],
__reference__: %{
location: %{file: "nofile", line: 1},
module: AshExample.Api.Schema
},
definition: AshExample.Api.Schema,
description: "A page of #{get_one_identifier}",
fields: %{
__typename: type_name_type(),
offset: %Absinthe.Type.Field{
__private__: [],
__reference__: %{
location: %{file: "nofile", line: 1},
module: AshExample.Api.Schema
},
args: %{},
# TODO: DO THIS
complexity: 1,
config: %{},
default_value: nil,
definition: AshExample.Api.Schema,
deprecation: nil,
description: nil,
identifier: :offset,
middleware: [{Absinthe.Middleware.MapGet, :offset}],
name: "offset",
triggers: [],
type: :integer
},
limit: %Absinthe.Type.Field{
__private__: [],
__reference__: %{
location: %{file: "nofile", line: 1},
module: AshExample.Api.Schema
},
args: %{},
# TODO: DO THIS
complexity: 1,
config: %{},
default_value: nil,
definition: AshExample.Api.Schema,
deprecation: nil,
description: nil,
identifier: :limit,
middleware: [{Absinthe.Middleware.MapGet, :limit}],
name: "limit",
triggers: [],
type: :integer
},
results: %Absinthe.Type.Field{
__private__: [],
__reference__: %{
location: %{file: "nofile", line: 1},
module: AshExample.Api.Schema
},
args: %{},
# TODO: DO THIS
complexity: 1,
config: %{},
default_value: nil,
definition: AshExample.Api.Schema,
deprecation: nil,
description: nil,
identifier: :results,
middleware: [{Absinthe.Middleware.MapGet, :results}],
name: "results",
triggers: [],
type: %Absinthe.Type.NonNull{
of_type: %Absinthe.Type.List{
of_type: %Absinthe.Type.NonNull{of_type: get_one_identifier}
}
}
}
},
identifier: String.to_atom("page_of_#{get_one_identifier}"),
interfaces: [],
is_type_of: :object,
name: "pageOf#{String.capitalize(Atom.to_string(get_one_identifier))}"
}
end
defp id_type(field) do
%Absinthe.Type.Field{
__private__: [],
__reference__: %{
location: %{file: "nofile", line: 1},
module: AshExample.Api.Schema
},
args: %{},
# TODO: do this
complexity: 1,
config: %{},
default_value: nil,
definition: AshExample.Api.Schema,
deprecation: nil,
description: nil,
identifier: :id,
middleware: [{Absinthe.Middleware.MapGet, field}],
name: "id",
triggers: [],
type: :id
}
end
defp schema_type() do
%Absinthe.Type.Field{
__private__: [],
__reference__: %{
location: %{
file:
"/Users/zachdaniel/dev/ash/ash_example/deps/absinthe/lib/absinthe/phase/schema/introspection.ex",
line: 116
},
module: Absinthe.Phase.Schema.Introspection
},
args: %{},
complexity: nil,
config: nil,
default_value: nil,
definition: Absinthe.Phase.Schema.Introspection,
deprecation: nil,
description: "Represents the schema",
identifier: :__schema,
middleware: [
{{Absinthe.Middleware, :shim},
{:query, :__schema, [{:ref, Absinthe.Phase.Schema.Introspection, :schema}]}}
],
name: "__schema",
triggers: %{},
type: :__schema
}
end
def type_name_type() do
%Absinthe.Type.Field{
__private__: [],
__reference__: %{
location: %{
file:
"/Users/zachdaniel/dev/ash/ash_example/deps/absinthe/lib/absinthe/phase/schema/introspection.ex",
line: 74
},
module: Absinthe.Phase.Schema.Introspection
},
args: %{},
complexity: 0,
config: 0,
default_value: nil,
definition: Absinthe.Phase.Schema.Introspection,
deprecation: nil,
description: "The name of the object type currently being queried.",
identifier: :__typename,
middleware: [
{{Absinthe.Middleware, :shim},
{:query, :__typename, [{:ref, Absinthe.Phase.Schema.Introspection, :typename}]}}
],
name: "__typename",
triggers: %{},
type: :string
}
end
def type_type() do
%Absinthe.Type.Field{
__private__: [],
__reference__: %{
location: %{
file:
"/Users/zachdaniel/dev/ash/ash_example/deps/absinthe/lib/absinthe/phase/schema/introspection.ex",
line: 80
},
module: Absinthe.Phase.Schema.Introspection
},
args: %{
name: %Absinthe.Type.Argument{
__reference__: nil,
default_value: nil,
definition: nil,
deprecation: nil,
description: "The name of the type to introspect",
identifier: :name,
name: "name",
type: %Absinthe.Type.NonNull{of_type: :string}
}
},
complexity: nil,
config: nil,
default_value: nil,
definition: Absinthe.Phase.Schema.Introspection,
deprecation: nil,
description: "Represents scalars, interfaces, object types, unions, enums in the system",
identifier: :__type,
middleware: [
{{Absinthe.Middleware, :shim},
{:query, :__type, [{:ref, Absinthe.Phase.Schema.Introspection, :type}]}}
],
name: "__type",
triggers: %{},
type: :__type
}
end
end
# defmacro __using__(opts \\ []) do
# quoted =
# quote do
# for resource <- Ash.resources(unquote(opts[:api])) do
# end
# end
# quote do
# defmodule(__MODULE__.Schema, do: unquote(quoted))
# end
# end
# end

View file

@ -3,11 +3,52 @@ defmodule AshGraphql do
Documentation for `AshGraphql`.
"""
def fields(resource) do
resource.graphql_fields()
end
defmacro __using__(opts) do
quote bind_quoted: [api: opts[:api]] do
defmodule AshTypes do
alias Absinthe.{Phase, Pipeline, Blueprint}
def type(resource) do
resource.graphql_type()
def pipeline(pipeline) do
Pipeline.insert_before(
pipeline,
Phase.Schema.Validation.QueryTypeMustBeObject,
__MODULE__
)
end
def run(blueprint, _opts) do
api = unquote(api)
Code.ensure_compiled(api)
blueprint_with_queries =
api
|> AshGraphql.Api.queries(__MODULE__)
|> Enum.reduce(blueprint, fn query, blueprint ->
Absinthe.Blueprint.add_field(blueprint, "RootQueryType", query)
end)
blueprint_with_mutations =
api
|> AshGraphql.Api.mutations(__MODULE__)
|> Enum.reduce(blueprint_with_queries, fn mutation, blueprint ->
Absinthe.Blueprint.add_field(blueprint, "RootMutationType", mutation)
end)
new_defs =
List.update_at(blueprint_with_mutations.schema_definitions, 0, fn schema_def ->
%{
schema_def
| type_definitions:
schema_def.type_definitions ++
AshGraphql.Api.type_definitions(api, __MODULE__)
}
end)
{:ok, %{blueprint_with_mutations | schema_definitions: new_defs}}
end
end
@pipeline_modifier AshTypes
end
end
end

View file

@ -4,37 +4,66 @@ defmodule AshGraphql.Graphql.Resolver do
{api, resource, :get, action}
) do
opts =
if api.graphql_authorize?() do
if AshGraphql.Api.authorize?(api) do
[actor: Map.get(context, :actor), action: action]
else
[action: action]
end
opts = Keyword.put(opts, :load, load_nested(resource, resolution.definition.selections))
result = api.get(resource, id, opts)
Absinthe.Resolution.put_result(resolution, to_resolution(result))
end
def resolve(
%{arguments: %{limit: limit, offset: offset}, context: context} = resolution,
{api, resource, :read, action}
%{arguments: %{limit: limit, offset: offset} = args, context: context} = resolution,
{api, resource, :list, action}
) do
opts =
if api.graphql_authorize?() do
if AshGraphql.Api.authorize?(api) do
[actor: Map.get(context, :actor), action: action]
else
[action: action]
end
result =
selections =
case Enum.find(resolution.definition.selections, &(&1.schema_node.identifier == :results)) do
nil ->
[]
field ->
field.selections
end
query =
resource
|> api.query
|> Ash.Query.limit(limit)
|> Ash.Query.offset(offset)
|> Ash.Query.load(load_nested(resource, selections))
query =
case Map.fetch(args, :filter) do
{:ok, filter} ->
case Jason.decode(filter) do
{:ok, decoded} ->
Ash.Query.filter(query, to_snake_case(decoded))
{:error, error} ->
raise "Error parsing filter: #{inspect(error)}"
end
_ ->
query
end
result =
query
|> api.read(opts)
|> case do
{:ok, results} ->
{:ok, %AshGraphql.Paginator{results: results, limit: limit, offset: offset}}
{:ok, %AshGraphql.Paginator{results: results, count: Enum.count(results)}}
error ->
error
@ -43,9 +72,233 @@ defmodule AshGraphql.Graphql.Resolver do
Absinthe.Resolution.put_result(resolution, to_resolution(result))
end
def resolve(resolution, _),
do: Absinthe.Resolution.put_result(resolution, {:error, :unknown_request})
def mutate(
%{arguments: %{input: input}, context: context} = resolution,
{api, resource, :create, action}
) do
{attributes, relationships} =
Enum.reduce(input, {%{}, %{}}, fn {key, value}, {attrs, rels} ->
if Ash.Resource.attribute(resource, key) do
{Map.put(attrs, key, value), rels}
else
{attrs, Map.put(rels, key, value)}
end
end)
selections =
case Enum.find(resolution.definition.selections, &(&1.schema_node.identifier == :result)) do
nil ->
[]
field ->
field.selections
end
load = load_nested(resource, selections)
changeset = Ash.Changeset.new(resource, attributes)
changeset_with_relationships =
Enum.reduce(relationships, changeset, fn {relationship, replacement}, changeset ->
Ash.Changeset.replace_relationship(changeset, relationship, replacement)
end)
opts =
if AshGraphql.Api.authorize?(api) do
[actor: Map.get(context, :actor), action: action]
else
[action: action]
end
result =
with {:ok, value} <- api.create(changeset_with_relationships, opts),
{:ok, value} <- api.load(value, load) do
{:ok, %{result: value, errors: []}}
else
{:error, error} ->
{:ok, %{result: nil, errors: to_errors(error)}}
end
Absinthe.Resolution.put_result(resolution, to_resolution(result))
end
def mutate(
%{arguments: %{id: id, input: input}, context: context} = resolution,
{api, resource, :update, action}
) do
case api.get(resource, id) do
nil ->
{:ok, %{result: nil, errors: [to_errors("not found")]}}
initial ->
{attributes, relationships} =
Enum.reduce(input, {%{}, %{}}, fn {key, value}, {attrs, rels} ->
if Ash.Resource.attribute(resource, key) do
{Map.put(attrs, key, value), rels}
else
{attrs, Map.put(rels, key, value)}
end
end)
changeset = Ash.Changeset.new(initial, attributes)
changeset_with_relationships =
Enum.reduce(relationships, changeset, fn {relationship, replacement}, changeset ->
Ash.Changeset.replace_relationship(changeset, relationship, replacement)
end)
opts =
if AshGraphql.Api.authorize?(api) do
[actor: Map.get(context, :actor), action: action]
else
[action: action]
end
selections =
case Enum.find(
resolution.definition.selections,
&(&1.schema_node.identifier == :result)
) do
nil ->
[]
field ->
field.selections
end
load = load_nested(resource, selections)
result =
with {:ok, value} <- api.update(changeset_with_relationships, opts),
{:ok, value} <- api.load(value, load) do
{:ok, %{result: value, errors: []}}
else
{:error, error} ->
{:ok, %{result: nil, errors: List.wrap(error)}}
end
Absinthe.Resolution.put_result(resolution, to_resolution(result))
end
end
def mutate(%{arguments: %{id: id}, context: context} = resolution, {api, resource, action}) do
case api.get(resource, id) do
nil ->
{:ok, %{result: nil, errors: [to_errors("not found")]}}
initial ->
opts =
if AshGraphql.Api.authorize?(api) do
[actor: Map.get(context, :actor), action: action]
else
[action: action]
end
result =
case api.destroy(initial, opts) do
:ok -> {:ok, %{result: initial, errors: []}}
{:error, error} -> {:ok, %{result: nil, errors: to_errors(error)}}
end
Absinthe.Resolution.put_result(resolution, to_resolution(result))
end
end
defp to_errors(errors) do
errors
|> List.wrap()
|> Enum.map(fn error ->
cond do
is_binary(error) ->
%{message: error}
Exception.exception?(error) ->
%{
message: Exception.message(error)
}
true ->
%{message: "something went wrong"}
end
end)
end
def resolve_assoc(%{source: parent} = resolution, {:one, name}) do
Absinthe.Resolution.put_result(resolution, {:ok, Map.get(parent, name)})
end
def resolve_assoc(%{source: parent} = resolution, {:many, name}) do
values = Map.get(parent, name)
paginator = %AshGraphql.Paginator{results: values, count: Enum.count(values)}
Absinthe.Resolution.put_result(resolution, {:ok, paginator})
end
defp load_nested(resource, fields) do
Enum.map(fields, fn field ->
relationship = Ash.Resource.relationship(resource, field.schema_node.identifier)
cond do
!relationship ->
field.schema_node.identifier
relationship.cardinality == :many ->
trimmed_nested =
Enum.flat_map(field.selections, fn nested ->
if nested.schema_node.identifier == :results do
nested.selections
else
[nested]
end
end)
nested_loads = load_nested(relationship.destination, trimmed_nested)
query = Ash.Query.load(relationship.destination, nested_loads)
query =
Enum.reduce(field.arguments, query, fn
%{name: "limit", value: value}, query ->
Ash.Query.limit(query, value)
%{name: "offset", value: value}, query ->
Ash.Query.offset(query, value)
%{name: "filter", value: value}, query ->
case Jason.decode(value) do
{:ok, decoded} ->
Ash.Query.filter(query, to_snake_case(decoded))
{:error, error} ->
raise "Error parsing filter: #{inspect(error)}"
end
end)
{field.schema_node.identifier, query}
true ->
nested_loads = load_nested(relationship.destination, field.selections)
query = Ash.Query.load(relationship.destination, nested_loads)
{field.schema_node.identifier, query}
end
end)
end
defp to_snake_case(map) when is_map(map) do
Enum.into(map, %{}, fn {key, value} ->
{Macro.underscore(key), to_snake_case(value)}
end)
end
defp to_snake_case(list) when is_list(list) do
Enum.map(list, &to_snake_case/1)
end
defp to_snake_case(other), do: other
defp to_resolution({:ok, value}), do: {:ok, value}
defp to_resolution({:error, error}), do: {:error, List.wrap(error)}
defp to_resolution({:error, error}),
do: {:error, error |> List.wrap() |> Enum.map(&Exception.message(&1))}
end

View file

@ -1,63 +0,0 @@
defmodule AshGraphql.GraphqlResource do
@callback graphql_fields() :: [%AshGraphql.GraphqlResource.Field{}]
@callback graphql_type() :: atom
defmacro __using__(_) do
quote do
@extensions AshGraphql.GraphqlResource
@behaviour AshGraphql.GraphqlResource
@graphql_type nil
Module.register_attribute(__MODULE__, :graphql_fields, accumulate: true)
import AshGraphql.GraphqlResource, only: [graphql: 1]
end
end
defmacro graphql(do: body) do
quote do
import AshGraphql.GraphqlResource, only: [fields: 1, type: 1]
unquote(body)
import AshGraphql.GraphqlResource, only: [graphql: 1]
end
end
defmacro fields(do: body) do
quote do
import AshGraphql.GraphqlResource, only: [field: 2, field: 3]
unquote(body)
import AshGraphql.GraphqlResource, only: [fields: 1, type: 1]
end
end
defmacro type(type) do
quote do
@graphql_type unquote(type)
end
end
defmacro field(name, action, opts \\ []) do
quote do
field = AshGraphql.GraphqlResource.Field.new(unquote(name), unquote(action), unquote(opts))
@graphql_fields field
end
end
@doc false
def before_compile_hook(_env) do
quote do
unless @graphql_type do
raise "Must set graphql type for #{__MODULE__}"
end
def graphql_type() do
@graphql_type
end
def graphql_fields() do
@graphql_fields
end
end
end
end

View file

@ -1,15 +0,0 @@
defmodule AshGraphql.GraphqlResource.Field do
defstruct [:name, :action, :type]
def new(name, action, opts) do
if opts[:type] && opts[:type] not in [:read, :get] do
raise "Can only specify `read` or `get` for `type`"
end
%__MODULE__{
name: name,
action: action,
type: opts[:type] || :read
}
end
end

View file

@ -1,27 +0,0 @@
# defmodule AshGraphql.GraphqlResource.ResourceTypes do
# defmacro define_types(name, attributes, relationships) do
# quote do
# name = unquote(name)
# attributes = Enum.map(unquote(attributes), &Map.to_list/1)
# relationships = Enum.map(unquote(relationships), &Map.to_list/1)
# defmodule __MODULE__.GraphqlTypes do
# use Absinthe.Schema.Notation
# quote do
# object unquote(String.to_atom(name)) do
# for attribute <- unquote(attributes) do
# if attribute[:name] == :id and attribute[:primary_key?] do
# field :id, :id
# else
# quote do
# field unquote(attribute[:name]), unquote(attribute[:type])
# end
# end
# end
# end
# end
# end
# end
# end
# end

View file

@ -1,3 +1,3 @@
defmodule AshGraphql.Paginator do
defstruct [:limit, :results, :total, offset: 0]
defstruct [:results, :count]
end

46
lib/resource/mutation.ex Normal file
View file

@ -0,0 +1,46 @@
defmodule AshGraphql.Resource.Mutation do
defstruct [:name, :action, :type]
@create_schema [
name: [
type: :atom,
doc: "The name to use for the mutation.",
default: :get
],
action: [
type: :atom,
doc: "The action to use for the mutation.",
required: true
]
]
@update_schema [
name: [
type: :atom,
doc: "The name to use for the mutation.",
default: :get
],
action: [
type: :atom,
doc: "The action to use for the mutation.",
required: true
]
]
@destroy_schema [
name: [
type: :atom,
doc: "The name to use for the mutation.",
default: :get
],
action: [
type: :atom,
doc: "The action to use for the mutation.",
required: true
]
]
def create_schema, do: @create_schema
def update_schema, do: @update_schema
def destroy_schema, do: @destroy_schema
end

32
lib/resource/query.ex Normal file
View file

@ -0,0 +1,32 @@
defmodule AshGraphql.Resource.Query do
defstruct [:name, :action, :type]
@get_schema [
name: [
type: :atom,
doc: "The name to use for the query.",
default: :get
],
action: [
type: :atom,
doc: "The action to use for the query.",
required: true
]
]
@list_schema [
name: [
type: :atom,
doc: "The name to use for the query.",
default: :list
],
action: [
type: :atom,
doc: "The action to use for the query.",
required: true
]
]
def get_schema, do: @get_schema
def list_schema, do: @list_schema
end

555
lib/resource/resource.ex Normal file
View file

@ -0,0 +1,555 @@
defmodule AshGraphql.Resource do
@get %Ash.Dsl.Entity{
name: :get,
args: [:name, :action],
describe: "A query to fetch a record by primary key",
examples: [
"get :get_post, :default"
],
schema: AshGraphql.Resource.Query.get_schema(),
target: AshGraphql.Resource.Query,
auto_set_fields: [
type: :get
]
}
@list %Ash.Dsl.Entity{
name: :list,
schema: AshGraphql.Resource.Query.list_schema(),
args: [:name, :action],
describe: "A query to fetch a list of records",
examples: [
"list :list_posts, :default"
],
target: AshGraphql.Resource.Query,
auto_set_fields: [
type: :list
]
}
@create %Ash.Dsl.Entity{
name: :create,
schema: AshGraphql.Resource.Mutation.create_schema(),
args: [:name, :action],
describe: "A mutation to create a record",
examples: [
"create :create_post, :default"
],
target: AshGraphql.Resource.Mutation,
auto_set_fields: [
type: :create
]
}
@update %Ash.Dsl.Entity{
name: :update,
schema: AshGraphql.Resource.Mutation.update_schema(),
args: [:name, :action],
describe: "A mutation to update a record",
examples: [
"update :update_post, :default"
],
target: AshGraphql.Resource.Mutation,
auto_set_fields: [
type: :update
]
}
@destroy %Ash.Dsl.Entity{
name: :destroy,
schema: AshGraphql.Resource.Mutation.destroy_schema(),
args: [:name, :action],
describe: "A mutation to destroy a record",
examples: [
"destroy :destroy_post, :default"
],
target: AshGraphql.Resource.Mutation,
auto_set_fields: [
type: :destroy
]
}
@queries %Ash.Dsl.Section{
name: :queries,
describe: """
Queries (read actions) to expose for the resource.
""",
entities: [
@get,
@list
]
}
@mutations %Ash.Dsl.Section{
name: :mutations,
describe: """
Mutations (create/update/destroy actions) to expose for the resource.
""",
entities: [
@create,
@update,
@destroy
]
}
@graphql %Ash.Dsl.Section{
name: :graphql,
describe: """
Configuration for a given resource in graphql
""",
schema: [
type: [
type: :atom,
required: true,
doc: "The type to use for this entity in the graphql schema"
],
fields: [
type: {:custom, __MODULE__, :__fields, []},
required: true,
doc: "The fields from this entity to include in the graphql"
]
],
sections: [
@queries,
@mutations
]
}
@doc false
def __fields(fields) do
fields = List.wrap(fields)
if Enum.all?(fields, &is_atom/1) do
{:ok, fields}
else
{:error, "Expected `fields` to be a list of atoms"}
end
end
@transformers [
AshJsonApi.Resource.Transformers.RequireIdPkey
]
use Ash.Dsl.Extension, sections: [@graphql], transformers: @transformers
def queries(resource) do
Ash.Dsl.Extension.get_entities(resource, [:graphql, :queries])
end
def mutations(resource) do
Ash.Dsl.Extension.get_entities(resource, [:graphql, :mutations])
end
def type(resource) do
Ash.Dsl.Extension.get_opt(resource, [:graphql], :type, nil)
end
def fields(resource) do
Ash.Dsl.Extension.get_opt(resource, [:graphql], :fields, [])
end
@doc false
def queries(api, resource, schema) do
type = AshGraphql.Resource.type(resource)
resource
|> queries()
|> Enum.map(fn query ->
%Absinthe.Blueprint.Schema.FieldDefinition{
arguments: args(query.type),
identifier: query.name,
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, query.type, query.action}}
],
module: schema,
name: to_string(query.name),
type: query_type(query.type, type)
}
end)
end
@doc false
def mutations(api, resource, schema) do
resource
|> mutations()
|> Enum.map(fn
%{type: :destroy} = mutation ->
%Absinthe.Blueprint.Schema.FieldDefinition{
arguments: [
%Absinthe.Blueprint.Schema.InputValueDefinition{
identifier: :id,
module: schema,
name: "id",
placement: :argument_definition,
type: :id
}
],
identifier: mutation.name,
middleware: [
{{AshGraphql.Graphql.Resolver, :mutate},
{api, resource, mutation.type, mutation.action}}
],
module: schema,
name: to_string(mutation.name),
type: String.to_atom("#{mutation.name}_result")
}
%{type: :create} = mutation ->
%Absinthe.Blueprint.Schema.FieldDefinition{
arguments: [
%Absinthe.Blueprint.Schema.InputValueDefinition{
identifier: :input,
module: schema,
name: "input",
placement: :argument_definition,
type: String.to_atom("#{mutation.name}_input")
}
],
identifier: mutation.name,
middleware: [
{{AshGraphql.Graphql.Resolver, :mutate},
{api, resource, mutation.type, mutation.action}}
],
module: schema,
name: to_string(mutation.name),
type: String.to_atom("#{mutation.name}_result")
}
mutation ->
%Absinthe.Blueprint.Schema.FieldDefinition{
arguments: [
%Absinthe.Blueprint.Schema.InputValueDefinition{
identifier: :id,
module: schema,
name: "id",
placement: :argument_definition,
type: :id
},
%Absinthe.Blueprint.Schema.InputValueDefinition{
identifier: :input,
module: schema,
name: "input",
placement: :argument_definition,
type: String.to_atom("#{mutation.name}_input")
}
],
identifier: mutation.name,
middleware: [
{{AshGraphql.Graphql.Resolver, :mutate},
{api, resource, mutation.type, mutation.action}}
],
module: schema,
name: to_string(mutation.name),
type: String.to_atom("#{mutation.name}_result")
}
end)
end
@doc false
def mutation_types(resource, schema) do
resource
|> mutations()
|> Enum.flat_map(fn mutation ->
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: AshGraphql.Resource.type(resource)
},
%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
}
}
],
identifier: String.to_atom("#{mutation.name}_result"),
module: schema,
name: Macro.camelize("#{mutation.name}_result")
}
if mutation.type == :destroy do
[result]
else
input = %Absinthe.Blueprint.Schema.InputObjectTypeDefinition{
fields: mutation_fields(resource, schema, mutation),
identifier: String.to_atom("#{mutation.name}_input"),
module: schema,
name: Macro.camelize("#{mutation.name}_input")
}
[input, result]
end
end)
end
defp mutation_fields(resource, schema, query) do
fields = AshGraphql.Resource.fields(resource)
attribute_fields =
resource
|> Ash.Resource.attributes()
|> Enum.filter(&(&1.name in fields))
|> Enum.filter(& &1.writable?)
|> Enum.map(fn attribute ->
type = field_type(attribute.type)
field_type =
if attribute.allow_nil? || query.type == :update do
type
else
%Absinthe.Blueprint.TypeReference.NonNull{
of_type: type
}
end
%Absinthe.Blueprint.Schema.FieldDefinition{
description: attribute.description,
identifier: attribute.name,
module: schema,
name: to_string(attribute.name),
type: field_type
}
end)
relationship_fields =
resource
|> Ash.Resource.relationships()
|> Enum.filter(&(&1.name in fields))
|> Enum.filter(fn relationship ->
AshGraphql.Resource in Ash.Resource.extensions(relationship.destination)
end)
|> Enum.map(fn
%{cardinality: :one} = relationship ->
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
type: :id
}
%{cardinality: :many} = relationship ->
case query.type do
:update ->
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
type: :relationship_change
}
:create ->
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
type: %Absinthe.Blueprint.TypeReference.List{
of_type: :id
}
}
end
end)
attribute_fields ++ relationship_fields
end
defp query_type(:get, type), do: type
defp query_type(:list, type), do: String.to_atom("page_of_#{type}")
defp args(:get) do
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "id",
identifier: :id,
type: :id,
description: "The id of the record"
}
]
end
defp args(:list) do
[
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "limit",
identifier: :limit,
type: :integer,
description: "The limit of records to return",
default_value: 20
},
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "offset",
identifier: :offset,
type: :integer,
description: "The count of records to skip",
default_value: 0
},
%Absinthe.Blueprint.Schema.InputValueDefinition{
name: "filter",
identifier: :filter,
type: :string,
description: "A json encoded filter to apply"
}
]
end
@doc false
def type_definitions(resource, schema) do
[
type_definition(resource, schema),
page_of(resource, schema)
]
end
defp page_of(resource, schema) do
type = AshGraphql.Resource.type(resource)
%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",
type: %Absinthe.Blueprint.TypeReference.List{
of_type: type
}
},
%Absinthe.Blueprint.Schema.FieldDefinition{
description: "The count of records",
identifier: :count,
module: schema,
name: "count",
type: :integer
}
],
identifier: String.to_atom("page_of_#{type}"),
module: schema,
name: Macro.camelize("page_of_#{type}")
}
end
defp type_definition(resource, schema) do
type = AshGraphql.Resource.type(resource)
%Absinthe.Blueprint.Schema.ObjectTypeDefinition{
description: Ash.Resource.description(resource),
fields: fields(resource, schema),
identifier: type,
module: schema,
name: Macro.camelize(to_string(type))
}
end
defp fields(resource, schema) do
fields = AshGraphql.Resource.fields(resource)
attributes(resource, schema, fields) ++
relationships(resource, schema, fields) ++
aggregates(resource, schema, fields)
end
defp attributes(resource, schema, fields) do
resource
|> Ash.Resource.attributes()
|> Enum.filter(&(&1.name in fields))
|> Enum.map(fn
%{name: :id} = attribute ->
%Absinthe.Blueprint.Schema.FieldDefinition{
description: attribute.description,
identifier: :id,
module: schema,
name: "id",
type: :id
}
attribute ->
%Absinthe.Blueprint.Schema.FieldDefinition{
description: attribute.description,
identifier: attribute.name,
module: schema,
name: to_string(attribute.name),
type: field_type(attribute.type)
}
end)
end
defp relationships(resource, schema, fields) do
resource
|> Ash.Resource.relationships()
|> Enum.filter(&(&1.name in fields))
|> Enum.filter(fn relationship ->
AshGraphql.Resource in Ash.Resource.extensions(relationship.destination)
end)
|> Enum.map(fn
%{cardinality: :one} = relationship ->
type = AshGraphql.Resource.type(relationship.destination)
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve_assoc}, {:one, relationship.name}}
],
arguments: [],
type: type
}
%{cardinality: :many} = relationship ->
type = AshGraphql.Resource.type(relationship.destination)
query_type = String.to_atom("page_of_#{type}")
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
module: schema,
name: to_string(relationship.name),
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve_assoc}, {:many, relationship.name}}
],
arguments: args(:list),
type: query_type
}
end)
end
defp aggregates(resource, schema, fields) do
resource
|> Ash.Resource.aggregates()
|> Enum.filter(&(&1.name in fields))
|> Enum.map(fn aggregate ->
{:ok, type} = Ash.Query.Aggregate.kind_to_type(aggregate.kind)
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: aggregate.name,
module: schema,
name: to_string(aggregate.name),
type: field_type(type)
}
end)
end
defp field_type(Ash.Type.String), do: :string
defp field_type(Ash.Type.UUID), do: :string
defp field_type(Ash.Type.Integer), do: :integer
defp field_type({:array, type}) do
%Absinthe.Blueprint.TypeReference.List{
of_type: field_type(type)
}
end
end

View file

@ -0,0 +1,20 @@
defmodule AshGraphql.Resource.Transformers.RequireIdPkey do
@moduledoc "Ensures that the resource has a primary key called `id`"
use Ash.Dsl.Transformer
alias Ash.Dsl.Transformer
def transform(_resource, dsl) do
primary_key =
dsl
|> Transformer.get_entities([:attributes])
|> Enum.filter(& &1.primary_key?)
|> Enum.map(& &1.name)
unless primary_key == [:id] do
raise "AshGraphql currently requires the primary key to be a field called `id`"
end
{:ok, dsl}
end
end

15
mix.exs
View file

@ -21,8 +21,21 @@ defmodule AshGraphql.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:absinthe, "~> 1.5.0-rc.5"},
{:absinthe, "~> 1.5.2"},
{:jason, "~> 1.2"},
{:ash, path: "../ash"}
]
end
end
# defmodule Foo do
# use Absinthe.Schema.Notation
# object :foo do
# field :item, :string do
# resolve fn _, _, _ ->
# "hello"
# end
# end
# end
# end

View file

@ -1,13 +1,14 @@
%{
"absinthe": {:hex, :absinthe, "1.5.0-rc.5", "90b6335d452bfe72532257bb60c54793f6b8c4992b35b6861dc9adea5d01f463", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7caf5b2d7d4be5a401dc4a17d219d44650af5dce4fe635687ca543c1823047a6"},
"absinthe": {:hex, :absinthe, "1.5.2", "2f9449b0c135ea61c09c11968d3d4fe6abd5bed38cf9be1c6d6b7c5ec858cfa0", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669c84879629b7fffdc6cda9361ab9c81c9c7691e65418ba089b912a227963ac"},
"ashton": {:hex, :ashton, "0.4.1", "d0f7782ac44fa22da7ce544028ee3d2078592a834d8adf3e5b4b6aeb94413a55", [:mix], [], "hexpm", "24db667932517fdbc3f2dae777f28b8d87629271387d4490bc4ae8d9c46ff3d3"},
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
"ecto": {:hex, :ecto, "3.4.3", "3a14c2500c3964165245a4f24a463e080762f7ccd0c632c763ea589f75ca205f", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b6f18dea95f2004d0369f6a8346513ca3f706614f4ede219a5f3fe5db5dd962"},
"elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"},
"ets": {:hex, :ets, "0.8.0", "90153faafd289bb0801a537d5b05661f46d5e70b2bb55cccf5ab7f0d41d07832", [:mix], [], "hexpm", "bda4e05b16eada36798cfda16db551dc5243c0adc9a6dfe655b1bc1279b99cb8"},
"jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"},
"machinery": {:hex, :machinery, "1.0.0", "df6968d84c651b9971a33871c78c10157b6e13e4f3390b0bee5b0e8bdea8c781", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "4f6eb4185a48e7245360bedf653af4acc6fa6ae8ff4690619395543fa1a8395f"},
"nimble_options": {:hex, :nimble_options, "0.2.1", "7eac99688c2544d4cc3ace36ee8f2bf4d738c14d031bd1e1193aab096309d488", [:mix], [], "hexpm", "ca48293609306791ce2634818d849b7defe09330adb7e4e1118a0bc59bed1cf4"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"},
"picosat_elixir": {:hex, :picosat_elixir, "0.1.3", "1e4eab27786b7dc7764c307555d8943cbba82912ed943737372760377be05ec8", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3add4d5ea1afa49f51bb7576bae000fe88091969804cc25baf47ffec48a9c626"},
"telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"},
"nimble_options": {:hex, :nimble_options, "0.3.0", "1872911bf50a048f04da26e02704e6aeafc362c2daa7636b6dbfda9492ccfcfa", [:mix], [], "hexpm", "180790a8644fea402452bc15bb54b9bf2c8e5c1fdeb6b39d8072e59c324edf7f"},
"nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"},
"picosat_elixir": {:hex, :picosat_elixir, "0.1.4", "d259219ae27148c07c4aa3fdee61b1a14f4bc7f83b0ebdf2752558d06b302c62", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "eb41cb16053a45c8556de32f065084af98ea0b13a523fb46dfb4f9cff4152474"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
}