Server adapters
A grid with a remote data source sends one LoadOptions object per request — filter, search, sort, page, groups and summaries. The server adapters turn that object into a query and the rows back into the LoadResult the grid reads: @oge-ui/server builds parameterized SQL for PostgreSQL, MySQL / MariaDB, SQLite and SQL Server, and Oge.Data applies the request to any .NET IQueryable, Entity Framework Core included.
Both follow the filter contract: their test suites run its published vectors against a real database (SQLite for Node; LINQ to Objects and Entity Framework Core on SQLite for .NET), so a filter returns the same rows on the server as in the browser.
How it fits
On the client, a CustomDataSource posts the load options to your endpoint; the grid then sorts, filters, groups and pages through the server.
import { CustomDataSource } from '@oge-ui/core';
const orders = new CustomDataSource<Order>({
key: 'id',
// the server sorts, filters, groups, pages and sums (the default)
load: (options) => api.post('/api/orders/query', options, { signal: options.signal }),
distinct: (field, options = {}) =>
api.post(`/api/orders/distinct/${field}`, options, { signal: options.signal }),
});Node and SQL
npm install @oge-ui/server Describe the resource as a schema — the fields a request may name and the column behind each — check every request with ogeParseLoadOptions, and answer it with ogeSqlLoad. The adapter has no driver of its own: you hand it a function that runs one statement.
import express from 'express';
import pg from 'pg';
import {
OgeServerError,
ogeParseLoadOptions,
ogePostgresDialect,
ogeSqlDistinct,
ogeSqlLoad,
type OgeServerSchema,
type OgeSqlLoadOptions,
} from '@oge-ui/server';
const pool = new pg.Pool();
// the allowlist: only these fields may appear in a request, each mapped to
// its column; anything else is a 400
const orders: OgeServerSchema = {
key: 'id',
fields: {
id: { type: 'number' },
customer: { column: 'customer_name' },
country: {},
total: { type: 'number' },
orderedAt: { column: 'ordered_at', type: 'date' },
shipped: { type: 'boolean', searchable: false },
},
};
const sql = (tenantId: string): OgeSqlLoadOptions => ({
schema: orders,
from: 'sales.orders',
dialect: ogePostgresDialect({ unaccent: true }),
// your own condition, ANDed to every query
where: { sql: 'tenant_id = ?', params: [tenantId] },
run: async ({ sql, params }) => (await pool.query(sql, [...params])).rows,
});
const app = express().use(express.json());
app.post('/api/orders/query', async (req, res) => {
try {
const options = ogeParseLoadOptions(req.body, orders, { maxTake: 500 });
res.json(await ogeSqlLoad(options, sql(req.user.tenantId)));
} catch (error) {
if (error instanceof OgeServerError) res.status(error.status).json({ error: error.message });
else throw error;
}
});
// a header filter's value list
app.post('/api/orders/distinct/:field', async (req, res) => {
const { filter, searchText, search, take } = ogeParseLoadOptions(
{ filter: req.body.filter, searchText: req.body.searchText, take: req.body.take },
orders,
);
res.json(
await ogeSqlDistinct(req.params.field, { filter, searchText, search: req.body.search, take }, sql(req.user.tenantId)),
);
});- Without
groupthe answer is the page of rows; with it, the first-level groups as{ key, items: null, count, summary }. The grid asks for an expanded group's rows itself, with a filter on its key. - Calendar buckets (
hour,day,month,quarter,year) and number buckets are computed in SQL.weekis refused: its first day depends on the reader's locale. - The schema's
keyis appended to every sort, so rows with equal sort values never trade places between pages.
Other databases
The dialect decides placeholders, quoting, paging and text folding. Text compares folded: lower() on PostgreSQL (plus unaccent when the extension is installed) and MySQL (whose default collations already ignore accents), an accent- and case-insensitive collation on SQL Server, and on SQLite the suite's own folding registered as a function.
// mysql2 — ? placeholders
ogeMysqlDialect();
run: async ({ sql, params }) => (await pool.execute(sql, params))[0]
// SQL Server (mssql) — @p1, @p2… named parameters
ogeMssqlDialect({ collation: 'Turkish_100_CI_AI' });
run: async ({ sql, params }) => {
const request = pool.request();
params.forEach((value, i) => request.input(`p${i + 1}`, value));
return (await request.query(sql)).recordset;
}
// SQLite (node:sqlite or better-sqlite3) — register the suite's folding once
db.function('oge_fold', (text) => (text === null ? null : ogeFoldText(String(text))));
ogeSqliteDialect();
run: async ({ sql, params }) => db.prepare(sql).all(...params).NET and IQueryable
dotnet add package Oge.DataOge.Data (net8.0) builds expression trees, so Entity Framework translates the whole request — filter, search, sort, paging, counts, groups and summaries — into one SQL query per part. Calendar buckets group by date parts, which every provider translates.
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Oge.Data;
[ApiController]
[Route("api/orders")]
public sealed class OrdersController(SalesContext db) : ControllerBase
{
// the allowlist: every scalar property in camelCase, minus what must stay private
private static readonly OgeSchema<Order> Schema =
OgeSchema<Order>.FromProperties().Without("internalNote").WithKey("id");
[HttpPost("query")]
public async Task<ActionResult<OgeLoadResult>> Query(OgeLoadOptions options, CancellationToken ct)
{
try
{
return await db.Orders.Where(o => o.TenantId == User.TenantId())
.OgeLoadAsync(options, Schema, EfRunner.Instance, cancellationToken: ct);
}
catch (OgeServerException error)
{
return BadRequest(new { error = error.Message });
}
}
}
// Entity Framework Core runs the queries asynchronously
public sealed class EfRunner : OgeQueryRunner
{
public static readonly EfRunner Instance = new();
public override Task<List<T>> ToListAsync<T>(IQueryable<T> query, CancellationToken ct) =>
EntityFrameworkQueryableExtensions.ToListAsync(query, ct);
public override Task<int> CountAsync<T>(IQueryable<T> query, CancellationToken ct) =>
EntityFrameworkQueryableExtensions.CountAsync(query, ct);
}// OgeTextFold.ToLower (default): LOWER() on both sides — translates on every
// provider; accents follow the column collation.
// OgeTextFold.Contract: the suite's own folding (case, accents, İ / ı). Map it
// to a database function so Entity Framework can translate it:
protected override void OnModelCreating(ModelBuilder model) =>
model.HasDbFunction(typeof(OgeText).GetMethod(nameof(OgeText.Fold))!).HasName("oge_fold");
var result = await db.Orders.OgeLoadAsync(
options, Schema, EfRunner.Instance, new OgeQueryOptions { Fold = OgeTextFold.Contract });Exports on the server
Building a 100 000-row Excel file in the browser locks the tab. The grid's exportOnServer sends its query and its visible columns instead, and saves what your endpoint returns. On the server, ogeSqlRows reads the rows in keyed batches and ogeExportResponse streams a real .xlsx (bold frozen header, typed numbers, booleans and dates in the column's time zone) or a formula-guarded CSV — built on CompressionStream, so it runs on Node, Deno, Bun and edge runtimes with no dependency, and never holds the file in memory.
// Angular — the same call on React's grid ref (gridRef.current.exportOnServer)
await this.grid().exportOnServer('xlsx', async (request) => {
// the server streams the file: a URL lets the browser download it natively
const { url } = await (
await fetch('/api/orders/export', { method: 'POST', body: JSON.stringify(request) })
).json();
return url;
}, 'orders');
// or return the streamed Response: written straight to disk where the File
// System Access API exists (Chromium), through a Blob elsewhere
await this.grid().exportOnServer('csv', (request) =>
fetch('/api/orders/export', { method: 'POST', body: JSON.stringify(request) }),
);import { Readable } from 'node:stream';
import { ogeExportResponse, ogeParseExportRequest, ogeSqlRows } from '@oge-ui/server';
app.post('/api/orders/export', async (req, res) => {
// the query, the columns (schema fields only) and a safe file name
const request = ogeParseExportRequest(req.body, orders, { maxListLength: 10_000 });
// rows in batches of 5 000, keyed, never all in memory
const rows = ogeSqlRows(request, sql(req.user.tenantId), { maxRows: 1_000_000 });
// a streamed .xlsx (or CSV) with its headers — no dependency
const response = ogeExportResponse(request, rows);
res.status(200);
response.headers.forEach((value, name) => res.setHeader(name, value));
Readable.fromWeb(response.body).pipe(res);
});Security
- The schema is the allowlist. A request can only name schema fields; an unknown field, operator or summary is a 400, never part of a query. Mark fields
filterable: falseorsortable: false(or leave them out) when they must not be probed. - Values are always parameters. Identifiers come from the schema and are quoted;
LIKEwildcards in typed text are escaped, so50%means the characters, not a pattern. - Scope every query yourself. Tenant and permission conditions go in
where(Node) or aWherebeforeOgeLoadAsync(.NET) — the request cannot remove them.
Limits
Requests are bounded: at most 1000 rows per page (a missing take becomes the maximum), 16 levels of filter nesting, 200 conditions, 1000 values in an in list and 500 characters of text. Raise or lower them per endpoint with OgeParseLimits / OgeQueryOptions. Custom summaries (type: 'custom') are computed in the browser only and are refused.
API reference
@oge-ui/server API
Methods 17
Functions
| Name | Type | Description |
|---|---|---|
ogeParseLoadOptions(input, schema, limits?) | (input: unknown, schema: OgeServerSchema, limits?: OgeParseLimits) => LoadOptions | Checks an untrusted request body (the object or its JSON): only schema fields, only the contract's operators, bounded page size, filter depth and list length. Throws OgeServerError (status 400) otherwise. A missing take becomes maxTake. |
ogeSqlLoad(options, config) | (options: LoadOptions, config: OgeSqlLoadOptions) => Promise<LoadResult> | Answers a grid request with your driver: the page of rows (or of first-level groups with counts and group summaries), the total count when asked for, the total summaries. config.run executes one { sql, params } statement. |
ogeSqlDistinct(field, options, config) | (field: string, options: DistinctOptions, config) => Promise<unknown[]> | The distinct values of a field for a header filter list, under the other columns' filter, the search box and the list's own search text. |
ogeBuildSqlQueries(options, config) | (options: LoadOptions, config: OgeSqlOptions) => OgeSqlQueries | The statements alone — data, count, summary, groupCount — for running them yourself (a transaction, a read replica, logging). |
ogeBuildDistinctQuery(field, options, config) | (field, options, config: OgeSqlOptions) => OgeSqlStatement | The header filter statement alone. |
ogePostgresDialect(options?) | (options?: { unaccent?: boolean }) => OgeSqlDialect | PostgreSQL: $1 placeholders, LIMIT / OFFSET, text folded with lower() — or lower(unaccent()) with { unaccent: true } when the extension is installed — and date_trunc buckets. |
ogeMysqlDialect() | () => OgeSqlDialect | MySQL / MariaDB: ? placeholders, backtick quoting, lower() folding (the default _ai_ci collations already ignore accents). |
ogeSqliteDialect(options?) | (options?: { foldFunction?: string | null }) => OgeSqlDialect | SQLite: text folds through oge_fold, registered with ogeFoldText (foldFunction: null falls back to ASCII-only lower()); date buckets expect ISO text. |
ogeMssqlDialect(options?) | (options?: { collation?: string }) => OgeSqlDialect | SQL Server: @p1 named parameters, bracket quoting, OFFSET … FETCH (with ORDER BY (SELECT NULL) when unsorted) and folding through a case- and accent-insensitive collation (default Latin1_General_100_CI_AI). |
ogeParseExportRequest(input, schema, limits?) | (input: unknown, schema, limits?) => OgeExportRequest | Checks a grid's exportOnServer request: the query as ogeParseLoadOptions does, every column a schema field, a safe file name (no paths or control characters) and bounded selected keys. |
ogeSqlRows(request, config, options?) | AsyncGenerator<Record<string, unknown>> | The request's rows from SQL in batches (batchSize, default 5 000) with the schema key as tie-breaker — no gaps or repeats — up to maxRows (default 1 000 000); selectedKeys become an in filter. |
ogeXlsxStream(rows, columns, options?) | ReadableStream<Uint8Array> | A streamed .xlsx: one sheet with a bold, frozen header row and an autofilter; numbers, booleans and dates (the column's time-zone wall clock) typed. Built on CompressionStream, no dependency; ZIP32, up to 4 GB. |
ogeCsvChunks(rows, columns, options?) | AsyncGenerator<string> | The file as CSV text chunks: a byte-order mark (unless bom: false), a header, the suite's formula guard, dates as ISO wall clock. |
ogeExportResponse(request, rows) | Response | The finished response: the streamed file, its content type and a Content-Disposition with the UTF-8 file name. In Node, pipe Readable.fromWeb(response.body). |
ogeFoldText(text) | (text: string) => string | The suite's text folding, to register as SQLite's oge_fold function so text compares exactly as in the browser. |
ogeEscapeLike(text) | (text: string) => string | Escapes %, _ and ! for a LIKE … ESCAPE '!' pattern. |
ogeSchemaField(schema, name, use?) | (schema, name: string, use?: 'filter' | 'sort' | 'select') => OgeServerField | A schema field, or the OgeServerError a request naming it would get. |
Types 10
Types
| Name | Type | Description |
|---|---|---|
OgeServerSchema | interface | { fields, key? } — the allowlist. key is appended to every sort so pages are stable. |
OgeServerField | interface | { column?, type?, searchable?, filterable?, sortable?, expression? } — type is string (default, compared folded), number, date or boolean; expression: true takes column as raw SQL written by you. |
OgeServerFieldType | 'string' | 'number' | 'date' | 'boolean' | How a field's values compare. |
OgeParseLimits | interface | { maxTake = 1000, maxFilterDepth = 16, maxFilterNodes = 200, maxListLength = 1000, maxTextLength = 500 }. |
OgeSqlOptions / OgeSqlLoadOptions | interface | { schema, from, dialect, where?, toDbValue? } plus run and mapRow for loading. where is your own condition (? per parameter) ANDed to every query; toDbValue converts request values (dates arrive as ISO text). |
OgeSqlRunner | (statement: OgeSqlStatement) => Promise<Record<string, unknown>[]> | Executes one statement with your driver and returns its rows. |
OgeSqlStatement / OgeSqlQueries | interface | { sql, params }, and the set { data, count, summary, groupCount }. |
OgeSqlDialect | interface | { name, quote, placeholder, fold, trim, paging, dateBucket } — implement it for another database. |
OgeExportRequest / OgeExportFileColumn | interface | A checked export request { format, fileName, loadOptions, columns, selectedKeys? } and one file column { field, caption, dataType, width?, timeZone? }. |
OgeServerError | class | A request the schema or the limits refuse; status is 400. Answer it as a bad request. |
Oge.Data API
Methods 3
Extension methods (IQueryable<T>)
| Name | Type | Description |
|---|---|---|
OgeLoadAsync(options, schema, runner, settings?, ct?) | Task<OgeLoadResult> | Answers a request on any IQueryable: filter, search, sort (with the schema key as tie-breaker), paging, total count, total summaries, and first-level groups with counts and group summaries. OgeLoad is the synchronous form. |
ApplyOgeFilter(options, schema, settings?) / ApplyOgeSort(sort, schema) | IQueryable<T> | The filter and search, or the sort, alone — to compose with your own query. |
OgeDistinctAsync(field, schema, filter?, searchText?, search?, take?, runner?, settings?, ct?) | Task<IReadOnlyList<object?>> | The distinct values of a field for a header filter list. |
Types 6
Types
| Name | Type | Description |
|---|---|---|
OgeSchema<T> | class | The allowlist: FromProperties() (every scalar property, camelCase), Field(name, x => x.Member, searchable?, filterable?, sortable?), Without(names), WithKey(name). |
OgeLoadOptions / OgeLoadResult / OgeGroup | class | The request and the response, shaped as the grid sends and reads them (System.Text.Json, camelCase). OgeLoadOptions.Parse(json) reads a body. |
OgeQueryOptions | class | { Fold, MaxTake = 1000, MaxFilterDepth = 16, MaxFilterNodes = 200, MaxListLength = 1000 }. |
OgeTextFold | enum | ToLower (default; translates on every provider), Contract (OgeText.Fold: case, accents, İ / ı — map it with HasDbFunction for Entity Framework) or None. |
OgeQueryRunner | class | Runs the queries; override ToListAsync / CountAsync with Entity Framework's. OgeQueryRunner.Sync runs them synchronously. |
OgeServerException | class | A request the schema or the limits refuse — answer with 400. |