Decant
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 janeand 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:
| Want | Option |
|---|---|
| Any word may match ("or search") | token_logic: :or |
| A row must match every field | field_logic: :and |
| Prefix / autocomplete | match: :prefix |
| Exact, case-sensitive | match: :exact, case: :sensitive |
Options
| Option | Default | Meaning |
|---|---|---|
:fields | (required) | {binding, column} or {binding, column, opts} specs |
:match | :contains | :contains:prefix:suffix:exact |
:token_logic | :and | how words combine |
:field_logic | :or | how columns combine per word |
:case | :insensitive | :insensitive (ILIKE) / :sensitive (LIKE) |
:escape | true | escape %_\ in user input |
:on_blank | :all | blank 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