feat: rewrite with dataloader

This commit is contained in:
Zach Daniel 2020-09-23 20:54:57 -04:00
parent c74f76f8a0
commit 7adb8c0f22
No known key found for this signature in database
GPG key ID: C377365383138D4B
8 changed files with 432 additions and 186 deletions

View file

@ -2,7 +2,7 @@
## Get familiar with Ash resources
If you haven't already, read the getting started guide for Ash. This assumes that you already have resources set up, and only gives you the steps to *add* AshGraphql to your resources/apis.
If you haven't already, read the getting started guide for Ash. This assumes that you already have resources set up, and only gives you the steps to _add_ AshGraphql to your resources/apis.
## Add the API Extension
@ -11,7 +11,7 @@ defmodule MyApi do
use Ash.Api, extensions: [
AshGraphql.Api
]
graphql do
authorize? false # Defaults to `true`, use this to disable authorization for the entire API (you probably only want this while prototyping)
end
@ -26,17 +26,17 @@ defmodule Post do
extensions: [
AshGraphql.Resource
]
graphql do
type :post
fields [:name, :count_of_comments, :comments] # <- a list of all of the attributes/relationships/aggregates to include in the graphql API
queries do
get :get_post, :default # <- create a field called `get_post` that uses the `default` read action to fetch a single post
list :list_posts, :default # <- create a field called `list_posts` that uses the `default` read action to fetch a list of posts
end
mutations do
# And so on
create :create_post, :default
@ -51,21 +51,35 @@ end
If you don't have an absinthe schema, you can create one just for ash
If you don't have any queries or mutations in your schema, you may
If you don't have any queries or mutations in your schema, you may
need to add empty query and mutation blocks. If you have no mutations,
don't add an empty mutations block, same for queries.
don't add an empty mutations block, same for queries. Additionally,
define a `context/1` function, and call `AshGraphql.add_context/2` with
the current context and your apis. Additionally, add the `Absinthe.Middleware.Dataloader`
to your plugins, as shown below. If you're starting fresh, just copy the schema below and
adjust the module name and api name.
```elixir
defmodule MyApp.Schema do
use Absinthe.Schema
use AshGraphql, api: AshExample.Api
@apis [MyApp.Api]
use AshGraphql, apis: @apis
query do
end
mutation do
end
def context(ctx) do
AshGraphql.add_context(ctx, @apis)
end
def plugins() do
[Absinthe.Middleware.Dataloader | Absinthe.Plugin.defaults()]
end
end
```

View file

@ -18,7 +18,7 @@ defmodule AshGraphql.Api do
use Ash.Dsl.Extension, sections: [@graphql]
def authorize?(api) do
Extension.get_opt(api, :api, :authorize?, true)
Extension.get_opt(api, [:graphql], :authorize?, true)
end
@doc false
@ -39,7 +39,7 @@ defmodule AshGraphql.Api do
end
@doc false
def type_definitions(api, schema) do
def type_definitions(api, schema, first?) do
resource_types =
api
|> Ash.Api.resources()
@ -47,11 +47,15 @@ defmodule AshGraphql.Api do
AshGraphql.Resource in Ash.Resource.extensions(resource)
end)
|> Enum.flat_map(fn resource ->
AshGraphql.Resource.type_definitions(resource, schema) ++
AshGraphql.Resource.type_definitions(resource, api, schema) ++
AshGraphql.Resource.mutation_types(resource, schema)
end)
[mutation_error(schema), relationship_change(schema)] ++ resource_types
if first? do
[mutation_error(schema), relationship_change(schema)] ++ resource_types
else
resource_types
end
end
defp relationship_change(schema) do

View file

