Skip to content
OGE logoOGE

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.

FilterExpr server parity test vectors

Rules for every operator

  • Two-valued logic. Every condition is true or false for a row — never SQL's "unknown" — and not is the plain complement. A SQL translation spells the null branch out (as the table does) or wraps a negation as NOT COALESCE(expr, FALSE).
  • Text is folded. Comparisons ignore case and accents with a locale-independent fold: İzmir, IZMIR and izmir are equal, and ı matches i. 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.in and notin take an array and never split on commas — "Kaya, Mehmet" is one value. An empty in list matches no row: nothing selected is not "no filter".
  • Blank means null, undefined or text that is empty after trimming. Zero and false are values.

Operators

Filter operators
OperatorValueMatchesBlank cellsReference 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 / isnotblank for text columns and isnull / isnotnull for number, date and boolean columns — the same rows, and an operator every OData server understands.
  • A date condition becomes a day range: "equals" is ge day and lt next day, "does not equal" is lt day, ge next day or isnull — never a not a server would turn into "unknown".
  • A header filter's checked values travel as in; the global search box travels separately as LoadOptions.searchText.
  • buildODataQuery and ODataDataSource follow 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 isblank condition (isnull on number, date and boolean columns) next to the in list, 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 first valueLimit values and notes that typing narrows the list. A server should honour take.
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.
  • calculateFilterValue sets 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.
  • filterField sends a column's filters and its distinct() request to the server under another field: a derived customerName column filtered as customer.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):

In-memory timings
RowsFilter (in + between + contains)SearchSortValue 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());
  });
}