defmodule Decant do @moduledoc """ Tokenized, multi-field `ILIKE`/`LIKE` search compiled to a composable `Ecto.Query.dynamic/2`. `Decant` turns a free-text search string into a boolean expression over a set of columns, then hands it back as a `dynamic` you splice into your own query: filter = Decant.dynamic(term, fields: [ {:customer, :email}, {:customer, :first_name}, {:customer, :last_name}, {:order, :display_id, cast: :string} ] ) from q in query, where: ^filter ## The shape A search string is split into tokens (words). The default logic is: every token must match SOMEWHERE (token_logic: :and) token matches if ANY field hits (field_logic: :or) so `"jane gmail"` matches a row whose email contains `jane` and whose name (or email, or any listed field) contains `gmail`. Flip `:token_logic` / `:field_logic` to get "or search" or "match-all-fields" behaviour. ## Bindings `Decant` is binding-agnostic: it references columns through **named bindings** (`as:`), so the same field spec works no matter the join shape of the host query. Every searchable source must declare an `as:`: from o in Order, as: :order, join: c in assoc(o, :customer), as: :customer ## Options * `:fields` (required) — list of field specs. Each is `{binding, column}` or `{binding, column, field_opts}`. `field_opts`: * `:cast` — `:string` wraps the column in `CAST(? AS TEXT)` so non-text columns (integer ids, enums) are searchable. * `:match` — per-field override of the global `:match` mode. * `:match` — `:contains` (default), `:prefix`, `:suffix`, or `:exact`. * `:token_logic` — `:and` (default) or `:or`. How tokens combine. * `:field_logic` — `:or` (default) or `:and`. How fields combine per token. * `:case` — `:insensitive` (default, `ILIKE`) or `:sensitive` (`LIKE`). * `:escape` — escape `%`, `_`, `\\` in user input so they are matched literally instead of acting as wildcards. Defaults to `true`. * `:on_blank` — what a blank/no-token term resolves to: `:all` (default, `dynamic(true)` — don't constrain an empty search) or `:none` (`dynamic(false)` — match nothing). * `:tokenizer` — keyword opts forwarded to `Decant.Tokenizer.tokenize/2` (`:pattern`, `:trim`, `:drop_empty`, `:downcase`, `:max_tokens`). ## Empty input A `nil`, blank, or all-whitespace term tokenizes to `[]`. By default `dynamic/2` then returns `dynamic(true)` — an always-true filter — so callers can unconditionally write `where: ^Decant.dynamic(term, ...)` with no branching, and the planner discards the `WHERE true`. Pass `on_blank: :none` when an empty search should instead return no rows (`dynamic(false)`). """ require Ecto.Query alias Decant.{Field, Tokenizer} @doc """ Build an `Ecto.Query.dynamic/2` from a search `term` and `opts`. See the module doc for the full option list. Returns `dynamic(true)` when the term yields no tokens. """ @spec dynamic(String.t() | nil, keyword()) :: Ecto.Query.dynamic_expr() def dynamic(term, opts) do fields = opts |> Keyword.fetch!(:fields) |> List.wrap() |> Enum.map(&Field.normalize/1) if fields == [] do raise ArgumentError, "Decant.dynamic/2 requires a non-empty :fields list" end match = validate_match!(Keyword.get(opts, :match, :contains)) token_logic = validate_logic!(:token_logic, Keyword.get(opts, :token_logic, :and)) field_logic = validate_logic!(:field_logic, Keyword.get(opts, :field_logic, :or)) kase = validate_case!(Keyword.get(opts, :case, :insensitive)) escape? = Keyword.get(opts, :escape, true) on_blank = validate_on_blank!(Keyword.get(opts, :on_blank, :all)) tokenizer_opts = Keyword.get(opts, :tokenizer, []) case Tokenizer.tokenize(term, tokenizer_opts) do [] -> blank_clause(on_blank) tokens -> tokens |> Enum.map(&token_clause(&1, fields, match, kase, escape?, field_logic)) |> combine(token_logic) end end # One token → (field1 hits OR/AND field2 hits OR/AND ...) defp token_clause(token, fields, match, kase, escape?, field_logic) do fields |> Enum.map(fn %Field{} = f -> pattern = pattern(token, f.match || match, escape?) field_clause(f, pattern, kase) end) |> combine(field_logic) end defp field_clause(%Field{binding: b, column: c, cast: nil}, pattern, :insensitive), do: Ecto.Query.dynamic([{^b, t}], ilike(field(t, ^c), ^pattern)) defp field_clause(%Field{binding: b, column: c, cast: nil}, pattern, :sensitive), do: Ecto.Query.dynamic([{^b, t}], like(field(t, ^c), ^pattern)) defp field_clause(%Field{binding: b, column: c, cast: :string}, pattern, :insensitive), do: Ecto.Query.dynamic([{^b, t}], ilike(fragment("CAST(? AS TEXT)", field(t, ^c)), ^pattern)) defp field_clause(%Field{binding: b, column: c, cast: :string}, pattern, :sensitive), do: Ecto.Query.dynamic([{^b, t}], like(fragment("CAST(? AS TEXT)", field(t, ^c)), ^pattern)) # A blank/no-token term resolves to a constant filter: `:all` matches every # row (the search box is empty, so don't constrain), `:none` matches nothing. defp blank_clause(:all), do: Ecto.Query.dynamic(true) defp blank_clause(:none), do: Ecto.Query.dynamic(false) defp combine([single], _logic), do: single defp combine([first | rest], :and), do: Enum.reduce(rest, first, fn d, acc -> Ecto.Query.dynamic(^acc and ^d) end) defp combine([first | rest], :or), do: Enum.reduce(rest, first, fn d, acc -> Ecto.Query.dynamic(^acc or ^d) end) defp pattern(token, match, escape?) do token = if escape?, do: escape_like(token), else: token case match do :contains -> "%" <> token <> "%" :prefix -> token <> "%" :suffix -> "%" <> token :exact -> token end end # Escape LIKE/ILIKE metacharacters. Backslash is PostgreSQL's default escape # character, so no explicit ESCAPE clause is needed. Backslash itself is # escaped first to avoid double-escaping the escapes we introduce. defp escape_like(str) do str |> String.replace("\\", "\\\\") |> String.replace("%", "\\%") |> String.replace("_", "\\_") end defp validate_match!(m) when m in [:contains, :prefix, :suffix, :exact], do: m defp validate_match!(m), do: raise( ArgumentError, ":match must be one of :contains, :prefix, :suffix, :exact, got: #{inspect(m)}" ) defp validate_logic!(_key, l) when l in [:and, :or], do: l defp validate_logic!(key, l), do: raise(ArgumentError, "#{inspect(key)} must be :and or :or, got: #{inspect(l)}") defp validate_case!(c) when c in [:insensitive, :sensitive], do: c defp validate_case!(c), do: raise(ArgumentError, ":case must be :insensitive or :sensitive, got: #{inspect(c)}") defp validate_on_blank!(b) when b in [:all, :none], do: b defp validate_on_blank!(b), do: raise(ArgumentError, ":on_blank must be :all or :none, got: #{inspect(b)}") end