@ -7,79 +7,82 @@ defmodule AshGraphql do
"""
defmacro __using__(opts) do
quote bind_quoted: [api: opts[:api]] do
defmodule Module.concat(api, AshTypes) do
@moduledoc false
alias Absinthe.{Blueprint, Phase, Pipeline}
quote bind_quoted: [apis: opts[:apis], api: opts[:api]] do
apis =
api
|> List.wrap()
|> Kernel.++(List.wrap(apis))
|> Enum.map(&{&1, false})
|> List.update_at(0, fn {api, _} -> {api, true} end)
def pipeline(pipeline) do
Pipeline.insert_before(
pipeline,
Phase.Schema.Validation.QueryTypeMustBeObject,
__MODULE__
)
end
for {api, first?} <- apis do
defmodule Module.concat(api, AshTypes) do
@moduledoc false
alias Absinthe.{Blueprint, Phase, Pipeline}
def run(blueprint, _opts) do
api = unquote(api)
def pipeline(pipeline) do
Pipeline.insert_before(
pipeline,
Phase.Schema.Validation.QueryTypeMustBeObject,
__MODULE__
)
end
case Code.ensure_compiled(api) do
{:module, _} ->
blueprint_with_queries =
api
|> AshGraphql.Api.queries(__MODULE__)
|> Enum.reduce(blueprint, fn query, blueprint ->
Absinthe.Blueprint.add_field(blueprint, "RootQueryType", query)
end)
def run(blueprint, _opts) do
api = unquote(api)
blueprint_with_mutations =
api
|> AshGraphql.Api.mutations(__MODULE__)
|> Enum.reduce(blueprint_with_queries, fn mutation, blueprint ->
Absinthe.Blueprint.add_field(blueprint, "RootMutationType", mutation)
end)
case Code.ensure_compiled(api) do
{:module, _} ->
blueprint_with_queries =
api
|> AshGraphql.Api.queries(__MODULE__)
|> Enum.reduce(blueprint, fn query, blueprint ->
Absinthe.Blueprint.add_field(blueprint, "RootQueryType", query)
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)
blueprint_with_mutations =
api
|> AshGraphql.Api.mutations(__MODULE__)
|> Enum.reduce(blueprint_with_queries, fn mutation, blueprint ->
Absinthe.Blueprint.add_field(blueprint, "RootMutationType", mutation)
end)
{:ok, %{blueprint_with_mutations | schema_definitions: new_defs}}
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__, unquote(first?))
}
end)
{:error, _} ->
# Something else will fail here, so we don't need to
{:ok, blueprint}
{:ok, %{blueprint_with_mutations | schema_definitions: new_defs}}
{:error, _} ->
# Something else will fail here, so we don't need to
{:ok, blueprint}
end
end
end
end
@pipeline_modifier Module.concat(api, AshTypes)
@pipeline_modifier Module.concat(api, AshTypes)
end
end
end
defguard is_digit(x) when x in ?0..?0
def add_context(ctx, apis) do
dataloader =
apis
|> List.wrap()
|> Enum.reduce(Dataloader.new(), fn api, dataloader ->
Dataloader.add_source(
dataloader,
api,
AshGraphql.Dataloader.new(api)
)
end)
def roll(schema) do
Enum.map(schema, fn
<<?d, x, y>> when is_digit(x) and is_digit(y) ->
Enum.random(1..String.to_integer(<<x, y>>))
<<?d, x, y, z>> when is_digit(x) and is_digit(y) and is_digit(z) ->
Enum.random(1..String.to_integer(<<x, y, z>>))
"adv" ->
{:max, roll(["d20", "d20"])}
"dis" ->
{:min, roll(["d20", "d20"])}
x ->
x
end)
Map.put(ctx, :ash_loader, dataloader)
end
end

257
lib/graphql/dataloader.ex Normal file
View file

@ -0,0 +1,257 @@
defmodule AshGraphql.Dataloader do
@moduledoc "The dataloader in charge of resolving "
defstruct [
:api,
batches: %{},
results: %{},
default_params: %{}
]
@type t :: %__MODULE__{
api: Ash.api(),
batches: map,
results: map,
default_params: map
}
@type api_opts :: Keyword.t()
@type batch_fun :: (Ash.resource(), Ash.query(), any, [any], api_opts -> [any])
@doc """
Create an Ash Dataloader source.
This module handles retrieving data from Ash for dataloader. It requires a
valid Ash API.
"""
@spec new(Ash.api()) :: t
def new(api) do
%__MODULE__{api: api}
end
defimpl Dataloader.Source do
def run(source) do
results = Dataloader.async_safely(__MODULE__, :run_batches, [source])
results =
Map.merge(source.results, results, fn _, {:ok, v1}, {:ok, v2} ->
{:ok, Map.merge(v1, v2)}
end)
%{source | results: results, batches: %{}}
end
def fetch(source, batch_key, item) do
{batch_key, item_key, _item} =
batch_key
|> normalize_key(source.default_params)
|> get_keys(item)
case Map.fetch(source.results, batch_key) do
{:ok, batch} ->
fetch_item_from_batch(batch, item_key)
:error ->
{:error, "Unable to find batch #{inspect(batch_key)}"}
end
end
defp fetch_item_from_batch({:error, _reason} = tried_and_failed, _item_key),
do: tried_and_failed
defp fetch_item_from_batch({:ok, batch}, item_key) do
case Map.fetch(batch, item_key) do
:error -> {:error, "Unable to find item #{inspect(item_key)} in batch"}
result -> result
end
end
def put(source, _batch, _item, %Ash.NotLoaded{type: :relationship}) do
source
end
def put(source, batch, item, result) do
batch = normalize_key(batch, source.default_params)
{batch_key, item_key, _item} = get_keys(batch, item)
results =
Map.update(
source.results,
batch_key,
{:ok, %{item_key => result}},
fn {:ok, map} -> {:ok, Map.put(map, item_key, result)} end
)
%{source | results: results}
end
def load(source, batch, item) do
{batch_key, item_key, item} =
batch
|> normalize_key(source.default_params)
|> get_keys(item)
if fetched?(source.results, batch_key, item_key) do
source
else
entry = {item_key, item}
update_in(source.batches, fn batches ->
Map.update(batches, batch_key, MapSet.new([entry]), &MapSet.put(&1, entry))
end)
end
end
defp fetched?(results, batch_key, item_key) do
case results do
%{^batch_key => {:ok, %{^item_key => _}}} -> true
_ -> false
end
end
def pending_batches?(%{batches: batches}) do
batches != %{}
end
def timeout(%{options: options}) do
options[:timeout]
end
defp related(path, resource) do
Ash.Resource.related(resource, path) ||
raise """
Valid relationship for path #{inspect(path)} not found on resource #{inspect(resource)}
"""
end
defp get_keys({assoc_field, opts}, %resource{} = record) when is_atom(assoc_field) do
validate_resource(resource)
primary_keys = Ash.Resource.primary_key(resource)
id = Enum.map(primary_keys, &Map.get(record, &1))
queryable = related([assoc_field], resource)
{{:assoc, resource, self(), assoc_field, queryable, opts}, id, record}
end
# defp get_keys({{cardinality, resource}, opts}, value) when is_atom(resource) do
# validate_resource(resource)
# {_, col, value} = normalize_value(resource, value)
# {{:resource, self(), resource, cardinality, col, opts}, value, value}
# end
# defp get_keys({resource, opts}, value) when is_atom(resource) do
# validate_resource(resource)
# case normalize_value(resource, value) do
# {:primary, col, value} ->
# {{:resource, self(), resource, :one, col, opts}, value, value}
# {:not_primary, col, _value} ->
# raise """
# Cardinality required unless using primary key
# The non-primary key column specified was: #{inspect(col)}
# """
# end
# end
defp get_keys(key, item) do
raise """
Invalid: #{inspect(key)}
#{inspect(item)}
The batch key must either be a schema module, or an association name.
"""
end
defp validate_resource(resource) do
unless Ash.Resource.resource?(resource) do
raise "The given module - #{resource} - is not an Ash resouce."
end
end
defp normalize_key({key, params}, default_params) do
{key, Enum.into(params, default_params)}
end
defp normalize_key(key, default_params) do
{key, default_params}
end
def run_batches(source) do
options = [
timeout: Dataloader.default_timeout(),
on_timeout: :kill_task
]
results =
source.batches
|> Task.async_stream(
fn batch ->
id = :erlang.unique_integer()
system_time = System.system_time()
start_time_mono = System.monotonic_time()
emit_start_event(id, system_time, batch)
batch_result = run_batch(batch, source)
emit_stop_event(id, start_time_mono, batch)
batch_result
end,
options
)
|> Enum.map(fn
{:ok, {_key, result}} -> {:ok, result}
{:exit, reason} -> {:error, reason}
end)
source.batches
|> Enum.map(fn {key, _set} -> key end)
|> Enum.zip(results)
|> Map.new()
end
defp run_batch(
{{:assoc, source_resource, _pid, field, _resource, opts} = key, records},
source
) do
{ids, records} = Enum.unzip(records)
query = opts[:query]
empty = source_resource |> struct |> Map.fetch!(field)
records = records |> Enum.map(&Map.put(&1, field, empty))
cardinality = Ash.Resource.relationship(source_resource, field).cardinality
loaded = source.api.load!(records, [{field, Ash.Query.new(query)}], source.api_opts)
results =
case cardinality do
:many ->
Enum.map(loaded, fn record ->
related = List.wrap(Map.get(record, field))
%{results: related, count: Enum.count(related)}
end)
:one ->
Enum.map(loaded, fn record ->
Map.get(record, field)
end)
end
{key, Map.new(Enum.zip(ids, results))}
end
defp emit_start_event(id, system_time, batch) do
:telemetry.execute(
[:dataloader, :source, :batch, :run, :start],
%{system_time: system_time},
%{id: id, batch: batch}
)
end
defp emit_stop_event(id, start_time_mono, batch) do
:telemetry.execute(
[:dataloader, :source, :batch, :run, :stop],
%{duration: System.monotonic_time() - start_time_mono},
%{id: id, batch: batch}
)
end
end
end

