From cd16030324995a5cfdd31c9d485314cdceee77b3 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Thu, 13 Aug 2020 17:39:59 -0400 Subject: [PATCH] feat: initial POC release --- lib/api/api.ex | 146 ++++- lib/api/schema.ex | 532 ------------------ lib/ash_graphql.ex | 51 +- lib/graphql/resolver.ex | 273 ++++++++- lib/graphql_resource/ash_graphql_resource.ex | 63 --- lib/graphql_resource/field.ex | 15 - lib/graphql_resource/resource_types.ex | 27 - lib/paginator.ex | 2 +- lib/resource/mutation.ex | 46 ++ lib/resource/query.ex | 32 ++ lib/resource/resource.ex | 555 +++++++++++++++++++ lib/resource/transformers/require_id_pkey.ex | 20 + mix.exs | 15 +- mix.lock | 11 +- 14 files changed, 1113 insertions(+), 675 deletions(-) delete mode 100644 lib/api/schema.ex delete mode 100644 lib/graphql_resource/ash_graphql_resource.ex delete mode 100644 lib/graphql_resource/field.ex delete mode 100644 lib/graphql_resource/resource_types.ex create mode 100644 lib/resource/mutation.ex create mode 100644 lib/resource/query.ex create mode 100644 lib/resource/resource.ex create mode 100644 lib/resource/transformers/require_id_pkey.ex diff --git a/lib/api/api.ex b/lib/api/api.ex index 519781f..6f4008d 100644 --- a/lib/api/api.ex +++ b/lib/api/api.ex @@ -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 diff --git a/lib/api/schema.ex b/lib/api/schema.ex deleted file mode 100644 index 23f747d..0000000 --- a/lib/api/schema.ex +++ /dev/null @@ -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 diff --git a/lib/ash_graphql.ex b/lib/ash_graphql.ex index fea0d8d..629c579 100644 --- a/lib/ash_graphql.ex +++ b/lib/ash_graphql.ex @@ -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 diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index c6e9bed..8fdb534 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -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 diff --git a/lib/graphql_resource/ash_graphql_resource.ex b/lib/graphql_resource/ash_graphql_resource.ex deleted file mode 100644 index a724866..0000000 --- a/lib/graphql_resource/ash_graphql_resource.ex +++ /dev/null @@ -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 diff --git a/lib/graphql_resource/field.ex b/lib/graphql_resource/field.ex deleted file mode 100644 index c3c9e1b..0000000 --- a/lib/graphql_resource/field.ex +++ /dev/null @@ -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 diff --git a/lib/graphql_resource/resource_types.ex b/lib/graphql_resource/resource_types.ex deleted file mode 100644 index 306ff91..0000000 --- a/lib/graphql_resource/resource_types.ex +++ /dev/null @@ -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 diff --git a/lib/paginator.ex b/lib/paginator.ex index bf15ecf..389579c 100644 --- a/lib/paginator.ex +++ b/lib/paginator.ex @@ -1,3 +1,3 @@ defmodule AshGraphql.Paginator do - defstruct [:limit, :results, :total, offset: 0] + defstruct [:results, :count] end diff --git a/lib/resource/mutation.ex b/lib/resource/mutation.ex new file mode 100644 index 0000000..a3c765e --- /dev/null +++ b/lib/resource/mutation.ex @@ -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 diff --git a/lib/resource/query.ex b/lib/resource/query.ex new file mode 100644 index 0000000..63558ea --- /dev/null +++ b/lib/resource/query.ex @@ -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 diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex new file mode 100644 index 0000000..bbc2161 --- /dev/null +++ b/lib/resource/resource.ex @@ -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 diff --git a/lib/resource/transformers/require_id_pkey.ex b/lib/resource/transformers/require_id_pkey.ex new file mode 100644 index 0000000..2edc90a --- /dev/null +++ b/lib/resource/transformers/require_id_pkey.ex @@ -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 diff --git a/mix.exs b/mix.exs index ceabc45..8043b82 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index e5b3569..271f126 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, }