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