Composite (composite v0.7.0)

Copy Markdown View Source

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.

Summary

Functions

Applies handlers to query.

Applies handlers to query.

Applies handlers and reports whether the input query changed.

Same as apply_tagged/1, but for composites built with new/0.

Default ignore function.

Forces loading dependency even if it is not required by params.

Initializes a Composite struct with delayed application of query and params.

Initializes a Composite struct with query and params.

Defines a parameter handler.

Types

apply_fun(query)

@type apply_fun(query) :: (query, value :: any() -> query) | (query -> query)

dependencies()

@type dependencies() :: nil | dependency_name() | [dependency_name()]

dependency_name()

@type dependency_name() :: atom()

dependency_option()

@type dependency_option() :: {:requires, dependencies()}

load_dependency(query)

@type load_dependency(query) :: (query -> query) | (query, params() -> query)

option()

@type option() :: {:strict, boolean()} | {:ignore?, (any() -> boolean())}

param_option(query)

@type param_option(query) ::
  {:requires, dependencies() | (value :: any() -> dependencies())}
  | {:ignore?, (any() -> boolean())}
  | {:on_ignore, (query -> query)}
  | {:ignore_requires, dependencies()}

param_path_item()

@type param_path_item() :: any()

params()

@type params() :: Access.t()

t(query)

@type t(query) :: %Composite{
  dep_definitions: %{
    optional(dependency_name()) => [
      {load_dependency(query), [dependency_option()]}
    ]
  },
  ignore?: (any() -> boolean()),
  input_query: query,
  param_definitions: [
    {[param_path_item()], apply_fun(query), [param_option(query)]}
  ],
  params: params() | nil,
  required_deps: [dependency_name()],
  strict: boolean()
}

Functions

apply(composite)

@spec apply(t(query)) :: query when query: any()

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.

apply(input_query, composite, params)

@spec apply(query, t(query), params()) :: query when query: any()

Applies handlers to query.

Used when composite is defined with new/0

apply_tagged(composite)

@spec apply_tagged(t(query)) :: :unchanged | {:changed, query} when query: any()

Applies handlers and reports whether the input query changed.

Returns :unchanged if the resulting query is equal (==) to the input query — i.e. no parameter handler, :on_ignore callback, dependency, or force_require/2 actually changed the query. Otherwise returns {:changed, query}.

Useful for short-circuiting when no filters were provided:

User
|> Composite.new(params)
|> Composite.param(:company, &where(&1, company: ^&2))
|> Composite.param(:location, &where(&1, location: ^&2))
|> Composite.apply_tagged()
|> case do
  {:changed, query} -> Repo.all(query)
  :unchanged -> []
end

Requires params and input_query to be bound via new/2.

iex> []
...> |> Composite.new(%{})
...> |> Composite.param(:item, fn list, value -> [value | list] end)
...> |> Composite.apply_tagged()
:unchanged

iex> []
...> |> Composite.new(%{item: :a})
...> |> Composite.param(:item, fn list, value -> [value | list] end)
...> |> Composite.apply_tagged()
{:changed, [:a]}

apply_tagged(input_query, composite, params)

@spec apply_tagged(query, t(query), params()) :: :unchanged | {:changed, query}
when query: any()

Same as apply_tagged/1, but for composites built with new/0.

See apply/3.

default_ignore?(value)

@spec default_ignore?(any()) :: boolean()

Default ignore function.

Returns true if the value is nil, "", [], or %{}.

iex> Composite.default_ignore?(nil)
true

iex> Composite.default_ignore?("hello")
false

dependency(composite, dependency, func, opts \\ [])

@spec dependency(t(query), dependency_name(), load_dependency(query), [
  dependency_option()
]) :: t(query)
when query: any()

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.

force_require(composite, dependency_or_dependencies)

@spec force_require(t(query), dependency_name() | [dependency_name()]) :: t(query)
when query: any()

Forces loading dependency even if it is not required by params.

new(opts \\ [])

@spec new([option()]) :: t(any())

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()

Options

  • :strict - if true, then apply/3 will raise an error if the caller provides params that are not defined in Composite.param/4. Defaults to false.
  • :ignore? - a function that determines the default behaviour of :ignore? option in param/4. Defaults to &Composite.default_ignore?/1.

new(input_query, params, opts \\ [])

@spec new(query, params(), [option()]) :: t(query) when query: any()

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.

Since Composite works with any Elixir term, here is a non-Ecto example:

iex> []
...> |> Composite.new(%{a: 1, b: 2, c: 3})
...> |> Composite.param(:a, fn list, v -> [{:a, v} | list] end)
...> |> Composite.param(:b, fn list, v -> [{:b, v} | list] end)
...> |> Composite.apply()
[{:b, 2}, {:a, 1}]

Options

  • :strict - if true, then apply/3 will raise an error if the caller provides params that are not defined in Composite.param/4. Defaults to false.
  • :ignore? - a function that determines the default behaviour of :ignore? option in param/4. Defaults to &Composite.default_ignore?/1.

param(composite, path, func, opts \\ [])

@spec param(t(query), param_path_item() | [param_path_item()], apply_fun(query), [
  param_option(query)
]) ::
  t(query)
when query: any()

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))

The same parameter path may be declared more than once — all handlers run, in declaration order, each receiving the same value. This is useful when one parameter should affect several independent parts of the query:

# `:search` both narrows the result set and tweaks ordering.
composite
|> Composite.param(:search, fn query, term ->
  where(query, [u], ilike(u.name, ^"%#{term}%"))
end)
|> Composite.param(:search, fn query, term ->
  order_by(query, [u], fragment("similarity(name, ?) DESC", ^term))
end)

Options

  • :ignore? - if function returns true, then handler apply_fun/1 won't be applied. Default value is set by :ignore? option when creating the composite.
  • :on_ignore - a function that will be applied instead of apply_fun/1 if value is ignored. Defaults to Function.identity/1.
  • :requires - points to the dependencies which has to be loaded before calling 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.