The data table's entire backend contract in one struct.
Flop-shaped on purpose, Flop-free on purpose: anything that can produce
this struct can drive <.data_table>, and anything that can consume it
can execute the query - the in-memory Engine.List for plain lists,
or (Petal Pro) a Flop adapter for Ecto.
%State{
order_by: [{:email, :asc}],
filters: [%{field: :email, op: :contains, value: "d"}],
page: 1,
page_size: 10,
total: 74 # nil = cursor/unknown mode
}from_params/2 and to_params/1 round-trip the struct through URL
params, so URL-as-state (shareable sorts/filters, working back button)
is a one-liner in handle_params rather than a hand-rolled encoding.
Filter operators
The vocabulary describes what the USER expressed, not how a database
spells it. Each engine translates intent into its own query language,
which is why :contains here means "text contains" even though some
query libraries use that name for array membership.
- text -
:contains,:not_contains,:eq,:neq,:starts_with - ordered (numbers, dates) -
:gt,:gte,:lt,:lte,:between - sets -
:in,:not_in - null-ness -
:is_empty,:is_not_empty - dates, read as intent -
:before,:on,:after
Semantics pinned deliberately, because "obvious" differs per engine:
:betweenis INCLUSIVE of both bounds.:is_emptymatches nil,""and[]- a blank-ish" "is not empty. Trim before filtering if you want it to be.- text comparisons fold CASE, and only case.
:eq,:neq,:in,:not_in,:contains,:not_contains,:starts_with, the comparators against text columns, and the quicksearchall compare downcased on both sides. Accents are NOT folded: "cafe" does not match "café", while "CAFÉ" does. Folding is whateverString.downcase/1does; a SQLlower()is collation-dependent, so non-ASCII input can diverge between engines (Turkish dotless i is the classic case). ASCII agrees everywhere. :eqis deliberately NOT SQL=. A person picking "is" in a filter means "this value", not "these bytes" - and the same dropdown offers:containsand:starts_with, which every engine folds, so a byte-exact:eqwould make case behaviour silently flip as the user moves down the operator list. If a byte-exact equality is ever needed it will arrive as a NEW operator (:eq_sensitive), never as a change to this one.- comparators work on whatever the column holds - numbers, dates and text all compare with the same op.
:before/:on/:afterare date-shaped aliases of:lt/:eq/:gt; they exist so a date filter reads as a date filter, and an adapter may map them to the same primitives. They are defined only for date-typed cells (Date,DateTime,NaiveDateTime) - a text column holding an ISO string does not match, because no SQL engine would cast every row to find out.- a
nilvalue on a value-carrying op is unsatisfiable - it matches NO rows.from_params/2never emits one (a blank input drops the filter instead), so this only concerns hand-built states. Asking for empty cells is what:is_emptyis for. - map-shaped cells are filter-only. Erlang orders maps by size, then keys, then values; jsonb has its own type-ordering rules, and no SQL expression reconciles the two - so a map column has NO defined sort order across engines. Don't mark one sortable.
- an empty
order_by, and ties within one, have NO defined row order across engines: this engine preserves input order, SQL without ORDER BY promises nothing. For stable paging, append a unique tiebreaker (the primary key) to every sort. That is the APPLICATION's job, at the point the state is built - an adapter must reproduceorder_byexactly as given and never append one silently, or the same state would order differently per engine.
Engines may support a subset. ops/0 returns the recognised set and
valueless_op?/1 identifies the ops that carry no value.
Writing an adapter
Text equality in this contract folds case, so lower(col) = lower($1)
is the SQL you want and plain = is wrong - for :eq, :neq, :in
and :not_in alike, since they share one equality primitive. A
half-folded implementation is worse than either extreme: "is not
alice" leaving a visible "Alice" on screen is the loudest possible
wrong answer.
Plan for the index rather than discovering it: lower(col) will not
use a plain btree index - add CREATE INDEX ... ON t (lower(col)),
with the emitted expression matching the index expression exactly.
Measured at one million rows (PostgreSQL 14), the functional index
makes folded equality as fast as exact (~0.02ms either way);
without it you get a sequential scan.
One exception: on a citext column, plain = is ALREADY
case-insensitive and index-backed - wrapping it in lower() defeats
the index (measured ~28x slower). An adapter that knows a column is
citext should emit plain = there.
Reproduce order_by exactly - do not append a tiebreaker on the
application's behalf (see the tie rule above), and do not reorder or
drop entries. If the state's sort is unstable, that is the caller's
bug to fix where the state is built.
Text-shaped ops against float columns compare the TEXT FORM of the
float, and Elixir and SQL format extremes differently:
to_string(1.0e20) is "1.0e20" while Postgres renders 1e+20 -
so :contains/:in against exponent-range floats can diverge
between engines. Integral floats (10.0) agree. Money belongs in a
decimal column, which has no such gap.
Security
from_params/2 never creates atoms from user input: :fields is a
required whitelist and anything outside it is dropped, ops outside the
known set are dropped, and page/page_size are clamped (:max_page_size,
default 100).
Summary
Functions
Removes every filter, resetting to page 1.
Builds a State from URL/event params.
Applies one event-mode op payload - the entire data_table event
grammar in one call, so an event-mode handler is a one-liner
The operators this contract recognises.
Replaces the filter for field (or removes it when value is nil/empty), resetting to page 1.
Sets the page size (invalid values keep the current one), resetting to page 1.
Sets (or clears, for blank terms) the quick-search term, resetting to page 1.
Encodes the state as a flat params map suitable for push_patch
query strings. Defaults (page 1, empty sorts/filters, the default
page size) are omitted so URLs stay clean; total never round-trips -
it is a result, not a request.
Returns the state with field as the primary sort: cycles
asc -> desc -> removed on repeated calls (the header-click grammar),
and always resets to page 1 - a reordered page 7 is meaningless.
Total pages when total is known, else nil (cursor/unknown mode).
True for operators that carry no value, like :is_empty.
Types
@type order() :: {atom(), :asc | :desc}
@type t() :: %PetalComponents.DataTable.State{ filters: [filter()], order_by: [order()], page: pos_integer(), page_size: pos_integer(), search: String.t() | nil, total: non_neg_integer() | nil }
Functions
Removes every filter, resetting to page 1.
Builds a State from URL/event params.
Options:
:fields(required) - the whitelist of sortable/filterable fields, as atoms. Params referencing any other field are silently dropped.:page_size- default page size when the params carry none (10).:max_page_size- clamp ceiling for user-supplied sizes (100).
Accepted param shapes (all optional, all strings - what to_params/1
emits and what hand-written URLs naturally produce):
"order_by"-"email"or"email:desc"or"email:desc,name""filters"- a list (or Phoenix-style indexed map) of%{"field" => f, "op" => op, "value" => v}"page","page_size"- integers as strings
Applies one event-mode op payload - the entire data_table event
grammar in one call, so an event-mode handler is a one-liner:
def handle_event("table", params, socket) do
state = State.handle_op(socket.assigns.table, params, fields: [:name, :email])
{rows, state} = Engine.List.run(all_rows(), state)
{:noreply, assign(socket, rows: rows, table: state)}
endOps: sort (field), page (page), search (term), page_size
(page_size), filter (field, filter_op, value/value2/values), and
clear_filters. Unknown ops and non-whitelisted fields leave the
state unchanged; like from_params/2, no atoms are ever created
from input.
A filter op's value normalizes by editor shape: a values list
posts as-is (the select editor's :in), between pairs
value/value2 into [min, max], anything blank removes the
field's filter.
The operators this contract recognises.
Replaces the filter for field (or removes it when value is nil/empty), resetting to page 1.
Sets the page size (invalid values keep the current one), resetting to page 1.
Sets (or clears, for blank terms) the quick-search term, resetting to page 1.
Encodes the state as a flat params map suitable for push_patch
query strings. Defaults (page 1, empty sorts/filters, the default
page size) are omitted so URLs stay clean; total never round-trips -
it is a result, not a request.
Pass the same :page_size default given to from_params/2 so the
two stay symmetric (an omitted size decodes back to that default).
Returns the state with field as the primary sort: cycles
asc -> desc -> removed on repeated calls (the header-click grammar),
and always resets to page 1 - a reordered page 7 is meaningless.
Total pages when total is known, else nil (cursor/unknown mode).
True for operators that carry no value, like :is_empty.