Current section

Files

Jump to
query_builder lib join_maker.ex
Raw

lib/join_maker.ex

defmodule QueryBuilder.JoinMaker do
@moduledoc false
require Ecto.Query
alias QueryBuilder.AssocList.JoinSpec
@doc """
Emits joins described by the assoc tree.
Associations whose join spec has `required?: false` are not joined.
"""
def make_joins(ecto_query, %QueryBuilder.AssocList{} = assoc_list) do
{ecto_query, _joins_tuple} = do_make_joins(ecto_query, assoc_list.roots, assoc_list, nil)
ecto_query
end
def make_joins(_ecto_query, other) do
raise ArgumentError,
"QueryBuilder.JoinMaker.make_joins/2 expects a %QueryBuilder.AssocList{}, got: #{inspect(other)}"
end
defp do_make_joins(ecto_query, nodes_map, _original_assoc_list, joins_tuple)
when is_map(nodes_map) and map_size(nodes_map) == 0,
do: {ecto_query, joins_tuple}
defp do_make_joins(ecto_query, nodes_map, original_assoc_list, joins_tuple)
when is_map(nodes_map) do
Enum.reduce(nodes_map, {ecto_query, joins_tuple}, fn {_assoc_field, assoc_data},
{ecto_query, joins_tuple} ->
case assoc_data do
%{join_spec: %JoinSpec{required?: false}} ->
{ecto_query, joins_tuple}
_ ->
{ecto_query, joins_tuple} =
maybe_join(ecto_query, assoc_data, original_assoc_list, joins_tuple)
do_make_joins(ecto_query, assoc_data.nested_assocs, original_assoc_list, joins_tuple)
end
end)
end
defp maybe_join(ecto_query, assoc_data, original_assoc_list, joins_tuple) do
%{
source_binding: source_binding,
source_schema: source_schema,
assoc_binding: assoc_binding,
assoc_field: assoc_field,
assoc_schema: assoc_schema,
join_spec: %JoinSpec{qualifier: join_qualifier, filters: join_filters}
} = assoc_data
if Ecto.Query.has_named_binding?(ecto_query, assoc_binding) do
if join_filters != [] do
raise ArgumentError,
"QueryBuilder attempted to join #{inspect(source_schema)}.#{inspect(assoc_field)} " <>
"(assoc schema #{inspect(assoc_schema)}) using named binding #{inspect(assoc_binding)}, " <>
"but the query already has a named binding with that name. " <>
"This association also has join filters (from left_join*/*), " <>
"and QueryBuilder cannot safely apply those filters to an already-existing join. " <>
"Fix: remove the pre-joined binding, or join it under a different named binding."
end
joins_tuple =
validate_existing_assoc_join_cached!(ecto_query, assoc_data, join_qualifier, joins_tuple)
{ecto_query, joins_tuple}
else
on =
case join_filters do
[] ->
true
[{filters, or_filters}] ->
QueryBuilder.Query.Where.build_dynamic_query(
ecto_query,
original_assoc_list,
filters,
or_filters
)
join_filters ->
Enum.reduce(join_filters, nil, fn {filters, or_filters}, acc ->
dynamic =
QueryBuilder.Query.Where.build_dynamic_query(
ecto_query,
original_assoc_list,
filters,
or_filters
)
case acc do
nil -> dynamic
_ -> Ecto.Query.dynamic(^acc and ^dynamic)
end
end)
end
join_type =
case join_qualifier do
:any -> :left
other -> other
end
ecto_query = source_schema._join(ecto_query, join_type, source_binding, assoc_field, on)
{ecto_query, joins_tuple}
end
end
def validate_existing_assoc_join!(ecto_query, assoc_data, expected_qualifier \\ :any)
def validate_existing_assoc_join!(%Ecto.Query{} = ecto_query, assoc_data, expected_qualifier) do
_joins_tuple =
validate_existing_assoc_join_cached!(ecto_query, assoc_data, expected_qualifier, nil)
:ok
end
defp validate_existing_assoc_join_cached!(
%Ecto.Query{} = ecto_query,
assoc_data,
expected_qualifier,
joins_tuple
) do
%{
source_binding: source_binding,
source_schema: source_schema,
assoc_binding: assoc_binding,
assoc_field: assoc_field,
assoc_schema: assoc_schema
} = assoc_data
join_index =
case Map.fetch(ecto_query.aliases, assoc_binding) do
{:ok, index} when is_integer(index) and index > 0 ->
index
{:ok, 0} ->
raise ArgumentError,
"QueryBuilder attempted to reuse existing named binding #{inspect(assoc_binding)} for " <>
"#{inspect(source_schema)}.#{inspect(assoc_field)}, but that binding refers to the root query. " <>
"This is likely a query construction bug; please report it."
:error ->
raise ArgumentError,
"QueryBuilder attempted to reuse an existing named binding #{inspect(assoc_binding)} for " <>
"#{inspect(source_schema)}.#{inspect(assoc_field)}, but it was not found in the query aliases map. " <>
"This is likely a query construction bug; please report it."
end
{existing_join, joins_tuple} =
fetch_existing_join_expr!(ecto_query, assoc_binding, join_index, joins_tuple, assoc_data)
cond do
expected_qualifier != :any and existing_join.qual != expected_qualifier ->
raise ArgumentError,
"QueryBuilder attempted to join #{inspect(source_schema)}.#{inspect(assoc_field)} " <>
"(assoc schema #{inspect(assoc_schema)}) using named binding #{inspect(assoc_binding)}, " <>
"but the query already has that binding joined as #{inspect(existing_join.qual)} while " <>
"QueryBuilder requires #{inspect(expected_qualifier)}. " <>
"QueryBuilder cannot change the join qualifier of an existing join. " <>
"Fix: remove the pre-joined binding, or join it with the required qualifier under that binding."
expected_qualifier == :any and existing_join.qual not in [:inner, :left] ->
raise ArgumentError,
"QueryBuilder attempted to reuse existing named binding #{inspect(assoc_binding)} for " <>
"#{inspect(source_schema)}.#{inspect(assoc_field)}, but the existing join under that binding " <>
"has qualifier #{inspect(existing_join.qual)}. QueryBuilder can only reuse :inner or :left " <>
"association joins under named bindings."
true ->
:ok
end
expected_source_index = binding_index!(ecto_query, source_binding)
expected_assoc = {expected_source_index, assoc_field}
case existing_join.assoc do
^expected_assoc ->
:ok
nil ->
raise ArgumentError,
"QueryBuilder attempted to reuse existing named binding #{inspect(assoc_binding)} for " <>
"#{inspect(source_schema)}.#{inspect(assoc_field)}, but the existing join under that binding " <>
"is not an association join for that association. " <>
"QueryBuilder can only reuse a named binding when it was created by joining the same association " <>
"(e.g. `join: x in assoc(u, #{inspect(assoc_field)}), as: ^#{inspect(assoc_binding)}`). " <>
"Fix: rename the existing binding, or join the correct association under that binding."
{actual_source_index, actual_assoc_field} ->
actual_source_binding = binding_name_for_index(ecto_query, actual_source_index)
raise ArgumentError,
"QueryBuilder attempted to reuse existing named binding #{inspect(assoc_binding)} for " <>
"#{inspect(source_schema)}.#{inspect(assoc_field)}, but the existing join under that binding " <>
"is an association join for #{inspect(actual_source_binding)}.#{inspect(actual_assoc_field)} " <>
"instead. QueryBuilder cannot safely reuse a binding for a different association join. " <>
"Fix: rename the existing binding, or join the correct association under that binding."
other ->
raise ArgumentError,
"QueryBuilder attempted to reuse existing named binding #{inspect(assoc_binding)} for " <>
"#{inspect(source_schema)}.#{inspect(assoc_field)}, but the existing join under that binding " <>
"has an unexpected association descriptor: #{inspect(other)}. " <>
"Fix: rename the existing binding, or join the correct association under that binding."
end
joins_tuple
end
defp fetch_existing_join_expr!(
%Ecto.Query{} = ecto_query,
assoc_binding,
join_index,
joins_tuple,
assoc_data
)
when is_integer(join_index) and join_index > 0 do
join_pos = join_index - 1
joins_tuple =
cond do
is_tuple(joins_tuple) and tuple_size(joins_tuple) > join_pos ->
joins_tuple
true ->
List.to_tuple(ecto_query.joins)
end
existing_join =
if join_pos < tuple_size(joins_tuple) do
elem(joins_tuple, join_pos)
else
nil
end
case existing_join do
%Ecto.Query.JoinExpr{} = join ->
{join, joins_tuple}
other ->
%{
source_schema: source_schema,
assoc_field: assoc_field
} = assoc_data
raise ArgumentError,
"QueryBuilder attempted to reuse an existing named binding #{inspect(assoc_binding)} for " <>
"#{inspect(source_schema)}.#{inspect(assoc_field)}, but could not find a corresponding join " <>
"expression in the query. This is likely a query construction bug; please report it. " <>
"aliases[#{inspect(assoc_binding)}]=#{inspect(join_index)} join_at_index=#{inspect(other)}"
end
end
defp binding_index!(ecto_query, binding) do
cond do
ecto_query.from.as == binding ->
0
true ->
case Map.fetch(ecto_query.aliases, binding) do
{:ok, index} when is_integer(index) and index > 0 ->
index
{:ok, 0} ->
# `binding` was not the root binding (handled above), so this implies an inconsistent aliases map.
raise ArgumentError,
"QueryBuilder expected named binding #{inspect(binding)} to refer to a join, " <>
"but the query aliases map reports index 0. This is likely a query construction bug; please report it."
:error ->
raise ArgumentError,
"QueryBuilder expected the query to have named binding #{inspect(binding)}, " <>
"but it was not found. This is likely a query construction bug; please report it."
end
end
end
defp binding_name_for_index(%Ecto.Query{from: %{as: as}}, 0) when not is_nil(as), do: as
defp binding_name_for_index(%Ecto.Query{}, 0), do: :root
defp binding_name_for_index(%Ecto.Query{joins: joins}, index)
when is_integer(index) and index > 0 do
case Enum.at(joins, index - 1) do
%Ecto.Query.JoinExpr{as: as} when not is_nil(as) -> as
_ -> {:binding_index, index}
end
end
end