Hex.pm Docs

Tokenized, multi-field ILIKE/LIKE search for Ecto — compiled to a composable Ecto.Query.dynamic/2.

Almost every app grows the same function: take a search box string, split it on spaces, and ILIKE each word against a handful of columns. Everyone re-implements the nested reduce, and everyone gets a detail subtly wrong — forgetting to escape %, double-wrapping the pattern, or hand-rolling LOWER() that ILIKE already does. Decant is that function, written once.

filter =
  Decant.dynamic(search_string,
    fields: [
      {:customer, :email},
      {:customer, :first_name},
      {:customer, :last_name},
      {:order, :display_id, cast: :string}
    ]
  )

from q in query, where: ^filter

"jane gmail" → rows where every word matches some field: name/email contains jane and name/email contains gmail.

Why a dynamic?

Decant hands back a dynamic, not a modified query. That keeps it binding-agnostic and composable — it slots into any query regardless of joins, select, pagination, or other wheres, and it never needs to know your schema module. The one requirement: reference columns through named bindings.

from o in Order, as: :order,
  join: c in assoc(o, :customer), as: :customer

Installation

def deps do
  [{:decant, "~> 0.1.0-beta.2"}]
end

Decant's only runtime dependency is :ecto.

The shape

A search string becomes tokens (words). Default logic:

every token must match SOMEWHERE     (token_logic: :and)
  a token matches if ANY field hits  (field_logic: :or)
"foo bar"
     AND across tokens
   
 ( field1 ILIKE %foo%  OR  field2 ILIKE %foo% )   AND
 ( field1 ILIKE %bar%  OR  field2 ILIKE %bar% )
         OR across fields 

Flip the axes for other behaviours:

WantOption
Any word may match ("or search")token_logic: :or
A row must match every fieldfield_logic: :and
Prefix / autocompletematch: :prefix
Exact, case-sensitivematch: :exact, case: :sensitive

Options

OptionDefaultMeaning
:fields(required){binding, column} or {binding, column, opts} specs
:match:contains:contains :prefix :suffix :exact
:token_logic:andhow words combine
:field_logic:orhow columns combine per word
:case:insensitive:insensitive (ILIKE) / :sensitive (LIKE)
:escapetrueescape % _ \ in user input
:on_blank:allblank term → :all (dynamic(true)) / :none (dynamic(false))
:tokenizer[]opts for Decant.Tokenizer

Field options

{:order, :display_id, cast: :string}   # CAST(? AS TEXT) — search integer/enum cols
{:order, :display_id, match: :exact}   # per-field match override

Tokenizer options

:pattern (regex/string delimiter), :trim, :drop_empty, :downcase, :max_tokens (a backstop against pathological input).

Empty input is a no-op

A nil, blank, or all-whitespace term returns dynamic(true), so callers never branch:

# adds `WHERE true` when search is empty; the planner discards it
from q in query, where: ^Decant.dynamic(params["q"], fields: [...])

When an empty search should return nothing instead (e.g. a typeahead that shouldn't dump the whole table), pass on_blank: :none:

from q in query, where: ^Decant.dynamic(params["q"], fields: [...], on_blank: :none)

License

MIT © Zarar Siddiqi