defmodule Composite do
@moduledoc """
A utility for writing dynamic queries.
It allows getting rid of some boilerplate when building a query based on input parameters.
params = %{search_query: "John Doe"}
User
|> where(active: true)
|> Composite.new(params)
|> Composite.param(:org_id, &filter_by_org_id/2)
|> Composite.param(:search_query, &search_by_full_name/2)
|> Composite.param(:org_name, &filter_by_org_name/2, requires: :org)
|> Composite.param(:org_type, &filter_by_org_type/2, requires: :org)
|> Composite.dependency(:org, &join_orgs/1)
|> Repo.all()
Even though most of the examples in this doc use `Ecto`, Composite itself is not limited only to it.
`Ecto` is an optional dependency and it is present only for having an implementation of `Ecto.Queryable` OOTB.
You're able to use Composite with any Elixir term, as it is just an advanced wrapper around `Enum.reduce/3`.
"""
import Kernel, except: [apply: 3]
defstruct param_definitions: [],
dep_definitions: %{},
params: nil,
input_query: nil,
required_deps: []
@type dependency_name :: atom()
@type dependencies ::
nil
| dependency_name()
| [dependency_name()]
@type param_option(query) ::
{:requires, dependencies() | (value :: any -> dependencies())}
| {:ignore?, (any() -> boolean())}
| {:on_ignore, (query -> query)}
| {:ignore_requires, dependencies()}
@type dependency_option :: {:requires, dependencies()}
@type param_path_item :: any()
@type apply_fun(query) :: (query, value :: any() -> query) | (query -> query)
@type load_dependency(query) :: (query -> query) | (query, params() -> query)
@type params :: Access.t()
@type t(query) :: %__MODULE__{
param_definitions: [{[param_path_item()], apply_fun(query), [param_option(query)]}],
dep_definitions: %{
optional(dependency_name()) => [{load_dependency(query), [dependency_option()]}]
},
required_deps: [dependency_name()],
params: params() | nil,
input_query: query
}
@doc """
Initializes a `Composite` struct with delayed application of `query` and `params`.
Must be used with `apply/3`.
composite =
Composite.new()
|> Composite.param(:organization_id, &where(&1, organization_id: ^&2))
|> Composite.param(:age_more_than, &where(&1, [users], users.age > ^&2))
params = %{organization_id: 1}
User
|> where(active: true)
|> Composite.apply(composite, params)
|> Repo.all()
"""
@spec new :: t(any())
def new, do: %__MODULE__{}
@doc """
Initializes a `Composite` struct with `query` and `params`.
Must be used with `apply/1`.
This strategy is useful when working with `Ecto.Query` in pipe-based queries.
params = %{organization_id: 1}
User
|> where(active: true)
|> Composite.new(params)
|> Composite.param(:organization_id, &where(&1, organization_id: ^&2))
|> Composite.param(:age_more_than, &where(&1, [users], users.age > ^&2))
|> Repo.all()
Please note, that there is no explicit `Composite.apply/1` call before `Repo.all/1`, because `Composite`
implements `Ecto.Queryable` protocol.
"""
@spec new(query, params()) :: t(query) when query: any()
def new(input_query, params) do
%__MODULE__{params: params, input_query: input_query}
end
@doc """
Defines a parameter handler.
Handler is applied to a query when `apply/1` or `apply/3` is invoked.
All handlers are invoke in the same order as they are defined.
If the parameter requires dependencies, then they will be loaded before the parameters' handler and only if
parameter wasn't ignored. Examples with dependencies usage can be found in doc for `dependency/4`
params = %{location: "Arctic", order: :age_desc}
User
|> Composite.new(params)
|> Composite.param(:location, &where(&1, location: ^&2),
ignore?: &(&1 in [nil, "WORLDWIDE", ""])
)
|> Composite.param(
:order,
fn
query, :name_asc -> query |> order_by(name: :asc)
query, :age_desc -> query |> order_by(age: :desc)
end,
on_ignore: &order_by(&1, inserted_at: :desc)
)
If input parameters have nested maps (or any other key-based data structure):
params = %{filter: %{name: "John"}}
User
|> Composite.new(params)
|> Composite.param([:filter, :name], &where(&1, name: ^&2))
### Options
* `:ignore?` - if function returns `true`, then handler `t:apply_fun/1` won't be applied.
Default value is `&(&1 in [nil, "", [], %{}])`.
* `:on_ignore` - a function that will be applied instead of `t:apply_fun/1` if value is ignored.
Defaults to `Function.identity/1`.
* `:requires` - points to the dependencies which has to be loaded before calling `t:apply_fun/1`.
It is, also, possible to specify dependencies dynamically based on a value of the parameter by
passing a function. The latter function will always receive not ignored values.
Defaults to `nil` (which is equivalent to `[]`).
* `:ignore_requires` - points to the dependencies which has to be loaded when value is ignored. May be needed
for custom `:on_ignore` implementation.
"""
@spec param(t(query), param_path_item() | [param_path_item()], apply_fun(query), [
param_option(query)
]) :: t(query)
when query: any()
def param(
%__MODULE__{param_definitions: param_definitions} = composite,
path,
func,
opts \\ []
)
when is_function(func, 1) or is_function(func, 2) do
ensure_unknown_opts_absent!(opts, [:ignore?, :on_ignore, :requires, :ignore_requires])
%{composite | param_definitions: [{List.wrap(path), func, opts} | param_definitions]}
end
@doc """
Defines a dependency loader.
Dependency is an instruction which is being applied lazily to a query.
The same dependency can be required by many parameters, but it will be invoked only once.
Dependency can depend on other dependency.
Useful for joining tables.
params = %{org_type: :nonprofit, is_org_closed: false}
User
|> Composite.new(params)
|> Composite.param(:is_org_closed, &where(&1, [orgs: orgs], orgs.closed == ^&2), requires: :orgs)
|> Composite.param(:org_type, &where(&1, [orgs: orgs], orgs.type == ^&2), requires: :orgs)
|> Composite.dependency(:orgs, &join(&1, :inner, [users], orgs in assoc(users, :org), as: :orgs))
It is also possible to require a dependency only if specific value is set. In example below dependency `:phone` will be
loaded only if value of `:search` param starts from `+` sign
composite
|> Composite.param(
:search,
fn
query, "+" <> _ = phone_number -> where(query, [phones: phones], phones.number == ^phone_number)
query, query_string -> where(query, [records], ilike(records.text, ^query_string))
end,
requires: fn
"+" <> _ -> :phone
_ -> nil
end
)
|> Composite.dependency(:phone, &join(&1, :inner, [records], phones in assoc(records, :phone), as: :phones))
When `loader` function has arity 2, then all parameters are passed in the second argument.
### Options
* `:requires` - allows to set dependencies for current dependency.
"""
@spec dependency(t(query), dependency_name(), load_dependency(query), [dependency_option]) ::
t(query)
when query: any()
def dependency(
%__MODULE__{dep_definitions: dep_definitions} = composite,
dependency,
func,
opts \\ []
)
when is_function(func, 1) or is_function(func, 2) do
ensure_unknown_opts_absent!(opts, [:requires])
%{composite | dep_definitions: Map.put(dep_definitions, dependency, {func, opts})}
end
@doc """
Applies handlers to query.
Used when composite is defined with `new/2`.
If used with `Ecto`, then calling this function is not necessary,
as `Composite` implements `Ecto.Queryable` protocol, so applying will be done automatically when it is needed.
"""
@spec apply(t(query)) :: query when query: any()
def apply(%__MODULE__{} = composite) do
apply(nil, composite, nil)
end
@doc """
Applies handlers to query.
Used when composite is defined with `new/0`
"""
@spec apply(query, t(query), params()) :: query when query: any()
def apply(
input_query,
%__MODULE__{} = composite,
params
) do
composite =
composite
|> set_once!(:input_query, input_query)
|> set_once!(:params, params)
{query, loaded_deps} =
load_dependencies(
composite.input_query,
composite.params,
composite.dep_definitions,
MapSet.new(),
composite.required_deps
)
{query, _loaded_deps} =
composite.param_definitions
|> Enum.reverse()
|> Enum.reduce({query, loaded_deps}, fn {path, func, opts}, {query, loaded_deps} ->
value = get_in(composite.params, path)
ignore? = Keyword.get(opts, :ignore?, &empty_value?/1)
if ignore?.(value) do
on_ignore =
case Keyword.fetch(opts, :on_ignore) do
{:ok, on_ignore} when is_function(on_ignore, 1) -> on_ignore
:error -> &Function.identity/1
end
required_deps = opts |> Keyword.get(:ignore_requires) |> List.wrap()
{query, loaded_deps} =
load_dependencies(
query,
composite.params,
composite.dep_definitions,
loaded_deps,
required_deps
)
{on_ignore.(query), loaded_deps}
else
required_deps =
opts
|> Keyword.get(:requires)
|> case do
requires when is_function(requires, 1) -> requires.(value)
requires -> requires
end
|> List.wrap()
{query, loaded_deps} =
load_dependencies(
query,
composite.params,
composite.dep_definitions,
loaded_deps,
required_deps
)
case func do
func when is_function(func, 1) -> {func.(query), loaded_deps}
func when is_function(func, 2) -> {func.(query, value), loaded_deps}
end
end
end)
query
end
@doc """
Forces loading dependency even if it is not required by `params`.
"""
@spec force_require(t(query), dependency_name() | [dependency_name()]) :: t(query)
when query: any()
def force_require(
%__MODULE__{required_deps: required_deps} = composite,
dependency_or_dependencies
) do
dependencies = List.wrap(dependency_or_dependencies)
%__MODULE__{composite | required_deps: dependencies ++ required_deps}
end
defp empty_value?(value) do
value in [nil, "", [], %{}]
end
defp ensure_unknown_opts_absent!([], _allowlist), do: :ok
defp ensure_unknown_opts_absent!(opts, allowlist) do
diff =
MapSet.difference(
MapSet.new(Keyword.keys(opts)),
MapSet.new(allowlist)
)
case MapSet.to_list(diff) do
[] -> :ok
unknown_keys -> raise ArgumentError, "unsupported options: #{inspect(unknown_keys)}"
end
end
defp set_once!(composite, key, value) do
case {value, Map.fetch!(composite, key)} do
{nil, nil} -> raise "#{inspect(key)} is not set"
{nil, _} -> composite
{_, nil} -> Map.replace!(composite, key, value)
{_, _} -> raise "#{inspect(key)} has already been provided"
end
end
defp load_dependencies(query, _params, _deps_definitions, loaded_deps, [] = _required_deps) do
{query, loaded_deps}
end
defp load_dependencies(query, params, deps_definitions, loaded_deps, required_deps) do
deps_to_load = required_deps |> MapSet.new() |> MapSet.difference(loaded_deps)
{query, loaded_deps} =
Enum.reduce(deps_to_load, {query, loaded_deps}, fn dependency_name, {query, loaded_deps} ->
if dependency_name in loaded_deps do
{query, loaded_deps}
else
{loader, opts} =
case Map.fetch(deps_definitions, dependency_name) do
{:ok, result} ->
result
:error ->
raise "unable to load dependency `#{dependency_name}`"
end
required_deps = opts |> Keyword.get(:requires) |> List.wrap()
{query, loaded_deps} =
load_dependencies(query, params, deps_definitions, loaded_deps, required_deps)
query =
case loader do
loader when is_function(loader, 1) -> loader.(query)
loader when is_function(loader, 2) -> loader.(query, params)
end
{query, loaded_deps}
end
end)
{query, MapSet.union(loaded_deps, deps_to_load)}
end
if Code.ensure_loaded?(Ecto.Queryable) do
defimpl Ecto.Queryable do
def to_query(composite) do
Composite.apply(composite)
end
end
end
end