Current section
Files
Jump to
Current section
Files
lib/query/preload.ex
defmodule QueryBuilder.Query.Preload do
@moduledoc false
require Ecto.Query
alias QueryBuilder.AssocList.PreloadSpec
alias QueryBuilder.AssocList
alias QueryBuilder.Query.OrderBy
alias QueryBuilder.Query.Where
def preload(ecto_query, %AssocList{} = assoc_list) do
preloads = build_preload_entries(ecto_query, assoc_list.roots, true, [])
if preloads == [] do
ecto_query
else
Ecto.Query.preload(ecto_query, ^preloads)
end
end
defp build_preload_entries(_ecto_query, nodes_map, _through_join_allowed?, _path_rev)
when is_map(nodes_map) and map_size(nodes_map) == 0,
do: []
defp build_preload_entries(ecto_query, nodes_map, through_join_allowed?, path_rev)
when is_map(nodes_map) do
nodes_map
|> Enum.reduce([], fn {_assoc_field, assoc_data}, acc ->
case build_preload_entry(ecto_query, assoc_data, through_join_allowed?, path_rev) do
nil -> acc
entry -> [entry | acc]
end
end)
|> Enum.reverse()
end
defp build_preload_entry(
_ecto_query,
%{preload_spec: nil, nested_assocs: nested_assocs},
_through_join_allowed?,
_path_rev
)
when is_map(nested_assocs) and map_size(nested_assocs) == 0 do
nil
end
defp build_preload_entry(
ecto_query,
%{preload_spec: nil, nested_assocs: nested_assocs} = assoc_data,
through_join_allowed?,
path_rev
)
when is_map(nested_assocs) do
nested_preloads =
build_preload_entries(
ecto_query,
nested_assocs,
through_join_allowed?,
[assoc_data.assoc_field | path_rev]
)
if nested_preloads == [] do
nil
else
raise ArgumentError,
"internal error: found nested preloads under #{inspect(assoc_data.source_schema)}.#{inspect(assoc_data.assoc_field)} " <>
"without a preload spec; nested: #{inspect(nested_preloads)}"
end
end
defp build_preload_entry(
ecto_query,
%{preload_spec: %PreloadSpec{} = preload_spec} = assoc_data,
through_join_allowed?,
path_rev
) do
path_rev = [assoc_data.assoc_field | path_rev]
child_through_join_allowed? =
case preload_spec.strategy do
:through_join ->
if not through_join_allowed? do
path = Enum.reverse(path_rev)
raise ArgumentError,
"invalid mixed preload strategies: `preload_through_join` must apply to a prefix " <>
"of an association path; got: #{inspect(path)}"
end
true
:separate ->
false
end
nested_preloads =
build_preload_entries(
ecto_query,
assoc_data.nested_assocs,
child_through_join_allowed?,
path_rev
)
case preload_spec do
%PreloadSpec{strategy: :separate, query_opts: nil} ->
case nested_preloads do
[] -> assoc_data.assoc_field
nested -> {assoc_data.assoc_field, nested}
end
%PreloadSpec{strategy: :separate, query_opts: opts} ->
if nested_preloads != [] do
nested = nested_preloaded_assoc_fields(assoc_data.nested_assocs)
raise ArgumentError,
"invalid scoped separate preload for #{inspect(assoc_data.source_schema)}.#{inspect(assoc_data.assoc_field)}: " <>
"cannot combine `preload_separate_scoped/3` with nested preloads under that association " <>
"(nested: #{inspect(nested)}). Use an explicit Ecto query-based preload query instead."
end
{assoc_data.assoc_field, build_scoped_preload_query!(assoc_data, opts)}
%PreloadSpec{strategy: :through_join, query_opts: nil} ->
ensure_effective_joined!(ecto_query, assoc_data)
binding_expr = Ecto.Query.dynamic([{^assoc_data.assoc_binding, x}], x)
case nested_preloads do
[] -> {assoc_data.assoc_field, binding_expr}
nested -> {assoc_data.assoc_field, {binding_expr, nested}}
end
%PreloadSpec{strategy: :through_join, query_opts: opts} ->
raise ArgumentError,
"internal error: invalid preload spec for #{inspect(assoc_data.source_schema)}.#{inspect(assoc_data.assoc_field)}; " <>
"scoped preload opts require separate preload, got: #{inspect(opts)}"
end
end
defp nested_preloaded_assoc_fields(nodes_map) when is_map(nodes_map) do
nodes_map
|> Enum.sort_by(fn {assoc_field, _} -> assoc_field end)
|> Enum.flat_map(fn {_assoc_field, assoc_data} ->
case assoc_data.preload_spec do
%PreloadSpec{} ->
[assoc_data.assoc_field | nested_preloaded_assoc_fields(assoc_data.nested_assocs)]
_ ->
nested_preloaded_assoc_fields(assoc_data.nested_assocs)
end
end)
end
defp ensure_effective_joined!(ecto_query, assoc_data) do
unless effective_joined?(ecto_query, assoc_data) do
raise ArgumentError,
"preload_through_join requested join-preload for #{inspect(assoc_data.source_schema)}.#{inspect(assoc_data.assoc_field)}, " <>
"but that association is not joined. " <>
"Join it first (e.g. `QueryBuilder.left_join(query, #{inspect(assoc_data.assoc_field)})` or filter/order_by through it), " <>
"or use `preload_separate/2`."
end
end
defp effective_joined?(ecto_query, %{assoc_binding: assoc_binding} = assoc_data) do
if Ecto.Query.has_named_binding?(ecto_query, assoc_binding) do
QueryBuilder.JoinMaker.validate_existing_assoc_join!(ecto_query, assoc_data, :any)
true
else
false
end
end
defp effective_joined?(_ecto_query, _assoc_data), do: false
defp build_scoped_preload_query!(
%{assoc_schema: assoc_schema},
opts
) do
query = assoc_schema._query()
assoc_list = AssocList.new(assoc_schema)
query =
case Keyword.get(opts, :where, []) do
[] -> query
filters -> Where.where(query, assoc_list, filters, [])
end
case Keyword.get(opts, :order_by, []) do
[] -> query
order_by -> OrderBy.order_by(query, assoc_list, order_by)
end
end
end