Filter contract
A grid, tree list or pivot filters in memory or hands the same FilterExpr to your server. This page is the contract both sides follow — what each operator means and what it does with blank cells — so the same filter returns the same rows either way. Run the published test vectors against your server to prove it.
Rules for every operator
- Two-valued logic. Every condition is true or false for a row — never SQL's "unknown" — and
notis the plain complement. A SQL translation spells the null branch out (as the table does) or wraps a negation asNOT COALESCE(expr, FALSE). - Text is folded. Comparisons ignore case and accents with a locale-independent fold:
İzmir,IZMIRandizmirare equal, andımatchesi. On a server, use a case- and accent-insensitive collation. - Days are half-open. A day is
[00:00, next day 00:00)in local time, so a row stamped 17:30 on the last day of a range is inside it, and "not on that day" keeps rows with no date. - Lists are arrays.
inandnotintake an array and never split on commas —"Kaya, Mehmet"is one value. An emptyinlist matches no row: nothing selected is not "no filter". - Blank means null, undefined or text that is empty after trimming. Zero and
falseare values.
Operators
| Operator | Value | Matches | Blank cells | Reference SQL |
|---|---|---|---|---|
eq | one value | The cell equals the value (text folded, dates by instant). | A null cell matches only a null value. | f = v (f IS NULL when v is null) |
ne | one value | The cell does not equal the value. | Null cells are included (they are not equal to the value). | (f <> v OR f IS NULL) |
gt | one value | The cell is greater than the value. | Null cells never match an ordering comparison. | f > v |
ge | one value | The cell is greater than or equal to the value. | Null cells never match an ordering comparison. | f >= v |
lt | one value | The cell is less than the value. | Null cells never match an ordering comparison. | f < v |
le | one value | The cell is less than or equal to the value. | Null cells never match an ordering comparison. | f <= v |
contains | one value | The cell text contains the value text. | Null cells never match. | f LIKE '%' || v || '%' |
notcontains | one value | The cell text does not contain the value text. | Null cells are included. | (f NOT LIKE '%' || v || '%' OR f IS NULL) |
startswith | one value | The cell text starts with the value text. | Null cells never match. | f LIKE v || '%' |
endswith | one value | The cell text ends with the value text. | Null cells never match. | f LIKE '%' || v |
in | array | The cell equals one of the listed values. An empty list matches no row: "nothing selected" is not "no filter". | A null cell matches when the list contains null. | f IN (…) (OR f IS NULL when the list holds null; FALSE when empty) |
notin | array | The cell equals none of the listed values. An empty list matches every row. | Null cells are included unless the list contains null. | (f NOT IN (…) OR f IS NULL) (AND f IS NOT NULL when the list holds null) |
between | [from, to] | The cell lies within [from, to], both ends included; a null end leaves that side open. Grid day ranges are sent as ge / lt pairs instead (half-open by day). | Null cells never match. | f >= from AND f <= to |
isnull | none | The cell is null or undefined. | An empty string is not null. | f IS NULL |
isnotnull | none | The cell is neither null nor undefined. | An empty string is not null, so it matches. | f IS NOT NULL |
isblank | none | The cell is null, undefined, or text that is empty after trimming. | Matches exactly the blank cells. | (f IS NULL OR TRIM(f) = '') |
isnotblank | none | The cell has a value other than empty or whitespace-only text. | Blank cells never match. | (f IS NOT NULL AND TRIM(f) <> '') |
What the grid sends
- "Is blank" / "Is not blank" in the filter menus send
isblank/isnotblankfor text columns andisnull/isnotnullfor number, date and boolean columns — the same rows, and an operator every OData server understands. - A date condition becomes a day range: "equals" is
geday andltnext day, "does not equal" isltday,genext day orisnull— never anota server would turn into "unknown". - A header filter's checked values travel as
in; the global search box travels separately asLoadOptions.searchText. buildODataQueryandODataDataSourcefollow the table, including the explicit null branches.
Value lists
An Excel-style header filter lists a column's values through DataSource.distinct(field, options). The list cascades: it is taken under every other active filter and the global search text, never under the column's own filter. An in-memory source is searched in the browser; a server source is asked once when the popup opens and again 250 ms after the user stops typing in its search box, with the typed text as search. Each request aborts the previous one and a late answer never replaces a newer one. A source without distinct shows no value list — the current page is never passed off as every value.
- One "(Blank)" item. Null, empty and whitespace-only values list as a single (Blank) entry at the end. Checking it adds an
isblankcondition (isnullon number, date and boolean columns) next to theinlist, so a boolean column filters yes / no / blank. - A cut list says so. The grid asks for one value more than
valueLimit(take); when the source returns more, the popup lists the firstvalueLimitvalues and notes that typing narrows the list. A server should honourtake.
import { CustomDataSource, type DistinctOptions } from '@oge-ui/core';
const orders = new CustomDataSource<Order>({
key: 'id',
load: (options) => api.post('/orders/query', options),
// the header filter's value list: other columns' filters + search text,
// never the column's own filter; `search` is the popup's typed text
distinct: (field, options: DistinctOptions = {}) =>
api.post(`/orders/distinct/${field}`, {
filter: options.filter,
searchText: options.searchText,
search: options.search,
take: options.take,
}, { signal: options.signal }),
});Display values and server fields
A filter is written against a column's field. What it is compared with can differ from the stored value:
- Lookup columns (a static
lookup) are searched by their labels as well as their stored codes, list their labels alphabetically, and filter by code from the filter row and value list — leave finds the row whose status code means On leave. calculateFilterValuesets the value a column filters, lists and searches by on the client — the full name behind two fields, the translated text of an enum. Every operator then compares that value.filterFieldsends a column's filters and itsdistinct()request to the server under another field: a derivedcustomerNamecolumn filtered ascustomer.lastName. The saved state and the filter chips keep the column's own field.
Sorting and locales
Text sorts numeric-aware and case-insensitively under a collator for sorting.locale, which is set apart from the locale that formats numbers and dates and defaults to it. With 'tr', I is the capital of ı, which sorts before i and İ: Isparta comes before İnegöl, where an English sort puts İnegöl first. sorting.compare replaces the comparison outright; blank cells sort last either way. Both apply to rows the grid sorts itself and to its value lists — a server sorts with its own collation (COLLATE "tr-TR-x-icu" in PostgreSQL, Turkish_CI_AS in SQL Server). Search and text filters fold case and accents the same way in every locale, so izmir, IZMIR and İzmir all match.
Performance
An in-memory source compiles the filter tree once per load, not per row: folded needles, the Set behind an in list and the epoch of a date bound are prepared up front, and a search formats each distinct date once. A value list is read once when its popup opens; typing in its search box narrows that list without touching the rows. Measured on a laptop with the suite's own data source (five columns, one with Turkish text):
| Rows | Filter (in + between + contains) | Search | Sort | Value list |
|---|---|---|---|---|
| 10 000 | ≈ 6 ms | ≈ 20 ms | ≈ 6 ms | ≈ 1 ms |
| 100 000 | ≈ 55 ms | ≈ 200 ms | ≈ 50 ms | ≈ 6 ms |
The grid renders only the rows in view, so these costs — not the row count — decide how quickly a menu opens or a filter applies.
Test vectors
@oge-ui/core/filter-vectors.json holds rows, expressions and the expected row ids for every operator — blank and null cells, timestamps inside a day, values with commas, the Turkish dotted and dotless i. The suite's own evaluator runs them on every build; run them against your translation too.
// a server test suite, run against your own FilterExpr → SQL translation
import vectors from '@oge-ui/core/filter-vectors.json' with { type: 'json' };
for (const c of vectors.cases) {
test(c.name, async () => {
await seed(vectors.rows); // revive vectors.dateFields as timestamps
const ids = await query(translate(c.filter)); // your translation
expect(ids.sort()).toEqual([...c.expect].sort());
});
}