From 6c5265b4a1b08901c20b70e62046af858fb70a90 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Sun, 14 Jun 2020 03:04:18 -0400 Subject: [PATCH] feat: use the new DSL builder for config (#7) --- lib/ash_postgres.ex | 646 +------------------------------------------ lib/data_layer.ex | 625 +++++++++++++++++++++++++++++++++++++++++ lib/repo.ex | 2 +- logos/small-logo.png | Bin 0 -> 3088 bytes mix.exs | 22 +- mix.lock | 24 +- 6 files changed, 666 insertions(+), 653 deletions(-) create mode 100644 lib/data_layer.ex create mode 100644 logos/small-logo.png diff --git a/lib/ash_postgres.ex b/lib/ash_postgres.ex index e91337e..1a2923a 100644 --- a/lib/ash_postgres.ex +++ b/lib/ash_postgres.ex @@ -1,649 +1,21 @@ defmodule AshPostgres do - @using_opts_schema [ - repo: [ - type: :atom, - required: true, - doc: - "The repo that will be used to fetch your data. See the `Ecto.Repo` documentation for more" - ], - table: [ - type: :string, - doc: "The name of the database table backing the resource", - required: true - ] - ] - - alias Ash.Filter.{And, Eq, In, NotEq, NotIn, Or} - alias AshPostgres.Predicates.Trigram - @moduledoc """ - A postgres data layer that levereges Ecto's postgres tools. + A postgres extension library for `Ash`. - To use it, add `use AshPostgres, repo: MyRepo` to your resource, after `use Ash.Resource` + `AshPostgres.DataLayer` provides a DataLayer, and a DSL extension to configure that data layer. - #{NimbleOptions.docs(@using_opts_schema)} + The dsl extension exposes the `postgres` section. See: `AshPostgres.DataLayer.postgres/1` for more. """ - @behaviour Ash.DataLayer - defmacro __using__(opts) do - quote bind_quoted: [opts: opts] do - opts = AshPostgres.validate_using_opts(__MODULE__, opts) - - @data_layer AshPostgres - @repo opts[:repo] - @table opts[:table] - - def repo do - @repo - end - - def postgres_table do - @table - end - end - end - - def validate_using_opts(mod, opts) do - case NimbleOptions.validate(opts, @using_opts_schema) do - {:ok, opts} -> - opts - - {:error, message} -> - raise Ash.Error.ResourceDslError, - resource: mod, - using: __MODULE__, - message: message - end - end - - def postgres_repo?(repo) do - repo.__adapter__() == Ecto.Adapters.Postgres - end + alias Ash.Dsl.Extension + @doc "Fetch the configured repo for a resource" def repo(resource) do - resource.repo() + Extension.get_opt(resource, [:postgres], :repo) end - @impl true - def custom_filters(resource) do - config = repo(resource).config() - - add_pg_trgm_search(%{}, config) - end - - defp add_pg_trgm_search(filters, config) do - if "pg_trgm" in config[:installed_extensions] do - Map.update(filters, :string, [{:trigram, AshPostgres.Predicates.Trigram}], fn filters -> - [{:trigram, AshPostgres.Predicates.Trigram} | filters] - end) - else - filters - end - end - - import Ecto.Query, only: [from: 2] - - @impl true - def can?(_, capability), do: can?(capability) - def can?(:query_async), do: true - def can?(:transact), do: true - def can?(:composite_primary_key), do: true - def can?(:upsert), do: true - def can?({:filter, :in}), do: true - def can?({:filter, :not_in}), do: true - def can?({:filter, :not_eq}), do: true - def can?({:filter, :eq}), do: true - def can?({:filter, :and}), do: true - def can?({:filter, :or}), do: true - def can?({:filter, :not}), do: true - def can?({:filter, :trigram}), do: true - def can?({:filter_related, _}), do: true - def can?(_), do: false - - @impl true - def limit(query, nil, _), do: {:ok, query} - - def limit(query, limit, _resource) do - {:ok, from(row in query, limit: ^limit)} - end - - @impl true - def offset(query, nil, _), do: query - - def offset(query, offset, _resource) do - {:ok, from(row in query, offset: ^offset)} - end - - @impl true - def run_query(%{__impossible__: true}, _) do - {:ok, []} - end - - @impl true - def run_query(query, resource) do - {:ok, repo(resource).all(query)} - end - - @impl true - def resource_to_query(resource), - do: Ecto.Queryable.to_query({resource.postgres_table(), resource}) - - @impl true - def create(resource, changeset) do - changeset = - Map.update!(changeset, :action, fn - :create -> :insert - action -> action - end) - - changeset = - Map.update!(changeset, :data, fn data -> - Map.update!(data, :__meta__, &Map.put(&1, :source, resource.postgres_table())) - end) - - repo(resource).insert(changeset) - rescue - e -> - {:error, e} - end - - @impl true - def upsert(resource, changeset) do - changeset = - Map.update!(changeset, :action, fn - :create -> :insert - action -> action - end) - - changeset = - Map.update!(changeset, :data, fn data -> - Map.update!(data, :__meta__, &Map.put(&1, :source, resource.postgres_table())) - end) - - repo(resource).insert(changeset, - on_conflict: :replace_all, - conflict_target: Ash.primary_key(resource) - ) - rescue - e -> - {:error, e} - end - - @impl true - def update(resource, changeset) do - repo(resource).update(changeset) - rescue - e -> - {:error, e} - end - - @impl true - def destroy(%resource{} = record) do - case repo(resource).delete(record) do - {:ok, _record} -> :ok - {:error, error} -> {:error, error} - end - rescue - e -> - {:error, e} - end - - @impl true - def sort(query, sort, _resource) do - {:ok, - from(row in query, - order_by: ^sanitize_sort(sort) - )} - end - - defp sanitize_sort(sort) do - sort - |> List.wrap() - |> Enum.map(fn - {sort, order} -> {order, sort} - sort -> sort - end) - end - - @impl true - def filter(query, filter, _resource) do - new_query = - query - |> Map.put(:bindings, %{}) - |> join_all_relationships(filter) - |> add_filter_expression(filter) - - impossible_query = - if filter.impossible? do - Map.put(new_query, :__impossible__, true) - else - new_query - end - - {:ok, impossible_query} - end - - defp join_all_relationships(query, filter, path \\ []) do - query = - Map.put_new(query, :__ash_bindings__, %{current: Enum.count(query.joins) + 1, bindings: %{}}) - - Enum.reduce(filter.relationships, query, fn {name, relationship_filter}, query -> - join_type = :left - - case {join_type, relationship_filter} do - {join_type, relationship_filter} -> - relationship = Ash.relationship(filter.resource, name) - - current_path = [relationship | path] - - joined_query = join_relationship(query, current_path, join_type) - - joined_query_with_distinct = - join_and_add_distinct(relationship, join_type, joined_query, filter) - - join_all_relationships(joined_query_with_distinct, relationship_filter, current_path) - end - end) - end - - defp join_and_add_distinct(relationship, join_type, joined_query, filter) do - if relationship.cardinality == :many and join_type == :left && !joined_query.distinct do - from(row in joined_query, - distinct: ^Ash.primary_key(filter.resource) - ) - else - joined_query - end - end - - defp join_relationship(query, path, join_type) do - path_names = Enum.map(path, & &1.name) - - case Map.get(query.__ash_bindings__.bindings, path_names) do - %{type: existing_join_type} when join_type != existing_join_type -> - raise "unreachable?" - - nil -> - do_join_relationship(query, path, join_type) - - _ -> - query - end - end - - defp do_join_relationship(query, relationships, kind, path \\ []) - defp do_join_relationship(_, [], _, _), do: nil - - defp do_join_relationship(query, [%{type: :many_to_many} = relationship | rest], :inner, path) do - relationship_through = maybe_get_resource_query(relationship.through) - - relationship_destination = - do_join_relationship(query, rest, :inner, [relationship.name | path]) || - Ecto.Queryable.to_query(maybe_get_resource_query(relationship.destination)) - - new_query = - from(row in query, - join: through in ^relationship_through, - on: - field(row, ^relationship.source_field) == - field(through, ^relationship.source_field_on_join_table), - join: destination in ^relationship_destination, - on: - field(destination, ^relationship.destination_field) == - field(through, ^relationship.destination_field_on_join_table) - ) - - join_path = - Enum.reverse([String.to_existing_atom(to_string(relationship.name) <> "_join_assoc") | path]) - - full_path = Enum.reverse([relationship.name | path]) - - new_query - |> add_binding(join_path, :inner) - |> add_binding(full_path, :inner) - |> merge_bindings(relationship_destination) - end - - defp do_join_relationship(query, [relationship | rest], :inner, path) do - relationship_destination = - do_join_relationship(query, rest, :inner, [relationship.name | path]) || - Ecto.Queryable.to_query(maybe_get_resource_query(relationship.destination)) - - new_query = - from(row in query, - join: destination in ^relationship_destination, - on: - field(row, ^relationship.source_field) == - field(destination, ^relationship.destination_field) - ) - - new_query - |> add_binding(Enum.reverse([relationship.name | path]), :inner) - |> merge_bindings(relationship_destination) - end - - defp do_join_relationship(query, [%{type: :many_to_many} = relationship | rest], :left, path) do - relationship_through = maybe_get_resource_query(relationship.through) - - relationship_destination = - do_join_relationship(query, rest, :inner, [relationship.name | path]) || - Ecto.Queryable.to_query(maybe_get_resource_query(relationship.destination)) - - new_query = - from(row in query, - left_join: through in ^relationship_through, - on: - field(row, ^relationship.source_field) == - field(through, ^relationship.source_field_on_join_table), - left_join: destination in ^relationship_destination, - on: - field(destination, ^relationship.destination_field) == - field(through, ^relationship.destination_field_on_join_table) - ) - - join_path = - Enum.reverse([String.to_existing_atom(to_string(relationship.name) <> "_join_assoc") | path]) - - full_path = Enum.reverse([relationship.name | path]) - - new_query - |> add_binding(join_path, :left) - |> add_binding(full_path, :left) - |> merge_bindings(relationship_destination) - end - - defp do_join_relationship(query, [relationship | rest], :left, path) do - relationship_destination = - do_join_relationship(query, rest, :inner, [relationship.name | path]) || - Ecto.Queryable.to_query(maybe_get_resource_query(relationship.destination)) - - new_query = - from(row in query, - left_join: destination in ^relationship_destination, - on: - field(row, ^relationship.source_field) == - field(destination, ^relationship.destination_field) - ) - - new_query - |> add_binding(Enum.reverse([relationship.name | path]), :left) - |> merge_bindings(relationship_destination) - end - - defp add_filter_expression(query, filter) do - {params, expr} = filter_to_expr(filter, query.__ash_bindings__.bindings, []) - - if expr do - boolean_expr = %Ecto.Query.BooleanExpr{ - expr: expr, - op: :and, - params: params - } - - %{query | wheres: [boolean_expr | query.wheres]} - else - query - end - end - - defp join_exprs(nil, nil, _op), do: nil - defp join_exprs(expr, nil, _op), do: expr - defp join_exprs(nil, expr, _op), do: expr - defp join_exprs(left_expr, right_expr, op), do: {op, [], [left_expr, right_expr]} - - defp filter_to_expr(filter, bindings, params, current_binding \\ 0, path \\ []) - - # A completely empty filter means "everything" - defp filter_to_expr(%{impossible?: true}, _, _, _, _), do: {[], false} - - defp filter_to_expr( - %{ands: [], ors: [], not: nil, attributes: attrs, relationships: rels}, - _, - _, - _, - _ - ) - when attrs == %{} and rels == %{} do - {[], true} - end - - defp filter_to_expr(filter, bindings, params, current_binding, path) do - {params, expr} = - Enum.reduce(filter.attributes, {params, nil}, fn {attribute, filter}, - {params, existing_expr} -> - {params, new_expr} = filter_value_to_expr(attribute, filter, current_binding, params) - - {params, join_exprs(existing_expr, new_expr, :and)} - end) - - {params, expr} = - Enum.reduce(filter.relationships, {params, expr}, fn {relationship, relationship_filter}, - {params, existing_expr} -> - full_path = path ++ [relationship] - - binding = - Map.get(bindings, full_path) || - raise "unbound relationship #{inspect(full_path)} referenced! #{inspect(bindings)}" - - {params, new_expr} = - filter_to_expr(relationship_filter, bindings, params, binding.binding, full_path) - - {params, join_exprs(new_expr, existing_expr, :and)} - end) - - {params, expr} = - Enum.reduce(filter.ors, {params, expr}, fn or_filter, {params, existing_expr} -> - {params, new_expr} = filter_to_expr(or_filter, bindings, params, current_binding, path) - - {params, join_exprs(existing_expr, new_expr, :or)} - end) - - {params, expr} = - case filter.not do - nil -> - {params, expr} - - not_filter -> - {params, new_expr} = filter_to_expr(not_filter, bindings, params, current_binding, path) - - {params, join_exprs(expr, {:not, [], [new_expr]}, :and)} - end - - {params, expr} = - Enum.reduce(filter.ands, {params, expr}, fn and_filter, {params, existing_expr} -> - {params, new_expr} = filter_to_expr(and_filter, bindings, params, current_binding, path) - - {params, join_exprs(existing_expr, new_expr, :and)} - end) - - if expr do - {params, expr} - else - # A filter that was not empty, but didn't generate an expr for some reason, should default to `false` - # AFAIK this shouldn't actually be possible - {params, false} - end - end - - # THe fact that we keep counting params here is very silly. - defp filter_value_to_expr(attribute, %Eq{value: value}, current_binding, params) do - {params ++ [{value, {current_binding, attribute}}], - {:==, [], - [ - {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, - {:^, [], [Enum.count(params)]} - ]}} - end - - defp filter_value_to_expr(attribute, %NotEq{value: value}, current_binding, params) do - {params ++ [{value, {current_binding, attribute}}], - {:==, [], - [ - {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, - {:^, [], [Enum.count(params)]} - ]}} - end - - defp filter_value_to_expr(attribute, %In{values: values}, current_binding, params) do - {params ++ [{values, {:in, {current_binding, attribute}}}], - {:in, [], - [ - {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, - {:^, [], [Enum.count(params)]} - ]}} - end - - defp filter_value_to_expr( - attribute, - %NotIn{values: values}, - current_binding, - params - ) do - {params ++ [{values, {:in, {current_binding, attribute}}}], - {:not, - {:in, [], - [ - {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, - {:^, [], [Enum.count(params)]} - ]}}} - end - - defp filter_value_to_expr( - attribute, - %Trigram{} = trigram, - current_binding, - params - ) do - param_count = Enum.count(params) - - case trigram do - %{equals: equals, greater_than: nil, less_than: nil, text: text} -> - {params ++ [{text, {current_binding, attribute}}, {equals, :float}], - {:fragment, [], - [ - raw: "similarity(", - expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, - raw: ", ", - expr: {:^, [], [param_count]}, - raw: ") = ", - expr: {:^, [], [param_count + 1]}, - raw: "" - ]}} - - %{equals: nil, greater_than: greater_than, less_than: nil, text: text} -> - {params ++ [{text, {current_binding, attribute}}, {greater_than, :float}], - {:fragment, [], - [ - raw: "similarity(", - expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, - raw: ", ", - expr: {:^, [], [param_count]}, - raw: ") > ", - expr: {:^, [], [param_count + 1]}, - raw: "" - ]}} - - %{equals: nil, greater_than: nil, less_than: less_than, text: text} -> - {params ++ [{text, {current_binding, attribute}}, {less_than, :float}], - {:fragment, [], - [ - raw: "similarity(", - expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, - raw: ", ", - expr: {:^, [], [param_count]}, - raw: ") < ", - expr: {:^, [], [param_count + 1]}, - raw: "" - ]}} - - %{equals: nil, greater_than: greater_than, less_than: less_than, text: text} -> - {params ++ - [{text, {current_binding, attribute}}, {less_than, :float}, {greater_than, :float}], - {:fragment, [], - [ - raw: "similarity(", - expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, - raw: ", ", - expr: {:^, [], [param_count]}, - raw: ") BETWEEN ", - expr: {:^, [], [param_count + 1]}, - raw: " AND ", - expr: {:^, [], [param_count + 2]}, - raw: "" - ]}} - end - end - - defp filter_value_to_expr( - attribute, - %And{left: left, right: right}, - current_binding, - params - ) do - {params, left_expr} = filter_value_to_expr(attribute, left, current_binding, params) - - {params, right_expr} = filter_value_to_expr(attribute, right, current_binding, params) - - {params, join_exprs(left_expr, right_expr, :and)} - end - - defp filter_value_to_expr( - attribute, - %Or{left: left, right: right}, - current_binding, - params - ) do - {params, left_expr} = filter_value_to_expr(attribute, left, current_binding, params) - - {params, right_expr} = filter_value_to_expr(attribute, right, current_binding, params) - - {params, join_exprs(left_expr, right_expr, :or)} - end - - defp merge_bindings(query, %{__ash_bindings__: ash_bindings}) do - ash_bindings - |> Map.get(:bindings) - |> Enum.reduce(query, fn {path, data}, query -> - add_binding(query, path, data) - end) - end - - defp merge_bindings(query, _) do - query - end - - defp add_binding(query, path, type) do - current = query.__ash_bindings__.current - bindings = query.__ash_bindings__.bindings - - new_ash_bindings = %{ - query.__ash_bindings__ - | bindings: do_add_binding(bindings, path, current, type), - current: current + 1 - } - - %{query | __ash_bindings__: new_ash_bindings} - end - - defp do_add_binding(bindings, path, current, type) do - Map.put(bindings, path, %{binding: current, type: type}) - end - - @impl true - def can_query_async?(resource) do - repo(resource).in_transaction?() - end - - @impl true - def transaction(resource, func) do - repo(resource).transaction(func) - end - - defp maybe_get_resource_query(resource) do - if Ash.resource_module?(resource) do - {resource.postgres_table(), resource} - else - resource - end + @doc "Fetch the configured table for a resource" + def table(resource) do + Extension.get_opt(resource, [:postgres], :table) end end diff --git a/lib/data_layer.ex b/lib/data_layer.ex new file mode 100644 index 0000000..6e81add --- /dev/null +++ b/lib/data_layer.ex @@ -0,0 +1,625 @@ +defmodule AshPostgres.DataLayer do + @moduledoc """ + A postgres data layer that levereges Ecto's postgres capabilities. + """ + @postgres %Ash.Dsl.Section{ + name: :postgres, + describe: """ + Postgres data layer configuration + """, + schema: [ + repo: [ + type: {:custom, AshPostgres.DataLayer, :validate_repo, []}, + required: true, + doc: + "The repo that will be used to fetch your data. See the `Ecto.Repo` documentation for more" + ], + table: [ + type: :string, + required: true, + doc: "The table to store and read the resource from" + ] + ] + } + + alias Ash.Filter.{And, Eq, In, NotEq, NotIn, Or} + alias AshPostgres.Predicates.Trigram + + import AshPostgres, only: [table: 1, repo: 1] + + @behaviour Ash.DataLayer + + use Ash.Dsl.Extension, sections: [@postgres] + + @doc false + def validate_repo(repo) do + if repo.__adapter__() == Ecto.Adapters.Postgres do + {:ok, repo} + else + {:error, "Expected a repo using the postgres adapter `Ecto.Adapters.Postgres`"} + end + end + + @impl true + def custom_filters(resource) do + config = repo(resource).config() + + add_pg_trgm_search(%{}, config) + end + + defp add_pg_trgm_search(filters, config) do + if "pg_trgm" in config[:installed_extensions] do + Map.update(filters, :string, [{:trigram, AshPostgres.Predicates.Trigram}], fn filters -> + [{:trigram, AshPostgres.Predicates.Trigram} | filters] + end) + else + filters + end + end + + import Ecto.Query, only: [from: 2] + + @impl true + def can?(_, capability), do: can?(capability) + defp can?(:query_async), do: true + defp can?(:transact), do: true + defp can?(:composite_primary_key), do: true + defp can?(:upsert), do: true + defp can?({:filter, :in}), do: true + defp can?({:filter, :not_in}), do: true + defp can?({:filter, :not_eq}), do: true + defp can?({:filter, :eq}), do: true + defp can?({:filter, :and}), do: true + defp can?({:filter, :or}), do: true + defp can?({:filter, :not}), do: true + defp can?({:filter, :trigram}), do: true + defp can?({:filter_related, _}), do: true + defp can?(_), do: false + + @impl true + def limit(query, nil, _), do: {:ok, query} + + def limit(query, limit, _resource) do + {:ok, from(row in query, limit: ^limit)} + end + + @impl true + def offset(query, nil, _), do: query + + def offset(query, offset, _resource) do + {:ok, from(row in query, offset: ^offset)} + end + + @impl true + def run_query(%{__impossible__: true}, _) do + {:ok, []} + end + + @impl true + def run_query(query, resource) do + {:ok, repo(resource).all(query)} + end + + @impl true + def resource_to_query(resource), + do: Ecto.Queryable.to_query({table(resource), resource}) + + @impl true + def create(resource, changeset) do + changeset = + Map.update!(changeset, :action, fn + :create -> :insert + action -> action + end) + + changeset = + Map.update!(changeset, :data, fn data -> + Map.update!(data, :__meta__, &Map.put(&1, :source, table(resource))) + end) + + repo(resource).insert(changeset) + rescue + e -> + {:error, e} + end + + @impl true + def upsert(resource, changeset) do + changeset = + Map.update!(changeset, :action, fn + :create -> :insert + action -> action + end) + + changeset = + Map.update!(changeset, :data, fn data -> + Map.update!(data, :__meta__, &Map.put(&1, :source, table(resource))) + end) + + repo(resource).insert(changeset, + on_conflict: :replace_all, + conflict_target: Ash.primary_key(resource) + ) + rescue + e -> + {:error, e} + end + + @impl true + def update(resource, changeset) do + repo(resource).update(changeset) + rescue + e -> + {:error, e} + end + + @impl true + def destroy(%resource{} = record) do + case repo(resource).delete(record) do + {:ok, _record} -> :ok + {:error, error} -> {:error, error} + end + rescue + e -> + {:error, e} + end + + @impl true + def sort(query, sort, _resource) do + {:ok, + from(row in query, + order_by: ^sanitize_sort(sort) + )} + end + + defp sanitize_sort(sort) do + sort + |> List.wrap() + |> Enum.map(fn + {sort, order} -> {order, sort} + sort -> sort + end) + end + + @impl true + def filter(query, filter, _resource) do + new_query = + query + |> Map.put(:bindings, %{}) + |> join_all_relationships(filter) + |> add_filter_expression(filter) + + impossible_query = + if filter.impossible? do + Map.put(new_query, :__impossible__, true) + else + new_query + end + + {:ok, impossible_query} + end + + defp join_all_relationships(query, filter, path \\ []) do + query = + Map.put_new(query, :__ash_bindings__, %{current: Enum.count(query.joins) + 1, bindings: %{}}) + + Enum.reduce(filter.relationships, query, fn {name, relationship_filter}, query -> + join_type = :left + + case {join_type, relationship_filter} do + {join_type, relationship_filter} -> + relationship = Ash.relationship(filter.resource, name) + + current_path = [relationship | path] + + joined_query = join_relationship(query, current_path, join_type) + + joined_query_with_distinct = + join_and_add_distinct(relationship, join_type, joined_query, filter) + + join_all_relationships(joined_query_with_distinct, relationship_filter, current_path) + end + end) + end + + defp join_and_add_distinct(relationship, join_type, joined_query, filter) do + if relationship.cardinality == :many and join_type == :left && !joined_query.distinct do + from(row in joined_query, + distinct: ^Ash.primary_key(filter.resource) + ) + else + joined_query + end + end + + defp join_relationship(query, path, join_type) do + path_names = Enum.map(path, & &1.name) + + case Map.get(query.__ash_bindings__.bindings, path_names) do + %{type: existing_join_type} when join_type != existing_join_type -> + raise "unreachable?" + + nil -> + do_join_relationship(query, path, join_type) + + _ -> + query + end + end + + defp do_join_relationship(query, relationships, kind, path \\ []) + defp do_join_relationship(_, [], _, _), do: nil + + defp do_join_relationship(query, [%{type: :many_to_many} = relationship | rest], :inner, path) do + relationship_through = maybe_get_resource_query(relationship.through) + + relationship_destination = + do_join_relationship(query, rest, :inner, [relationship.name | path]) || + Ecto.Queryable.to_query(maybe_get_resource_query(relationship.destination)) + + new_query = + from(row in query, + join: through in ^relationship_through, + on: + field(row, ^relationship.source_field) == + field(through, ^relationship.source_field_on_join_table), + join: destination in ^relationship_destination, + on: + field(destination, ^relationship.destination_field) == + field(through, ^relationship.destination_field_on_join_table) + ) + + join_path = + Enum.reverse([String.to_existing_atom(to_string(relationship.name) <> "_join_assoc") | path]) + + full_path = Enum.reverse([relationship.name | path]) + + new_query + |> add_binding(join_path, :inner) + |> add_binding(full_path, :inner) + |> merge_bindings(relationship_destination) + end + + defp do_join_relationship(query, [relationship | rest], :inner, path) do + relationship_destination = + do_join_relationship(query, rest, :inner, [relationship.name | path]) || + Ecto.Queryable.to_query(maybe_get_resource_query(relationship.destination)) + + new_query = + from(row in query, + join: destination in ^relationship_destination, + on: + field(row, ^relationship.source_field) == + field(destination, ^relationship.destination_field) + ) + + new_query + |> add_binding(Enum.reverse([relationship.name | path]), :inner) + |> merge_bindings(relationship_destination) + end + + defp do_join_relationship(query, [%{type: :many_to_many} = relationship | rest], :left, path) do + relationship_through = maybe_get_resource_query(relationship.through) + + relationship_destination = + do_join_relationship(query, rest, :inner, [relationship.name | path]) || + Ecto.Queryable.to_query(maybe_get_resource_query(relationship.destination)) + + new_query = + from(row in query, + left_join: through in ^relationship_through, + on: + field(row, ^relationship.source_field) == + field(through, ^relationship.source_field_on_join_table), + left_join: destination in ^relationship_destination, + on: + field(destination, ^relationship.destination_field) == + field(through, ^relationship.destination_field_on_join_table) + ) + + join_path = + Enum.reverse([String.to_existing_atom(to_string(relationship.name) <> "_join_assoc") | path]) + + full_path = Enum.reverse([relationship.name | path]) + + new_query + |> add_binding(join_path, :left) + |> add_binding(full_path, :left) + |> merge_bindings(relationship_destination) + end + + defp do_join_relationship(query, [relationship | rest], :left, path) do + relationship_destination = + do_join_relationship(query, rest, :inner, [relationship.name | path]) || + Ecto.Queryable.to_query(maybe_get_resource_query(relationship.destination)) + + new_query = + from(row in query, + left_join: destination in ^relationship_destination, + on: + field(row, ^relationship.source_field) == + field(destination, ^relationship.destination_field) + ) + + new_query + |> add_binding(Enum.reverse([relationship.name | path]), :left) + |> merge_bindings(relationship_destination) + end + + defp add_filter_expression(query, filter) do + {params, expr} = filter_to_expr(filter, query.__ash_bindings__.bindings, []) + + if expr do + boolean_expr = %Ecto.Query.BooleanExpr{ + expr: expr, + op: :and, + params: params + } + + %{query | wheres: [boolean_expr | query.wheres]} + else + query + end + end + + defp join_exprs(nil, nil, _op), do: nil + defp join_exprs(expr, nil, _op), do: expr + defp join_exprs(nil, expr, _op), do: expr + defp join_exprs(left_expr, right_expr, op), do: {op, [], [left_expr, right_expr]} + + defp filter_to_expr(filter, bindings, params, current_binding \\ 0, path \\ []) + + # A completely empty filter means "everything" + defp filter_to_expr(%{impossible?: true}, _, _, _, _), do: {[], false} + + defp filter_to_expr( + %{ands: [], ors: [], not: nil, attributes: attrs, relationships: rels}, + _, + _, + _, + _ + ) + when attrs == %{} and rels == %{} do + {[], true} + end + + defp filter_to_expr(filter, bindings, params, current_binding, path) do + {params, expr} = + Enum.reduce(filter.attributes, {params, nil}, fn {attribute, filter}, + {params, existing_expr} -> + {params, new_expr} = filter_value_to_expr(attribute, filter, current_binding, params) + + {params, join_exprs(existing_expr, new_expr, :and)} + end) + + {params, expr} = + Enum.reduce(filter.relationships, {params, expr}, fn {relationship, relationship_filter}, + {params, existing_expr} -> + full_path = path ++ [relationship] + + binding = + Map.get(bindings, full_path) || + raise "unbound relationship #{inspect(full_path)} referenced! #{inspect(bindings)}" + + {params, new_expr} = + filter_to_expr(relationship_filter, bindings, params, binding.binding, full_path) + + {params, join_exprs(new_expr, existing_expr, :and)} + end) + + {params, expr} = + Enum.reduce(filter.ors, {params, expr}, fn or_filter, {params, existing_expr} -> + {params, new_expr} = filter_to_expr(or_filter, bindings, params, current_binding, path) + + {params, join_exprs(existing_expr, new_expr, :or)} + end) + + {params, expr} = + case filter.not do + nil -> + {params, expr} + + not_filter -> + {params, new_expr} = filter_to_expr(not_filter, bindings, params, current_binding, path) + + {params, join_exprs(expr, {:not, [], [new_expr]}, :and)} + end + + {params, expr} = + Enum.reduce(filter.ands, {params, expr}, fn and_filter, {params, existing_expr} -> + {params, new_expr} = filter_to_expr(and_filter, bindings, params, current_binding, path) + + {params, join_exprs(existing_expr, new_expr, :and)} + end) + + if expr do + {params, expr} + else + # A filter that was not empty, but didn't generate an expr for some reason, should default to `false` + # AFAIK this shouldn't actually be possible + {params, false} + end + end + + # THe fact that we keep counting params here is very silly. + defp filter_value_to_expr(attribute, %Eq{value: value}, current_binding, params) do + {params ++ [{value, {current_binding, attribute}}], + {:==, [], + [ + {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, + {:^, [], [Enum.count(params)]} + ]}} + end + + defp filter_value_to_expr(attribute, %NotEq{value: value}, current_binding, params) do + {params ++ [{value, {current_binding, attribute}}], + {:==, [], + [ + {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, + {:^, [], [Enum.count(params)]} + ]}} + end + + defp filter_value_to_expr(attribute, %In{values: values}, current_binding, params) do + {params ++ [{values, {:in, {current_binding, attribute}}}], + {:in, [], + [ + {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, + {:^, [], [Enum.count(params)]} + ]}} + end + + defp filter_value_to_expr( + attribute, + %NotIn{values: values}, + current_binding, + params + ) do + {params ++ [{values, {:in, {current_binding, attribute}}}], + {:not, + {:in, [], + [ + {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, + {:^, [], [Enum.count(params)]} + ]}}} + end + + defp filter_value_to_expr( + attribute, + %Trigram{} = trigram, + current_binding, + params + ) do + param_count = Enum.count(params) + + case trigram do + %{equals: equals, greater_than: nil, less_than: nil, text: text} -> + {params ++ [{text, {current_binding, attribute}}, {equals, :float}], + {:fragment, [], + [ + raw: "similarity(", + expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, + raw: ", ", + expr: {:^, [], [param_count]}, + raw: ") = ", + expr: {:^, [], [param_count + 1]}, + raw: "" + ]}} + + %{equals: nil, greater_than: greater_than, less_than: nil, text: text} -> + {params ++ [{text, {current_binding, attribute}}, {greater_than, :float}], + {:fragment, [], + [ + raw: "similarity(", + expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, + raw: ", ", + expr: {:^, [], [param_count]}, + raw: ") > ", + expr: {:^, [], [param_count + 1]}, + raw: "" + ]}} + + %{equals: nil, greater_than: nil, less_than: less_than, text: text} -> + {params ++ [{text, {current_binding, attribute}}, {less_than, :float}], + {:fragment, [], + [ + raw: "similarity(", + expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, + raw: ", ", + expr: {:^, [], [param_count]}, + raw: ") < ", + expr: {:^, [], [param_count + 1]}, + raw: "" + ]}} + + %{equals: nil, greater_than: greater_than, less_than: less_than, text: text} -> + {params ++ + [{text, {current_binding, attribute}}, {less_than, :float}, {greater_than, :float}], + {:fragment, [], + [ + raw: "similarity(", + expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []}, + raw: ", ", + expr: {:^, [], [param_count]}, + raw: ") BETWEEN ", + expr: {:^, [], [param_count + 1]}, + raw: " AND ", + expr: {:^, [], [param_count + 2]}, + raw: "" + ]}} + end + end + + defp filter_value_to_expr( + attribute, + %And{left: left, right: right}, + current_binding, + params + ) do + {params, left_expr} = filter_value_to_expr(attribute, left, current_binding, params) + + {params, right_expr} = filter_value_to_expr(attribute, right, current_binding, params) + + {params, join_exprs(left_expr, right_expr, :and)} + end + + defp filter_value_to_expr( + attribute, + %Or{left: left, right: right}, + current_binding, + params + ) do + {params, left_expr} = filter_value_to_expr(attribute, left, current_binding, params) + + {params, right_expr} = filter_value_to_expr(attribute, right, current_binding, params) + + {params, join_exprs(left_expr, right_expr, :or)} + end + + defp merge_bindings(query, %{__ash_bindings__: ash_bindings}) do + ash_bindings + |> Map.get(:bindings) + |> Enum.reduce(query, fn {path, data}, query -> + add_binding(query, path, data) + end) + end + + defp merge_bindings(query, _) do + query + end + + defp add_binding(query, path, type) do + current = query.__ash_bindings__.current + bindings = query.__ash_bindings__.bindings + + new_ash_bindings = %{ + query.__ash_bindings__ + | bindings: do_add_binding(bindings, path, current, type), + current: current + 1 + } + + %{query | __ash_bindings__: new_ash_bindings} + end + + defp do_add_binding(bindings, path, current, type) do + Map.put(bindings, path, %{binding: current, type: type}) + end + + @impl true + def can_query_async?(resource) do + repo(resource).in_transaction?() + end + + @impl true + def transaction(resource, func) do + repo(resource).transaction(func) + end + + defp maybe_get_resource_query(resource) do + if Ash.resource_module?(resource) do + {table(resource), resource} + else + resource + end + end +end diff --git a/lib/repo.ex b/lib/repo.ex index a282348..d3d1d15 100644 --- a/lib/repo.ex +++ b/lib/repo.ex @@ -2,7 +2,7 @@ defmodule AshPostgres.Repo do @moduledoc """ Resources that use the `AshPostgres` data layer use a `Repo` to access the database. - This module is where database connection and configuration options go. + This repo is a slightly modified version of an ecto repo. """ @callback installed_extensions() :: [String.t()] diff --git a/logos/small-logo.png b/logos/small-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9fc9aa1801a2a5d20a6525f31fe7a619fb39f044 GIT binary patch literal 3088 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 D^yu%Z literal 0 HcmV?d00001 diff --git a/mix.exs b/mix.exs index 2a46b85..d295768 100644 --- a/mix.exs +++ b/mix.exs @@ -24,6 +24,7 @@ defmodule AshPostgres.MixProject do dialyzer: [ plt_add_apps: [:ecto] ], + docs: docs(), aliases: aliases(), package: package(), source_url: "https://github.com/ash-project/ash_postgres", @@ -41,14 +42,29 @@ defmodule AshPostgres.MixProject do ] end + defp docs do + [ + main: "AshPostgres", + source_ref: "v#{@version}", + logo: "logos/small-logo.png", + groups_for_modules: [ + "entry point": [AshPostgres], + "data layer": [AshPostgres.DataLayer], + repo: [AshPostgres.Repo], + "filter predicates": ~r/AshPostgres.Predicates/ + ] + ] + end + # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ecto_sql, "~> 3.0"}, + {:ecto_sql, "~> 3.4"}, {:postgrex, ">= 0.0.0"}, - {:ash, "~> 0.3.0"}, + {:ash, "~> 0.4.0"}, + {:db_connection, "~> 2.2", override: true}, {:git_ops, "~> 2.0.0", only: :dev}, - {:ex_doc, "~> 0.21", only: :dev, runtime: false}, + {:ex_doc, "~> 0.22", only: :dev, runtime: false}, {:ex_check, "~> 0.11.0", only: :dev}, {:credo, ">= 0.0.0", only: :dev, runtime: false}, {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 27f1e38..595c663 100644 --- a/mix.lock +++ b/mix.lock @@ -1,21 +1,21 @@ %{ - "ash": {:hex, :ash, "0.3.0", "932799b55435893354b9ebb4c219509b9cd5efea3575cea9185052672b493b2e", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.2.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.1.3", [hex: :picosat_elixir, repo: "hexpm", optional: false]}], "hexpm", "9106ba425611020f33e232db123da63ba3b0323fa2b67bc3b8f2bea11b020d02"}, + "ash": {:hex, :ash, "0.4.0", "23cff17ca4e13e12ea82f060cd3bfa409e702d5b5867aee0a9090ad8bd816178", [:mix], [{:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.2.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.1.4", [hex: :picosat_elixir, repo: "hexpm", optional: false]}], "hexpm", "81579015955c3028a36d95fe3a6a69ca69c0d2a25ede014a1cc9f5ccc8eae5a8"}, "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"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "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"}, "dataloader": {:hex, :dataloader, "1.0.6", "fb724d6d3fb6acb87d27e3b32dea3a307936ad2d245faf9cf5221d1323d6a4ba", [:mix], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, - "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "5a0e8c1c722dbcd31c0cbd1906b1d1074c863d335c295e4b994849b65a1fbe47"}, + "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, - "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, - "ecto": {:hex, :ecto, "3.2.5", "76c864b77948a479e18e69cc1d0f0f4ee7cced1148ffe6a093ff91eba644f0b5", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "01251d9b28081b7e0af02a1875f9b809b057f064754ca3b274949d5216ea6f5f"}, - "ecto_sql": {:hex, :ecto_sql, "3.2.0", "751cea597e8deb616084894dd75cbabfdbe7255ff01e8c058ca13f0353a3921b", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a2e23cf761668126252418cae07eff7967ad0152fbc5e2d0dc3de487a5ec774c"}, + "earmark": {:hex, :earmark, "1.4.5", "62ffd3bd7722fb7a7b1ecd2419ea0b458c356e7168c1f5d65caf09b4fbdd13c8", [:mix], [], "hexpm", "b7d0e6263d83dc27141a523467799a685965bf8b13b6743413f19a7079843f4f"}, + "ecto": {:hex, :ecto, "3.4.4", "a2c881e80dc756d648197ae0d936216c0308370332c5e77a2325a10293eef845", [: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", "cc4bd3ad62abc3b21fb629f0f7a3dab23a192fca837d257dd08449fba7373561"}, + "ecto_sql": {:hex, :ecto_sql, "3.4.4", "d28bac2d420f708993baed522054870086fd45016a9d09bb2cd521b9c48d32ea", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "edb49af715dd72f213b66adfd0f668a43c17ed510b5d9ac7528569b23af57fe8"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, "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.11.0", "6d878d9ae30d19168157bcbf346b527825284e14e77a07ec0492b19cf0036479", [:mix], [], "hexpm", "d41894aa6193f089a05e3abb43ca457e289619fcfbbdd7b60d070b7a62b26832"}, - "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, + "ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"}, "excoveralls": {:hex, :excoveralls, "0.13.0", "4e1b7cc4e0351d8d16e9be21b0345a7e165798ee5319c7800b9138ce17e0b38e", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "fe2a56c8909564e2e6764765878d7d5e141f2af3bc8ff3b018a68ee2a218fced"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, "git_ops": {:hex, :git_ops, "2.0.0", "d720b54de2ce9ca242164c57c982e4f05c1b6c020db2785e338f93b6190980aa", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9aa270ea1cd4500eac4f38cac9b24019eee0aa524b96c95ad2f90cb0010840db"}, @@ -23,20 +23,20 @@ "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"}, "machinery": {:hex, :machinery, "1.0.0", "df6968d84c651b9971a33871c78c10157b6e13e4f3390b0bee5b0e8bdea8c781", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "4f6eb4185a48e7245360bedf653af4acc6fa6ae8ff4690619395543fa1a8395f"}, - "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, + "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"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "nimble_options": {:hex, :nimble_options, "0.2.1", "7eac99688c2544d4cc3ace36ee8f2bf4d738c14d031bd1e1193aab096309d488", [:mix], [], "hexpm", "ca48293609306791ce2634818d849b7defe09330adb7e4e1118a0bc59bed1cf4"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.2", "1d71150d5293d703a9c38d4329da57d3935faed2031d64bc19e77b654ef2d177", [:mix], [], "hexpm", "51aa192e0941313c394956718bdb1e59325874f88f45871cff90345b97f60bba"}, + "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.3", "1e4eab27786b7dc7764c307555d8943cbba82912ed943737372760377be05ec8", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3add4d5ea1afa49f51bb7576bae000fe88091969804cc25baf47ffec48a9c626"}, + "picosat_elixir": {:hex, :picosat_elixir, "0.1.4", "d259219ae27148c07c4aa3fdee61b1a14f4bc7f83b0ebdf2752558d06b302c62", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "eb41cb16053a45c8556de32f065084af98ea0b13a523fb46dfb4f9cff4152474"}, "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, - "postgrex": {:hex, :postgrex, "0.15.1", "23ce3417de70f4c0e9e7419ad85bdabcc6860a6925fe2c6f3b1b5b1e8e47bf2f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "12cd418e207b8ed787dfe0f520fccd6c001f58d9108233feae7df36462593d1f"}, + "postgrex": {:hex, :postgrex, "0.15.5", "aec40306a622d459b01bff890fa42f1430dac61593b122754144ad9033a2152f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ed90c81e1525f65a2ba2279dbcebf030d6d13328daa2f8088b9661eb9143af7f"}, "sobelow": {:hex, :sobelow, "0.10.2", "00e91208046d3b434f9f08779fe0ca7c6d6595b7fa33b289e792dffa6dde8081", [:mix], [], "hexpm", "e30fc994330cf6f485c1c4f2fb7c4b2d403557d0e101c6e5329fd17a58e55a7e"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm", "e9e3cacfd37c1531c0ca70ca7c0c30ce2dbb02998a4f7719de180fe63f8d41e4"}, + "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, }