# Decant
[](https://hex.pm/packages/decant)
[](https://hexdocs.pm/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.
```elixir
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 `where`s, and it never needs to know your schema
module. The one requirement: reference columns through **named bindings**.
```elixir
from o in Order, as: :order,
join: c in assoc(o, :customer), as: :customer
```
## Installation
```elixir
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
```elixir
{: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:
```elixir
# 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`:
```elixir
from q in query, where: ^Decant.dynamic(params["q"], fields: [...], on_blank: :none)
```
## License
MIT © Zarar Siddiqi