Decant

Hex.pmDocs

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:

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