Current section
Files
Jump to
Current section
Files
lib/query_engine/query_builder.ex
defmodule OuterfacesEctoApi.QueryEngine.QueryBuilder do
@moduledoc """
Provides dynamic filtering and query building for API endpoints.
"""
require OuterfacesEctoApi.QueryEngine.QueryJoiner
alias OuterfacesEctoApi.QueryEngine.QueryJoiner
import Ecto.Query
require Logger
@type field_filter_definition ::
{
# Module
module(),
# Function
atom(),
# Field name
atom(),
# Operation {truthy arg, falsy arg} | operation
{atom(), atom()} | atom(),
# run filter with nil values
boolean()
}
| {
# Module
module(),
# Function
atom(),
# Field name
atom(),
# Operation {truthy arg, falsy arg} | operation
{atom(), atom()} | atom(),
# run filter with nil values
boolean(),
# default value or default filter definition
any()
}
@type association_field_filter_definition ::
{
# Module
module(),
# Function
atom(),
# Binding list or []
[atom()],
# Field name
atom(),
# Operation(s)
{atom(), atom()} | atom(),
# run filter with nil values
boolean()
}
| {
# Module
module(),
# Function
atom(),
# Binding list or []
[atom()],
# Field name
atom(),
# Operation(s)
{atom(), atom()} | atom(),
# run filter with nil values
boolean(),
# default value or default filter definition
any()
}
@type filter_spec :: {
# Filter key
atom(),
# Filter definition
field_filter_definition() | association_field_filter_definition()
}
@type field_sort_by_definition ::
{
# Module
module(),
# Function
atom(),
# Binding list or []
[atom()],
# Field to sort on
atom(),
# direction :desc or :asc
atom(),
# default sort
boolean()
}
| {
# Module
module(),
# Function
atom(),
# Field to sort on
atom(),
# direction :desc or :asc
atom(),
# default sort
boolean()
}
@type sort_by_spec :: {
# Sort key
atom(),
# Sort definition
field_sort_by_definition()
}
@spec build_index_query(
schema :: module(),
queryable :: Ecto.Queryable.t(),
params :: map(),
filter_specs :: [filter_spec()],
sort_specs :: [sort_by_spec()]
) :: {:ok, Ecto.Query.t()} | {:error, atom()}
def build_index_query(schema, queryable, params, filter_specs, sort_specs \\ []) do
try do
queryable = ensure_queryable(queryable, schema)
query = Map.get(params, "query", "{}") |> Jason.decode!()
filter_params = Map.get(query, "filters", %{})
sort_params = Map.get(query, "sort", [])
effective_sort = compute_effective_sort(sort_params, sort_specs)
join_info =
extract_needed_joins(filter_specs, schema, filter_params) ++
extract_needed_joins(effective_sort, schema, %{})
queryable =
case join_info do
[] ->
queryable
[_ | _] = list_of_lists ->
if is_list(hd(list_of_lists)) do
Enum.reduce(list_of_lists, queryable, fn chain, acc_query ->
QueryJoiner.ensure_joins(acc_query, chain)
end)
else
QueryJoiner.ensure_joins(queryable, list_of_lists)
end
end
queryable
|> apply_filters(filter_params, filter_specs)
|> apply_default_filters(filter_params, filter_specs)
|> apply_sorting(effective_sort)
|> then(&{:ok, &1})
catch
_ ->
{:error, :query_builder_failure}
end
end
defp compute_effective_sort(sort_params, sorting_defs) do
allowed_sort_keys = Enum.map(sorting_defs, fn {key, _} -> key end)
explicit_sorts =
sort_params
|> Enum.map(&parse_sort_param/1)
|> Enum.filter(fn {key, _dir} -> key in allowed_sort_keys end)
|> Enum.map(fn {key, dir} ->
{key, lookup_sort_def(key, dir, sorting_defs)}
end)
if explicit_sorts == [] do
Enum.filter(sorting_defs, fn
{_, {_mod, _func, _bindings, _field, _dir, default}} -> default
{_, {_mod, :by_field, _field, _dir, default}} -> default
_ -> false
end)
else
explicit_sorts
end
end
defp lookup_sort_def(key, dir, sorting_defs) do
sorting_defs
|> Enum.find(fn {k, _} -> k == key end)
|> case do
{_, {mod, func, binding_list, field, _original_dir, default}} ->
{mod, func, binding_list, field, dir, default}
{_, {mod, :by_field, field, _original_dir, default}} ->
{mod, :by_field, field, dir, default}
{_, {mod, func, binding_list, field, _original_dir}} ->
{mod, func, binding_list, field, dir}
{_, {mod, :by_field, field, _original_dir}} ->
{mod, :by_field, field, dir}
_ ->
raise ArgumentError, "Unexpected sorting definition for key: #{key}"
end
end
defp parse_sort_param(param) do
case String.split(param, ":") do
[key, "desc"] ->
{format_parameterized_sort(key), :desc}
[key, "asc"] ->
{format_parameterized_sort(key), :asc}
[key] ->
{format_parameterized_sort(key), :asc}
end
end
defp format_parameterized_sort(key) when is_binary(key), do: String.to_existing_atom(key)
defp apply_sorting(query, effective_sorts) do
Enum.reduce(effective_sorts, query, fn
{_sort_key, {mod, func, binding_list, field, direction, _default}}, acc_query ->
apply(mod, func, [acc_query, binding_list, field, direction])
{_sort_key, {mod, :by_field, field, direction, _default}}, acc_query ->
apply(mod, :by_field, [acc_query, field, direction])
{_sort_key, {mod, func, binding_list, field, direction}}, acc_query ->
apply(mod, func, [acc_query, binding_list, field, direction])
{_sort_key, {mod, :by_field, field, direction}}, acc_query ->
apply(mod, :by_field, [acc_query, field, direction])
end)
end
@spec ensure_queryable(Ecto.Queryable.t(), module()) :: Ecto.Query.t()
def ensure_queryable(queryable, schema) do
case queryable do
mod when is_atom(mod) -> from(q in mod)
%Ecto.Query{} = q -> q
struct when is_map(struct) -> from(q in schema)
end
end
@spec apply_filters(
queryable :: Ecto.Queryable.t(),
filter_params :: map(),
filter_specs :: [filter_spec()]
) :: Ecto.Query.t()
defp apply_filters(queryable, filter_params, filter_specs) do
Enum.reduce(filter_params, queryable, fn
{filter_key, filter_value}, acc ->
apply_filter(acc, filter_key, filter_value, filter_specs)
end)
end
@spec apply_filter(
queryable :: Ecto.Queryable.t(),
filter_key :: atom(),
filter_value :: any(),
filter_specs :: [filter_spec()]
) :: Ecto.Query.t()
defp apply_filter(queryable, filter_key, filter_value, filter_specs) do
case Enum.find(filter_specs, fn {key, _} -> Atom.to_string(key) == filter_key end) do
nil ->
queryable
{_, {mod, func, binding_list, field, operator, allow_nil, _default}}
when is_list(binding_list) ->
validate_filter_field!(detect_target_schema(queryable, binding_list), field)
resolved_operator = resolve_operator(operator, filter_value)
expected_alias =
binding_list
|> Enum.map(&Atom.to_string/1)
|> Enum.join("_")
|> String.to_atom()
case QueryJoiner.NamedBinding.find(queryable, expected_alias) do
{binding_index, _join} ->
cond do
is_nil(filter_value) and allow_nil ->
apply(mod, func, [queryable, nil, binding_index, field, resolved_operator])
is_nil(filter_value) ->
queryable
true ->
apply(mod, func, [
queryable,
filter_value,
binding_index,
field,
resolved_operator
])
end
nil ->
raise ArgumentError,
"No valid binding found for expected alias: #{inspect(expected_alias)}"
end
{_, {mod, func, binding_list, field, operator, allow_nil}} when is_list(binding_list) ->
validate_filter_field!(detect_target_schema(queryable, binding_list), field)
resolved_operator = resolve_operator(operator, filter_value)
expected_alias =
binding_list
|> Enum.map(&Atom.to_string/1)
|> Enum.join("_")
|> String.to_atom()
case QueryJoiner.NamedBinding.find(queryable, expected_alias) do
{binding_index, _join} ->
cond do
is_nil(filter_value) and allow_nil ->
apply(mod, func, [queryable, nil, binding_index, field, resolved_operator])
is_nil(filter_value) ->
queryable
true ->
apply(mod, func, [
queryable,
filter_value,
binding_index,
field,
resolved_operator
])
end
nil ->
raise ArgumentError,
"No valid binding found for expected alias: #{inspect(expected_alias)}"
end
{_, {mod, func, field, operator, allow_nil, _default}} when is_atom(field) ->
validate_filter_field!(queryable, field)
resolved_operator = resolve_operator(operator, filter_value)
cond do
is_nil(filter_value) and allow_nil ->
apply(mod, func, [queryable, nil, field, resolved_operator])
is_nil(filter_value) ->
queryable
true ->
apply(mod, func, [queryable, filter_value, field, resolved_operator])
end
{_, {mod, func, field, operator, allow_nil}} when is_atom(field) ->
validate_filter_field!(queryable, field)
resolved_operator = resolve_operator(operator, filter_value)
cond do
is_nil(filter_value) and allow_nil ->
apply(mod, func, [queryable, nil, field, resolved_operator])
is_nil(filter_value) ->
queryable
true ->
apply(mod, func, [queryable, filter_value, field, resolved_operator])
end
end
end
@spec resolve_operator(atom() | {atom(), atom()}, any()) :: atom()
defp resolve_operator(operator, filter_value) do
case {operator, filter_value} do
{{true_case, _false_case}, true} -> true_case
{{_true_case, false_case}, false} -> false_case
_ -> operator
end
end
defp detect_target_schema(queryable, []) do
case queryable do
%Ecto.Query{from: %{source: {_, schema}}} -> schema
schema when is_atom(schema) -> schema
end
end
defp detect_target_schema(queryable, [assoc | _]) do
base_schema = detect_target_schema(queryable, [])
case base_schema.__schema__(:association, assoc) do
%Ecto.Association.Has{related: schema} ->
schema
%Ecto.Association.BelongsTo{related: schema} ->
schema
%Ecto.Association.HasThrough{through: through_list} ->
List.last(through_list) |> detect_target_schema(queryable)
nil ->
base_schema
end
end
defp validate_filter_field!(queryable, field) do
schema =
case queryable do
%Ecto.Query{from: %{source: {_, schema}}} -> schema
schema when is_atom(schema) -> schema
_ -> raise ArgumentError, "Unexpected queryable type: #{inspect(queryable)}"
end
unless field in apply(schema, :__schema__, [:fields]) do
raise ArgumentError, "Field `#{field}` does not exist on schema `#{inspect(schema)}`"
end
end
defp apply_default_filters(queryable, filter_params, filter_specs) do
Enum.reduce(filter_specs, queryable, fn
{filter_key, {mod, func, binding_list, field, operator, _allow_nil, default}}, acc ->
if Map.has_key?(filter_params, Atom.to_string(filter_key)) do
acc
else
case default do
nil ->
acc
{default_mod, default_func, default_field, default_operator, default_value} ->
apply(default_mod, default_func, [
acc,
default_value,
default_field,
default_operator
])
literal ->
effective_operator =
case operator do
{true_op, false_op} ->
if literal, do: true_op, else: false_op
op when is_atom(op) ->
op
end
apply(mod, func, [acc, literal, binding_list, field, effective_operator])
end
end
{filter_key, {mod, func, field, operator, allow_nil, default}}, acc ->
if Map.has_key?(filter_params, Atom.to_string(filter_key)) do
acc
else
case default do
nil ->
if allow_nil do
effective_operator =
case operator do
{true_op, _false_op} ->
true_op
op when is_atom(op) ->
op
end
apply(mod, func, [acc, nil, field, effective_operator])
else
acc
end
{default_mod, default_func, default_field, default_operator, default_value} ->
apply(default_mod, default_func, [
acc,
default_value,
default_field,
default_operator
])
literal ->
effective_operator =
case operator do
{true_op, false_op} ->
if literal, do: true_op, else: false_op
op when is_atom(op) ->
op
end
apply(mod, func, [acc, literal, field, effective_operator])
end
end
end)
end
defp extract_needed_joins(filter_specs, schema, filter_params) do
filter_specs
|> Enum.reduce([], fn
{filter_key, {_mod, _func, binding_list, _field, _operator, allow_nil, _default}}, acc
when is_list(binding_list) and is_binary(filter_key) ->
filter_value = Map.get(filter_params, filter_key)
cond do
is_nil(filter_value) and not allow_nil ->
acc
not Map.has_key?(filter_params, filter_key) ->
acc
true ->
case binding_list do
list when is_list(list) -> [list | acc]
atom when is_atom(atom) -> [[atom] | acc]
_ -> acc
end
end
{filter_key, {_mod, _func, binding_list, _field, _operator, allow_nil}}, acc
when is_list(binding_list) and is_binary(filter_key) ->
filter_value = Map.get(filter_params, filter_key)
cond do
is_nil(filter_value) and not allow_nil ->
acc
not Map.has_key?(filter_params, filter_key) ->
acc
true ->
case binding_list do
list when is_list(list) -> [list | acc]
atom when is_atom(atom) -> [[atom] | acc]
_ -> acc
end
end
{filter_key, {_mod, :by_field, _field, _operator, _allow_nil, _default}}, acc
when is_binary(filter_key) ->
acc
{filter_key, {_mod, :by_field, _field, _operator, _allow_nil}}, acc
when is_binary(filter_key) ->
acc
# Sort specs - always extract joins regardless of params
{_sort_key, {_mod, _func, binding_list, _field, _direction, _default}}, acc ->
case binding_list do
list when is_list(list) -> [list | acc]
atom when is_atom(atom) -> [[atom] | acc]
_ -> acc
end
{_sort_key, {_mod, :by_field, _field, _direction, _default}}, acc ->
acc
{_sort_key, {_mod, _func, binding_list, _field, _direction}}, acc ->
case binding_list do
list when is_list(list) -> [list | acc]
atom when is_atom(atom) -> [[atom] | acc]
_ -> acc
end
{_sort_key, {_mod, :by_field, _field, _direction}}, acc ->
acc
unknown, acc ->
Logger.warning("Unexpected filter / sort spec format: #{inspect(unknown)}")
acc
end)
|> Enum.uniq()
|> Enum.flat_map(fn binding_list ->
[expand_association_chain(schema, binding_list, [])]
end)
end
defp expand_association_chain(current_schema, [assoc_name | rest], acc) do
steps = get_association_info(current_schema, assoc_name)
case steps do
[] ->
acc
[{_, next_schema, _, _} = step] ->
expand_association_chain(next_schema, rest, acc ++ [step])
end
end
defp expand_association_chain(_schema, [], acc), do: acc
defp get_association_info(schema, assoc_name) do
case schema.__schema__(:association, assoc_name) do
%Ecto.Association.Has{related: rs, owner_key: ok, related_key: rk} ->
[{assoc_name, rs, ok, rk}]
%Ecto.Association.BelongsTo{related: rs, owner_key: ok, related_key: rk} ->
[{assoc_name, rs, ok, rk}]
%Ecto.Association.HasThrough{through: through_list} ->
expand_through_association(schema, through_list, [])
nil ->
[]
end
end
defp expand_through_association(_schema, [], acc), do: acc
defp expand_through_association(schema, [assoc_name | rest], acc) do
steps = get_association_info(schema, assoc_name)
case steps do
[] ->
expand_through_association(schema, rest, acc)
_ ->
new_schema =
case List.last(steps) do
{_, related_schema, _, _} -> related_schema
nil -> schema
end
expand_through_association(new_schema, rest, acc ++ steps)
end
end
end