View file

@ -11,8 +11,6 @@ defmodule AshGraphql.Graphql.Resolver do
[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))
@ -29,20 +27,10 @@ defmodule AshGraphql.Graphql.Resolver do
[action: action]
end
selections =
case Enum.find(resolution.definition.selections, &(&1.schema_node.identifier == :results)) do
nil ->
[]
field ->
field.selections
end
query =
resource
|> Ash.Query.limit(limit)
|> Ash.Query.offset(offset)
|> Ash.Query.load(load_nested(resource, selections))
query =
case Map.fetch(args, :filter) do
@ -79,17 +67,6 @@ defmodule AshGraphql.Graphql.Resolver do
) do
{attributes, relationships} = split_attrs_and_rels(input, resource)
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 =
@ -105,10 +82,10 @@ defmodule AshGraphql.Graphql.Resolver do
end
result =
with {:ok, value} <- api.create(changeset_with_relationships, opts),
{:ok, value} <- api.load(value, load) do
{:ok, %{result: value, errors: []}}
else
case api.create(changeset_with_relationships, opts) do
{:ok, value} ->
{:ok, %{result: value, errors: []}}
{:error, error} ->
{:ok, %{result: nil, errors: to_errors(error)}}
end
@ -140,25 +117,11 @@ defmodule AshGraphql.Graphql.Resolver do
[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
case api.update(changeset_with_relationships, opts) do
{:ok, value} ->
{:ok, %{result: value, errors: []}}
{:error, error} ->
{:ok, %{result: nil, errors: List.wrap(error)}}
end
@ -219,68 +182,62 @@ defmodule AshGraphql.Graphql.Resolver do
end)
end
def resolve_assoc(%{source: parent} = resolution, {:one, name}) do
Absinthe.Resolution.put_result(resolution, {:ok, Map.get(parent, name)})
def resolve_assoc(
%{source: parent, arguments: args, context: %{ash_loader: loader}} = resolution,
{api, relationship}
) do
opts = [query: apply_load_arguments(args, Ash.Query.new(relationship.destination))]
{batch_key, parent} = {{relationship.name, opts}, parent}
do_dataloader(resolution, loader, api, batch_key, args, parent, opts)
end
def resolve_assoc(%{source: parent} = resolution, {:many, name}) do
values = Map.get(parent, name)
paginator = %{results: values, count: Enum.count(values)}
defp do_dataloader(
resolution,
loader,
api,
batch_key,
args,
parent,
opts
) do
loader = Dataloader.load(loader, api, batch_key, parent)
Absinthe.Resolution.put_result(resolution, {:ok, paginator})
fun = fn loader ->
callback = Keyword.get(opts, :callback, default_callback(loader))
loader
|> Dataloader.get(api, batch_key, parent)
|> callback.(parent, args)
end
Absinthe.Resolution.put_result(
resolution,
{:middleware, Absinthe.Middleware.Dataloader, {loader, fun}}
)
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 = nested_selections_with_pagination(field)
nested_loads = load_nested(relationship.destination, trimmed_nested)
query = Ash.Query.load(relationship.destination, nested_loads)
query = apply_load_arguments(field, query)
{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)
defp default_callback(%{options: loader_options}) do
if loader_options[:get_policy] == :tuples do
fn result, _parent, _args -> result end
else
fn result, _parent, _args -> {:ok, result} end
end
end
defp apply_load_arguments(field, query) do
Enum.reduce(field.arguments, query, fn
%{name: "limit", value: value}, query ->
Ash.Query.limit(query, value)
defp apply_load_arguments(arguments, query) do
Enum.reduce(arguments, query, fn
{:limit, limit}, query ->
Ash.Query.limit(query, limit)
%{name: "offset", value: value}, query ->
Ash.Query.offset(query, value)
{:offset, offset}, query ->
Ash.Query.offset(query, offset)
%{name: "filter", value: value}, query ->
{:filter, value}, query ->
decode_and_filter(query, value)
end)
end
defp nested_selections_with_pagination(field) do
Enum.flat_map(field.selections, fn nested ->
if nested.schema_node.identifier == :results do
nested.selections
else
[]
end
end)
end
defp decode_and_filter(query, value) do
case Jason.decode(value) do
{:ok, decoded} ->

View file

@ -263,6 +263,11 @@ defmodule AshGraphql.Resource do
resource
|> mutations()
|> Enum.flat_map(fn mutation ->
mutation = %{
mutation
| action: Ash.Resource.action(resource, mutation.action, mutation.type)
}
description =
if mutation.type == :destroy do
"The record that was successfully deleted"
@ -310,19 +315,22 @@ defmodule AshGraphql.Resource do
end)
end
defp mutation_fields(resource, schema, query) do
defp mutation_fields(resource, schema, mutation) do
fields = Resource.fields(resource)
attribute_fields =
resource
|> Ash.Resource.attributes()
|> Enum.filter(fn attribute ->
is_nil(mutation.action.accept) || attribute.name in mutation.action.accept
end)
|> 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
if attribute.allow_nil? || mutation.type == :update do
type
else
%Absinthe.Blueprint.TypeReference.NonNull{
@ -356,7 +364,7 @@ defmodule AshGraphql.Resource do
}
%{cardinality: :many} = relationship ->
case query.type do
case mutation.type do
:update ->
%Absinthe.Blueprint.Schema.FieldDefinition{
identifier: relationship.name,
@ -421,9 +429,9 @@ defmodule AshGraphql.Resource do
end
@doc false
def type_definitions(resource, schema) do
def type_definitions(resource, api, schema) do
[
type_definition(resource, schema),
type_definition(resource, api, schema),
page_of(resource, schema)
]
end
@ -458,23 +466,23 @@ defmodule AshGraphql.Resource do
}
end
defp type_definition(resource, schema) do
defp type_definition(resource, api, schema) do
type = Resource.type(resource)
%Absinthe.Blueprint.Schema.ObjectTypeDefinition{
description: Ash.Resource.description(resource),
fields: fields(resource, schema),
fields: fields(resource, api, schema),
identifier: type,
module: schema,
name: Macro.camelize(to_string(type))
}
end
defp fields(resource, schema) do
defp fields(resource, api, schema) do
fields = Resource.fields(resource)
attributes(resource, schema, fields) ++
relationships(resource, schema, fields) ++
relationships(resource, api, schema, fields) ++
aggregates(resource, schema, fields)
end
@ -504,7 +512,7 @@ defmodule AshGraphql.Resource do
end
# sobelow_skip ["DOS.StringToAtom"]
defp relationships(resource, schema, fields) do
defp relationships(resource, api, schema, fields) do
resource
|> Ash.Resource.relationships()
|> Enum.filter(&(&1.name in fields))
@ -520,7 +528,7 @@ defmodule AshGraphql.Resource do
module: schema,
name: to_string(relationship.name),
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve_assoc}, {:one, relationship.name}}
{{AshGraphql.Graphql.Resolver, :resolve_assoc}, {api, relationship}}
],
arguments: [],
type: type
@ -535,7 +543,7 @@ defmodule AshGraphql.Resource do
module: schema,
name: to_string(relationship.name),
middleware: [
{{AshGraphql.Graphql.Resolver, :resolve_assoc}, {:many, relationship.name}}
{{AshGraphql.Graphql.Resolver, :resolve_assoc}, {api, relationship}}
],
arguments: args(:list),
type: query_type

