Current section

Files

Jump to
query_builder lib assoc_list builder.ex
Raw

lib/assoc_list/builder.ex

defmodule QueryBuilder.AssocList.Builder do
@moduledoc false
alias QueryBuilder.AssocList
alias QueryBuilder.AssocList.JoinSpec
alias QueryBuilder.AssocList.Node
alias QueryBuilder.AssocList.PreloadSpec
defmodule State do
@moduledoc false
# AssocList is built recursively and needs to carry a handful of values between
# recursive calls. This struct keeps the recursive function signatures readable.
defstruct source_binding: nil,
source_schema: nil,
lock_left?: false
end
def build(source_schema, assoc_list, assoc_fields, opts \\ [])
def build(source_schema, %AssocList{} = assoc_list, assoc_fields, opts)
when is_atom(source_schema) do
if assoc_list.root_schema != source_schema do
raise ArgumentError,
"AssocList.build/4 expected root schema #{inspect(assoc_list.root_schema)}, " <>
"got: #{inspect(source_schema)}"
end
state = %State{
# the name of the binding of the query's root schema is the schema itself
source_binding: source_schema,
source_schema: source_schema
}
{roots, assoc_list} =
do_build_nodes(assoc_list, assoc_list.roots, List.wrap(assoc_fields), state, opts, [])
%{assoc_list | roots: roots}
end
def build(source_schema, other_assoc_list, _assoc_fields, _opts) do
raise ArgumentError,
"AssocList.build/4 expects the assoc_list argument to be a %QueryBuilder.AssocList{}, " <>
"got: #{inspect(other_assoc_list)} for root schema #{inspect(source_schema)}"
end
defp do_build_nodes(%AssocList{} = assoc_list, nodes_map, [], _state, _opts, _prefix_rev)
when is_map(nodes_map) do
{nodes_map, assoc_list}
end
defp do_build_nodes(
%AssocList{} = assoc_list,
nodes_map,
[{assoc_field, nested_assoc_fields} | tail],
state,
opts,
prefix_rev
) do
%{
source_binding: source_binding,
source_schema: source_schema
} = state
{assoc_field, join_type_marker} = normalize_assoc_field_and_join_type!(assoc_field)
{join_required?, join_qualifier, join_filter_group} =
case Keyword.get(opts, :join, :any) do
:none ->
{false, :any, []}
:left ->
left_join_mode = Keyword.get(opts, :left_join_mode, :leaf)
if nested_assoc_fields == [] do
join_filter_group = Keyword.get(opts, :join_filters, [])
{true, :left, List.wrap(join_filter_group)}
else
case left_join_mode do
:path ->
{true, :left, []}
:leaf ->
{true, :inner, []}
other ->
raise ArgumentError,
"invalid left_join_mode #{inspect(other)}; expected :leaf or :path"
end
end
join_qualifier when join_qualifier in [:inner, :left, :any] ->
{true, join_qualifier, []}
other ->
raise ArgumentError,
"invalid join option #{inspect(other)}; expected :none, :left, :inner, or :any"
end
join_type_marker =
if join_required? do
if state.lock_left? do
if join_type_marker == :inner do
raise ArgumentError,
"invalid assoc_fields: cannot require an inner join (`!`) under an optional association path (`?`) " <>
"for #{inspect(source_schema)}.#{inspect(assoc_field)}"
end
:left
else
join_type_marker
end
else
# Join qualifiers are irrelevant when the operation does not request joins (e.g. preload),
# but we still accept `:assoc?` / `:assoc!` to strip markers and keep assoc_fields uniform.
:any
end
join_qualifier =
JoinSpec.merge_qualifiers!(join_qualifier, join_type_marker, source_schema, assoc_field)
join_filters =
case join_filter_group do
[] ->
[]
[filters, or_filters] when is_list(filters) and is_list(or_filters) ->
[{filters, or_filters}]
other ->
raise ArgumentError,
"invalid join filters for #{inspect(source_schema)}.#{inspect(assoc_field)}: " <>
"expected `[filters, or_filters]` (as set by left_join*/*), got: #{inspect(other)}"
end
join_spec =
JoinSpec.new(
join_required?,
join_qualifier,
join_filters
)
preload_spec =
case Keyword.get(opts, :preload_spec, nil) do
nil -> nil
%PreloadSpec{} = preload_spec -> preload_spec
other -> raise ArgumentError, "invalid preload spec: #{inspect(other)}"
end
{nodes_map, assoc_list, assoc_data} =
upsert_current_assoc_data(
assoc_list,
nodes_map,
prefix_rev,
assoc_data(source_binding, source_schema, assoc_field, join_spec, preload_spec)
)
{nested_assocs, assoc_list} =
do_build_nodes(
assoc_list,
assoc_data.nested_assocs,
List.wrap(nested_assoc_fields),
%{
state
| source_binding: assoc_data.assoc_binding,
source_schema: assoc_data.assoc_schema,
lock_left?: state.lock_left? || join_qualifier == :left
},
opts,
[assoc_field | prefix_rev]
)
assoc_data = %{assoc_data | nested_assocs: nested_assocs}
nodes_map = Map.put(nodes_map, assoc_field, assoc_data)
do_build_nodes(assoc_list, nodes_map, tail, state, opts, prefix_rev)
end
defp do_build_nodes(
%AssocList{} = assoc_list,
nodes_map,
[assoc_field | tail],
state,
opts,
prefix_rev
) do
do_build_nodes(assoc_list, nodes_map, [{assoc_field, []} | tail], state, opts, prefix_rev)
end
defp upsert_current_assoc_data(
%AssocList{} = assoc_list,
nodes_map,
prefix_rev,
%Node{} = new_node
)
when is_map(nodes_map) and is_list(prefix_rev) do
{nodes_map, {node, inserted?}} =
case Map.get(nodes_map, new_node.assoc_field) do
nil ->
{Map.put(nodes_map, new_node.assoc_field, new_node), {new_node, true}}
%Node{} = existing ->
if existing.assoc_binding != new_node.assoc_binding or
existing.source_binding != new_node.source_binding or
existing.source_schema != new_node.source_schema or
existing.assoc_schema != new_node.assoc_schema do
raise ArgumentError,
"association tree conflict for #{inspect(existing.source_schema)}.#{inspect(existing.assoc_field)}; " <>
"this is likely a QueryBuilder bug. Please report it."
end
join_spec =
JoinSpec.merge!(
existing.join_spec,
new_node.join_spec,
existing.source_schema,
existing.assoc_field
)
preload_spec =
PreloadSpec.merge!(
existing.preload_spec,
new_node.preload_spec,
existing.source_schema,
existing.assoc_field
)
merged = %{existing | join_spec: join_spec, preload_spec: preload_spec}
{Map.put(nodes_map, merged.assoc_field, merged), {merged, false}}
end
assoc_list =
if inserted? do
path = Enum.reverse([new_node.assoc_field | prefix_rev])
assoc_list
|> then(&%{&1 | revision: &1.revision + 1})
|> register_path!(path, node.assoc_binding)
|> register_name!(node.assoc_field, node.assoc_binding, path)
|> register_binding!(node, path)
else
assoc_list
end
{nodes_map, assoc_list, node}
end
defp register_path!(%AssocList{} = assoc_list, path, binding) do
case Map.get(assoc_list.by_path, path) do
nil ->
%{assoc_list | by_path: Map.put(assoc_list.by_path, path, binding)}
^binding ->
assoc_list
other ->
raise ArgumentError,
"association path collision for #{inspect(path)}; it resolves to both " <>
"#{inspect(binding)} and #{inspect(other)}. This is likely a QueryBuilder bug; please report it."
end
end
defp register_name!(%AssocList{} = assoc_list, assoc_field, binding, path) do
match = %{binding: binding, path: path}
matches = Map.get(assoc_list.by_name, assoc_field, [])
%{assoc_list | by_name: Map.put(assoc_list.by_name, assoc_field, [match | matches])}
end
defp register_binding!(%AssocList{} = assoc_list, %Node{} = node, path) do
descriptor = %{
source_binding: node.source_binding,
source_schema: node.source_schema,
assoc_field: node.assoc_field,
path: path
}
case Map.get(assoc_list.by_binding, node.assoc_binding) do
nil ->
%{assoc_list | by_binding: Map.put(assoc_list.by_binding, node.assoc_binding, descriptor)}
%{source_binding: same_source, source_schema: same_schema, assoc_field: same_field} =
existing
when same_source == node.source_binding and same_schema == node.source_schema and
same_field == node.assoc_field ->
if existing.path != path do
raise ArgumentError,
"association binding collision for #{inspect(node.assoc_binding)}; " <>
"it is used for multiple association paths (#{inspect(existing.path)} and #{inspect(path)}). " <>
"QueryBuilder's binding naming scheme cannot represent the same association join multiple times " <>
"under different parent joins. Use explicit Ecto joins with distinct `as:` bindings for this case."
end
assoc_list
_other ->
raise ArgumentError,
"association binding collision for #{inspect(node.assoc_binding)}; " <>
"QueryBuilder attempted to use it for #{inspect(node.source_schema)}.#{inspect(node.assoc_field)} " <>
"at path #{inspect(path)}, but it is already used elsewhere in the association tree. " <>
"QueryBuilder cannot safely represent multiple distinct joins under the same binding. " <>
"Use explicit Ecto joins with distinct `as:` bindings for this case."
end
end
defp normalize_assoc_field_and_join_type!(assoc_field) when is_atom(assoc_field) do
assoc_field_string = Atom.to_string(assoc_field)
cond do
String.ends_with?(assoc_field_string, "?") ->
base = String.trim_trailing(assoc_field_string, "?")
if base == "" do
raise ArgumentError,
"invalid assoc field #{inspect(assoc_field)} (cannot be only a marker)"
end
assoc_atom =
try do
String.to_existing_atom(base)
rescue
ArgumentError ->
raise ArgumentError,
"invalid assoc field #{inspect(assoc_field)}; expected an existing association atom (like :role?), got unknown association #{inspect(base)}"
end
{assoc_atom, :left}
String.ends_with?(assoc_field_string, "!") ->
base = String.trim_trailing(assoc_field_string, "!")
if base == "" do
raise ArgumentError,
"invalid assoc field #{inspect(assoc_field)} (cannot be only a marker)"
end
assoc_atom =
try do
String.to_existing_atom(base)
rescue
ArgumentError ->
raise ArgumentError,
"invalid assoc field #{inspect(assoc_field)}; expected an existing association atom (like :role!), got unknown association #{inspect(base)}"
end
{assoc_atom, :inner}
true ->
{assoc_field, :any}
end
end
defp normalize_assoc_field_and_join_type!(assoc_field) do
raise ArgumentError,
"invalid assoc field #{inspect(assoc_field)}; expected an association atom (optionally suffixed with ? or !)"
end
defp assoc_data(
source_binding,
source_schema,
assoc_field,
join_spec,
preload_spec
) do
assoc_schema = source_schema._assoc_schema(assoc_field)
cardinality = source_schema._assoc_cardinality(assoc_field)
assoc_binding = source_schema._binding(assoc_field)
%Node{
assoc_binding: assoc_binding,
assoc_field: assoc_field,
assoc_schema: assoc_schema,
cardinality: cardinality,
join_spec: join_spec,
preload_spec: preload_spec,
nested_assocs: %{},
source_binding: source_binding,
source_schema: source_schema
}
end
end