Current section

Files

Jump to
query_builder lib schema.ex
Raw

lib/schema.ex

defmodule QueryBuilder.Schema do
@moduledoc false
# QueryBuilder builds up a list of operations (where/order_by/preload/joins) and only
# later turns them into an `Ecto.Query` with joins and `dynamic/2` expressions. In
# order to generate expressions like `field(x, ^field)` against the *right* joined
# table, it needs a stable way to refer to each join.
#
# Ecto’s named bindings (`as: ...` + `[{^binding, x}]`) are that stable handle: they
# are order-independent and composable. Without named bindings, QueryBuilder would
# have to track positional bind indexes (`[u, r, a, ...]`) and would become brittle
# as joins are added/merged/reordered.
#
# Preloading “with bindings” also depends on named bindings
# (`Ecto.Query.preload(query, [{^binding, x}], ...)`) to map joined rows back onto
# associations.
#
# Binding names for schema associations are generated automatically at compile time
# in `__before_compile__/1`.
defmacro __using__(opts) do
# Migration guard: v1 accepted `assoc_fields:` for manual association binding names. In v2,
# association bindings are generated automatically, so we fail fast with an upgrade hint.
if Keyword.has_key?(opts, :assoc_fields) do
raise ArgumentError,
"the `assoc_fields:` option was removed in QueryBuilder v2; " <>
"association bindings are now generated automatically"
end
unless opts == [] do
raise ArgumentError,
"unknown options passed to `use QueryBuilder`: #{inspect(opts)} " <>
"(supported options: none)"
end
[
quote do
require Ecto.Query
@before_compile unquote(__MODULE__)
# assign a named binding (the schema's module name) to the schema of the root query
def _query(), do: Ecto.Query.from(x in __MODULE__, as: unquote(__CALLER__.module))
def _binding(), do: __MODULE__
end
]
end
defmacro __before_compile__(env) do
assocs =
env.module
|> Module.get_attribute(:ecto_assocs)
|> List.wrap()
assoc_bindings =
Enum.map(assocs, fn {name, _struct} ->
binding =
("qb__" <> to_string(env.module) <> "__" <> to_string(name))
|> String.to_atom()
{name, binding}
end)
assoc_latest_bindings =
Enum.map(assocs, fn {name, _struct} ->
binding =
("qb__latest__" <> to_string(env.module) <> "__" <> to_string(name))
|> String.to_atom()
{name, binding}
end)
assoc_left_join_top_n_bindings =
Enum.map(assocs, fn {name, _struct} ->
binding =
("qb__top_n__" <> to_string(env.module) <> "__" <> to_string(name))
|> String.to_atom()
{name, binding}
end)
binding_clauses =
for {assoc_field, binding} <- assoc_bindings do
quote do
def _binding(unquote(assoc_field)), do: unquote(binding)
end
end
latest_binding_clauses =
for {assoc_field, binding} <- assoc_latest_bindings do
quote do
def _latest_binding(unquote(assoc_field)), do: unquote(binding)
end
end
left_join_top_n_binding_clauses =
for {assoc_field, binding} <- assoc_left_join_top_n_bindings do
quote do
def _top_n_binding(unquote(assoc_field)), do: unquote(binding)
end
end
schema_clauses =
for {assoc_field, assoc_struct} <- assocs do
case assoc_struct do
%Ecto.Association.HasThrough{through: through} when is_list(through) ->
through = Macro.escape(through)
quote do
def _assoc_schema(unquote(assoc_field)) do
QueryBuilder.Schema.has_through_queryable!(__MODULE__, unquote(through))
end
end
%{related: assoc_schema} when is_atom(assoc_schema) ->
quote do
def _assoc_schema(unquote(assoc_field)), do: unquote(assoc_schema)
end
other ->
raise ArgumentError,
"QueryBuilder.Schema could not resolve association schema for #{inspect(env.module)}.#{inspect(assoc_field)} " <>
"(expected assoc to have a :related schema module, got: #{inspect(other)})"
end
end
cardinality_clauses =
for {assoc_field, assoc_struct} <- assocs do
quote do
def _assoc_cardinality(unquote(assoc_field)), do: unquote(assoc_struct.cardinality)
end
end
assoc_fields = Enum.map(assoc_bindings, &elem(&1, 0))
join_clause =
if assocs == [] do
quote do
def _join(_query, _type, _source_binding, assoc_field, _on) do
raise ArgumentError,
"unknown association #{inspect(assoc_field)} for #{inspect(__MODULE__)}; " <>
"available associations: #{inspect(__schema__(:associations))}"
end
end
else
quote do
def _join(query, type, source_binding, assoc_field, on) do
assoc_binding = _binding(assoc_field)
Ecto.Query.join(
query,
type,
[{^source_binding, x}],
y in assoc(x, ^assoc_field),
as: ^assoc_binding,
on: ^on
)
end
end
end
quote do
unquote_splicing(binding_clauses)
def _binding(assoc_field) do
raise ArgumentError,
"unknown association #{inspect(assoc_field)} for #{inspect(__MODULE__)}; " <>
"available associations: #{inspect(__schema__(:associations))}"
end
unquote_splicing(latest_binding_clauses)
def _latest_binding(assoc_field) do
raise ArgumentError,
"unknown association #{inspect(assoc_field)} for #{inspect(__MODULE__)}; " <>
"available associations: #{inspect(__schema__(:associations))}"
end
unquote_splicing(left_join_top_n_binding_clauses)
def _top_n_binding(assoc_field) do
raise ArgumentError,
"unknown association #{inspect(assoc_field)} for #{inspect(__MODULE__)}; " <>
"available associations: #{inspect(__schema__(:associations))}"
end
unquote_splicing(schema_clauses)
def _assoc_schema(assoc_field) do
raise ArgumentError,
"unknown association #{inspect(assoc_field)} for #{inspect(__MODULE__)}; " <>
"available associations: #{inspect(__schema__(:associations))}"
end
unquote_splicing(cardinality_clauses)
def _assoc_cardinality(assoc_field) do
raise ArgumentError,
"unknown association #{inspect(assoc_field)} for #{inspect(__MODULE__)}; " <>
"available associations: #{inspect(__schema__(:associations))}"
end
def _assoc_fields(), do: unquote(assoc_fields)
unquote(join_clause)
end
end
def has_through_queryable!(owner_schema, through)
when is_atom(owner_schema) and is_list(through) do
Enum.reduce(through, owner_schema, fn assoc_field, schema ->
assoc = schema.__schema__(:association, assoc_field)
if is_nil(assoc) do
raise ArgumentError,
"QueryBuilder.Schema could not resolve has_through path #{inspect(through)} because " <>
"#{inspect(schema)} has no association #{inspect(assoc_field)}"
end
case assoc do
%{related: assoc_schema} when is_atom(assoc_schema) ->
assoc_schema
%Ecto.Association.HasThrough{through: nested_through} when is_list(nested_through) ->
has_through_queryable!(schema, nested_through)
other ->
raise ArgumentError,
"QueryBuilder.Schema could not resolve has_through path #{inspect(through)} because " <>
"#{inspect(schema)}.#{inspect(assoc_field)} has unsupported assoc reflection: #{inspect(other)}"
end
end)
end
end