defmodule QueryBuilder.Extension do @moduledoc ~S""" Use this module to create an extension module to `QueryBuilder` for app specific query utilities. Use your query builder extension module wherever you would normally use `QueryBuilder` Example: ``` defmodule MyApp.QueryBuilder do use QueryBuilder.Extension, from_opts_full_ops: [:where_initcap] import Ecto.Query defmacro __using__(opts) do quote do require QueryBuilder QueryBuilder.__using__(unquote(opts)) end end # Add app specific query functions #--------------------------------- def where_initcap(query, field, value) do text_equals_condition = fn field, value, get_binding_fun -> {field, binding} = get_binding_fun.(field) dynamic([{^binding, x}], fragment("initcap(?)", ^value) == field(x, ^field)) end query |> where(&text_equals_condition.(field, value, &1)) end end defmodule MyApp.Accounts.User do use MyApp.QueryBuilder schema "users" do field :name, :string field :active, :boolean end end defmodule MyApp.Accounts do alias MyApp.QueryBuilder, as: QB def list_users(opts \\ []) do # Query list can include custom query functions as well: # [where_initcap: QB.args(:name, "john"), where: [active: true]] MyApp.Accounts.User |> QB.from_opts(opts, mode: :full) |> Repo.all() end end ``` """ defmacro __using__(opts) do opts = validate_extension_opts!(opts) from_opts_full_ops = Keyword.get(opts, :from_opts_full_ops, []) boundary_ops_user_asserted = Keyword.get(opts, :boundary_ops_user_asserted, []) quote do # Expose all QueryBuilder functions: QueryBuilder.__info__(:functions) @doc false def __query_builder_extension_from_opts_config__() do %{ from_opts_full_ops: unquote(from_opts_full_ops), boundary_ops_user_asserted: unquote(boundary_ops_user_asserted) } end defdelegate left_join(query, assoc_fields, filters \\ [], or_filters \\ []), to: QueryBuilder defdelegate inner_join(query, assoc_fields), to: QueryBuilder defdelegate left_join_leaf(query, assoc_fields, filters \\ [], or_filters \\ []), to: QueryBuilder defdelegate left_join_path(query, assoc_fields, filters \\ [], or_filters \\ []), to: QueryBuilder defdelegate left_join_latest(query, assoc_field, opts \\ []), to: QueryBuilder defdelegate left_join_top_n(query, assoc_field, opts \\ []), to: QueryBuilder defdelegate maybe_where(query, bool, filters), to: QueryBuilder defdelegate maybe_where(query, condition, assoc_fields, filters, or_filters \\ []), to: QueryBuilder defdelegate maybe_order_by(query, bool, value), to: QueryBuilder defdelegate maybe_order_by(query, condition, assoc_fields, value), to: QueryBuilder defdelegate args(args), to: QueryBuilder defdelegate args(arg1, arg2), to: QueryBuilder defdelegate args(arg1, arg2, arg3), to: QueryBuilder defdelegate args(arg1, arg2, arg3, arg4), to: QueryBuilder defdelegate new(ecto_query), to: QueryBuilder defdelegate subquery(queryable, opts \\ []), to: QueryBuilder defdelegate paginate(query, repo, opts \\ []), to: QueryBuilder defdelegate distinct(query, value), to: QueryBuilder defdelegate distinct(query, assoc_fields, value), to: QueryBuilder defdelegate distinct_roots(query, enabled \\ true), to: QueryBuilder defdelegate top_n_per(query, opts), to: QueryBuilder defdelegate top_n_per(query, assoc_fields, opts), to: QueryBuilder defdelegate first_per(query, opts), to: QueryBuilder defdelegate first_per(query, assoc_fields, opts), to: QueryBuilder defdelegate group_by(query, expr), to: QueryBuilder defdelegate group_by(query, assoc_fields, expr), to: QueryBuilder defdelegate having(query, filters), to: QueryBuilder defdelegate having(query, assoc_fields, filters, or_filters \\ []), to: QueryBuilder defdelegate having_any(query, or_groups), to: QueryBuilder defdelegate having_any(query, assoc_fields, or_groups), to: QueryBuilder defdelegate order_by(query, value), to: QueryBuilder defdelegate order_by(query, assoc_fields, value), to: QueryBuilder defdelegate preload_separate(query, assoc_fields), to: QueryBuilder defdelegate preload_separate_scoped(query, assoc_field, opts \\ []), to: QueryBuilder defdelegate preload_through_join(query, assoc_fields), to: QueryBuilder defdelegate select(query, selection), to: QueryBuilder defdelegate select(query, assoc_fields, selection), to: QueryBuilder defdelegate select_merge(query, selection), to: QueryBuilder defdelegate select_merge(query, assoc_fields, selection), to: QueryBuilder defdelegate count(), to: QueryBuilder defdelegate count(token), to: QueryBuilder defdelegate count(token, modifier), to: QueryBuilder defdelegate count_distinct(token), to: QueryBuilder defdelegate avg(token), to: QueryBuilder defdelegate sum(token), to: QueryBuilder defdelegate min(token), to: QueryBuilder defdelegate max(token), to: QueryBuilder defdelegate array_agg(token, opts \\ []), to: QueryBuilder defdelegate where(query, filters), to: QueryBuilder defdelegate where(query, assoc_fields, filters, or_filters \\ []), to: QueryBuilder defdelegate where_any(query, or_groups), to: QueryBuilder defdelegate where_any(query, assoc_fields, or_groups), to: QueryBuilder defdelegate where_has(query, assoc_fields, filters \\ []), to: QueryBuilder defdelegate where_missing(query, assoc_fields, filters \\ []), to: QueryBuilder defdelegate where_exists(query, assoc_fields, filters, or_filters \\ []), to: QueryBuilder defdelegate where_not_exists(query, assoc_fields, filters, or_filters \\ []), to: QueryBuilder defdelegate where_exists_subquery(query, assoc_fields, opts \\ []), to: QueryBuilder defdelegate where_not_exists_subquery(query, assoc_fields, opts \\ []), to: QueryBuilder defdelegate offset(query, value), to: QueryBuilder defdelegate limit(query, value), to: QueryBuilder @doc ~S""" Applies a keyword list of operations to a query. Like `QueryBuilder.from_opts/*`, this defaults to boundary mode. To use the full `from_opts` surface, pass `mode: :full`. Custom extension operations are rejected by default in full mode; allowlist them via `use QueryBuilder.Extension, from_opts_full_ops: [...]`. Note: operations allowlisted via `boundary_ops_user_asserted: [...]` are also accepted in full mode (full is a superset). """ def from_opts(query, opts) do from_opts(query, opts, mode: :boundary) end def from_opts(query, opts, from_opts_opts) do QueryBuilder.__from_opts__(query, opts, __MODULE__, from_opts_opts) end # Migration shim: v1 exposed from_list/2. Keep it to raise with a clear upgrade hint. def from_list(_query, _opts) do raise ArgumentError, "from_list/2 was renamed to from_opts/2; please update your call sites" end end end defp validate_extension_opts!(opts) when is_list(opts) do unless Keyword.keyword?(opts) do raise ArgumentError, "use QueryBuilder.Extension expects options to be a keyword list, got: #{inspect(opts)}" end allowed_keys = [:from_opts_full_ops, :boundary_ops_user_asserted] case Keyword.keys(opts) -- allowed_keys do [] -> :ok unknown -> raise ArgumentError, "use QueryBuilder.Extension got unknown options #{inspect(unknown)}; " <> "supported options: #{inspect(allowed_keys)}" end validate_extension_ops_list!(opts, :from_opts_full_ops) validate_extension_ops_list!(opts, :boundary_ops_user_asserted) opts end defp validate_extension_opts!(opts) do raise ArgumentError, "use QueryBuilder.Extension expects options to be a keyword list, got: #{inspect(opts)}" end defp validate_extension_ops_list!(opts, key) do case Keyword.get(opts, key, []) do list when is_list(list) -> Enum.each(list, fn op when is_atom(op) -> :ok other -> raise ArgumentError, "use QueryBuilder.Extension expects #{inspect(key)} to be a list of atoms, got: #{inspect(other)} in #{inspect(list)}" end) other -> raise ArgumentError, "use QueryBuilder.Extension expects #{inspect(key)} to be a list of atoms, got: #{inspect(other)}" end end end