View file

@ -16,6 +16,7 @@ defmodule AshGraphql.MixProject do
package: package(),
aliases: aliases(),
deps: deps(),
dialyzer: [plt_add_apps: [:ash]],
test_coverage: [tool: ExCoveralls],
preferred_cli_env: [
coveralls: :test,
@ -68,7 +69,8 @@ defmodule AshGraphql.MixProject do
defp deps do
[
{:ash, ash_version("~> 1.11.0")},
{:absinthe, "~> 1.5.2"},
{:absinthe, "~> 1.5.3"},
{:dataloader, "~> 1.0"},
{:jason, "~> 1.2"},
{:ex_doc, "~> 0.22", only: :dev, runtime: false},
{:ex_check, "~> 0.12.0", only: :dev},

View file

@ -1,15 +1,16 @@
%{
"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"},
"ash": {:hex, :ash, "1.10.0", "c3b3eb98ac41da14bb70daeec15d498ea48a91105db9386cb2814eaf38ff0cc6", [:mix], [{:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.1.4", [hex: :picosat_elixir, repo: "hexpm", optional: false]}], "hexpm", "2a8babe6cfa9fa0274037074ba4a3c23bda9f45a77006c4e745bc6336629a5d1"},
"absinthe": {:hex, :absinthe, "1.5.3", "d255e6d825e63abd9ff22b6d2423540526c9d699f46b712aa76f4b9c06116ff9", [: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", "69a170f3a8630b2ca489367bc2aeeabd84e15cbd1e86fe8741b05885fda32a2e"},
"ash": {:hex, :ash, "1.11.1", "662b2d65731c4e61622a49293c6bcd41f567c6c3681818b9be7f0da868b4d78e", [:mix], [{:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.1.4", [hex: :picosat_elixir, repo: "hexpm", optional: false]}], "hexpm", "77e92ca815aabedbebfe53aee87d96de26b75338351de6a8d5e831243d4241f4"},
"ashton": {:hex, :ashton, "0.4.1", "d0f7782ac44fa22da7ce544028ee3d2078592a834d8adf3e5b4b6aeb94413a55", [:mix], [], "hexpm", "24db667932517fdbc3f2dae777f28b8d87629271387d4490bc4ae8d9c46ff3d3"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"},
"credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"},
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
"dataloader": {:hex, :dataloader, "1.0.8", "114294362db98a613f231589246aa5b0ce847412e8e75c4c94f31f204d272cbf", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eaf3c2aa2bc9dbd2f1e960561d616b7f593396c4754185b75904f6d66c82a667"},
"decimal": {:hex, :decimal, "1.9.0", "83e8daf59631d632b171faabafb4a9f4242c514b0a06ba3df493951c08f64d07", [:mix], [], "hexpm", "b1f2343568eed6928f3e751cf2dffde95bfaa19dd95d09e8a9ea92ccfd6f7d85"},
"dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"},
"earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"},
"ecto": {:hex, :ecto, "3.4.6", "08f7afad3257d6eb8613309af31037e16c36808dfda5a3cd0cb4e9738db030e4", [: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", "6f13a9e2a62e75c2dcfc7207bfc65645ab387af8360db4c89fee8b5a4bf3f70b"},
"elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"},
"elixir_make": {:hex, :elixir_make, "0.6.1", "8faa29a5597faba999aeeb72bbb9c91694ef8068f0131192fb199f98d32994ef", [:mix], [], "hexpm", "35d33270680f8d839a4003c3e9f43afb595310a592405a00afc12de4c7f55a18"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"},
"ex_check": {:hex, :ex_check, "0.12.0", "c0e2919ecc06afeaf62c52d64f3d91bd4bc7dd8deaac5f84becb6278888c967a", [:mix], [], "hexpm", "cfafa8ef97c2596d45a1f19b5794cb5c7f700f25d164d3c9f8d7ec17ee67cf42"},
@ -19,7 +20,7 @@
"git_ops": {:hex, :git_ops, "2.0.1", "9d3df6c710a80a8779dbb144c79fb24c777660ae862cc454ab3193afd0c02a37", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cd499a72523ba338c20973eadb707d25a42e4a77c46d2ff5c45e61e7adae6190"},
"hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"},
"idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"},
"jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"machinery": {:hex, :machinery, "1.0.0", "df6968d84c651b9971a33871c78c10157b6e13e4f3390b0bee5b0e8bdea8c781", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "4f6eb4185a48e7245360bedf653af4acc6fa6ae8ff4690619395543fa1a8395f"},
"makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"},
"makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"},
@ -28,7 +29,7 @@
"nimble_options": {:hex, :nimble_options, "0.3.0", "1872911bf50a048f04da26e02704e6aeafc362c2daa7636b6dbfda9492ccfcfa", [:mix], [], "hexpm", "180790a8644fea402452bc15bb54b9bf2c8e5c1fdeb6b39d8072e59c324edf7f"},
"nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
"picosat_elixir": {:hex, :picosat_elixir, "0.1.4", "d259219ae27148c07c4aa3fdee61b1a14f4bc7f83b0ebdf2752558d06b302c62", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "eb41cb16053a45c8556de32f065084af98ea0b13a523fb46dfb4f9cff4152474"},
"picosat_elixir": {:hex, :picosat_elixir, "0.1.5", "23673bd3080a4489401e25b4896aff1f1138d47b2f650eab724aad1506188ebb", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "b30b3c3abd1f4281902d3b5bc9b67e716509092d6243b010c29d8be4a526e8c8"},
"sobelow": {:hex, :sobelow, "0.10.4", "44ba642da120d84fedb9e85473375084034330c8f15a992351dd164a82963103", [:mix], [], "hexpm", "fea62a94a4112de45ee9c9d076fd636fbbc10b7c7c2ea99a928e7c289b8498d1"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},