Skip to the content.

Filtering, sorting, search

Source: lib/cafe_car/param_parser.rb, lib/cafe_car/query_builder.rb, lib/cafe_car/controller/filtering.rb, lib/cafe_car/queryable.rb, lib/cafe_car/model.rb.

On an index request, every query param that isn’t a control param (sort page per view tab q chart_* …) is a filter. Never invent bespoke filter params for an index screen — link with this grammar and the whole pipeline (table, grid, chart, CSV export, pagination) honors it. The same grammar is a plain Ruby API on every model (Model.query, below) — use it for any filtered query, not only CafeCar-rendered pages.

The dot-query URL grammar

URL Meaning
?name=Widget name = 'Widget'
?status.eq=active explicit equals
?price.min=10&price.max=50 >= and <= (aliases gte/lte; also gt/lt)
?created_at=2024-01-01..2024-12-31 range (... = exclusive end)
?tags=red,blue,green IN (…)
?name!=bob negate any filter (! suffix on the key)
?name~=wid literal substring match, case-insensitive (~ suffix)
?author=true / ?author=false association exists / doesn’t
?line_items=3, ?line_items.min=2 association count
?author.name~=bob filter through an association (nests recursively)
?published=true call a model scope (zero-arity); with a value, the value is the argument
?q=widget keyword search (see below)

Datetime values parse with Chronic, so ?created_at.min=last%20week works. Combine freely; everything composes with sort, q, view, and .csv.

Sorting

?sort=name              ascending
?sort=-price            descending
?sort=category,-price   multiple
?sort=author.name       through a belongs_to (joins automatically)

Keys are validated against real columns/associations — bad input is dropped, never raw SQL. Table headers emit these links already.

?q=term matches case-insensitively across the model’s string/text columns (parameter-filtered columns like passwords are skipped). A model overrides the default by defining a search scope:

scope :search, ->(term) { query("title~": term) }

Programmatic: Model.query

The same engine, from Ruby — available on every model with no opt-in:

Article.query("published" => true, "author.name~" => "bob")
Invoice.query("total.min" => 100)
Article.query(["draft term"])        # a bare string routes to search

Returns a relation; chain as usual.

CSV export

Every index responds to .csv (the toolbar has a Download CSV button). The export carries the current filters + sort, spans the whole result set (not just the page), mirrors the policy’s displayable columns, and is capped at CafeCar.csv_export_row_limit (default 10,000; truncation sets X-CafeCar-Truncated: true).

Chart view

Every index has a third view beside table and grid:

?view=chart&chart_x=published_at&chart_by=month        # count per month
?view=chart&chart_x=issued_on&chart_y=sum:total        # or sum:/avg: of a numeric column

Buckets day/week/month; columns are validated against the policy’s displayable date/numeric attributes. The chart aggregates the same filtered, policy-scoped collection the table shows, as dependency-free inline SVG.

Filter panel

The grammar above also drives a rendered panel: the model policy’s permitted_filters enumerates the controls (one typed per attribute), and the same list is the query whitelist. List a dot-path to filter through an association:

def permitted_filters = %i[status client.status client.owner_id]

Each hop is the association name; the terminal may be the far column, its enum, or a belongs_to (client.owner_idclient.owner). The control is typed by that terminal. An undeclared path — even one naming a real far column (?client.owner.email=) — is pruned before any join, exactly like an unpermitted top-level column.