SqlStandardTableDataModel
SQL implementation of StandardTableDataModel that translates the IR into SELECT queries with filtering, sorting, grouping, and pagination.
SqlStandardTableDataModel is the SQL implementation of StandardTableDataModel. It takes a DataSchema[], a SqlDataSource, and an optional StandardTableConfig, then translates each StandardDataFetchAndTransformIR into a single SQL SELECT query.
This implementation targets in-browser WASM SQL engines (like DuckDB WASM) where the grid runs queries locally in the browser. If your data lives behind an API that connects to a full SQL server (Postgres, BigQuery, Redshift, Snowflake etc.), you can use this class as a reference for how to translate the IR into SQL and build your own StandardTableDataModel subclass that generates server-compatible queries.
class SqlStandardTableDataModel extends StandardTableDataModel {
protected table: string;
protected dataSource: SqlDataSource;
constructor(dataSchema: DataSchema[], dataSource: SqlDataSource, config: Partial<StandardTableConfig>, metadataPlumber?: StandardMetadataPlumber);
protected buildResolverInput(ir: StandardDataFetchAndTransformIR): StandardMetadataResolverInput;
getData(ir: StandardDataFetchAndTransformIR, metadataResolver?: StandardMetadataResolver<unknown>): Promise<GetRowsResponse>;
getRangeOfColumn(field: string): Promise<ColumnRangeValues>;
}
interface DataSchema extends Schema {
name: string;
displayName?: string;
}
interface Schema {
type: "measure" | "dimension";
subtype?: SchemaSubtype;
datetimeFormat?: string;
aggregateFn?: AggregateFn;
cardinality?: "low" | "high";
}
type SchemaSubtype = "temporal" | "nominal" | "integer" | "decimal";
type AggregateFn = "sum" | "avg" | "count" | "min" | "max";
interface StandardTableConfig {
pageSize: number;
maxNumPageBeforeEviction: number;
}
interface StandardDataFetchAndTransformIR {
startRow: number;
endRow: number;
groupPath: string[];
groupBy: string[];
project: string[];
sort: SortEntry[];
filter: ScalarFilter[];
metadata?: unknown;
}
interface SortEntry {
field: string;
direction: SortDirection;
by?: string;
}
type SortDirection = "asc" | "desc" | "noop";
interface ScalarFilter {
type: "scalar";
field: string;
subtype?: "date";
op: "eq" | "neq" | "in" | "not_in"
| "gt" | "lt" | "gte" | "lte"
| "between"
| "contains" | "doesNotContain" | "startsWith" | "endsWith"
| "before" | "after"
| "empty" | "notEmpty";
value: string | string[] | number | number[] | null;
}
interface StandardMetadataResolverInput {
ir: StandardDataFetchAndTransformIR;
schema: DataSchema[];
dataSource?: DataSource<any>;
}
interface DataSource {
execute(req: T): Promise<Record<string, any>[]>;
addRef(): void;
release(): Promise<void>;
}
interface StandardMetadataResolver {
resolve(input: StandardMetadataResolverInput): T[];
}
interface GetRowsResponse {
rowData: any[][];
totalRowCount: number;
metadata?: Record<string, any[]>;
}
type ColumnRangeValues = | { type: "categorical"; values: string[] }
| { type: "range"; min: number; max: number }
| { type: "temporal"; min: string; max: string };Constructor
import { SqlStandardTableDataModel } from "grid";
const model = new SqlStandardTableDataModel(schema, dataSource);
// or with config overrides:
const model = new SqlStandardTableDataModel(schema, dataSource, {
pageSize: 5000,
maxNumPageBeforeEviction: 100,
});schema-DataSchema[]describing each column's name, type, and subtypedataSource- aSqlDataSourceinstance (e.g.DuckDBWasmDataSource) with data already loadedconfig- optionalPartial<StandardTableConfig>. Defaults to{ pageSize: 10000, maxNumPageBeforeEviction: 300 }if omitted. See Configuration
The constructor reads the table name from dataSource.table and stores it for query generation.
getData implementation
getData receives a StandardDataFetchAndTransformIR and builds a single SQL query. The query shape depends on whether the IR has grouping active.
The code snippets below show user-facing getViewModelData() calls - the parent class handles page caching and orchestration, then calls getData() internally with the same IR shape. The SQL shown is what getData generates via its internal buildSQL method.
The IR has two fields that control grouping: groupBy is an ordered list of columns that define the nesting hierarchy (e.g. ["country", "state", "city"]), and groupPath is the path the user has drilled into (e.g. ["USA"] means "show what's inside USA"). When groupPath is shorter than groupBy, the model is at a group level and emits a GROUP BY query. When they are equal length (or groupBy is empty), the model is at the leaf level and emits a flat SELECT. See Row grouping for how expand/collapse drives these values.
Flat mode (no grouping)
When groupBy is empty or groupPath has reached the leaf level (groupPath.length >= groupBy.length), the model generates a plain SELECT with pagination:
const vm = await model.getViewModelData({
startRow: 0,
endRow: 50,
groupBy: [],
groupPath: [],
project: ["city", "revenue", "quarter"],
sort: [{ field: "revenue", direction: "desc" }],
filter: [{ type: "scalar", field: "quarter", op: "in", value: ["Q1", "Q2"] }],
});The equivalent SQL (formatted for readability; actual output is a single line):
SELECT "city", "revenue", "quarter", COUNT(*) OVER() AS __total__
FROM "sales"
WHERE "quarter" IN ('Q1', 'Q2')
ORDER BY "revenue" DESC
LIMIT 50 OFFSET 0COUNT(*) OVER() is a window function that returns the total row count across the full filtered result, without requiring a separate count query. The model reads it from the first row (rows[0].__total__) and strips it from the output data.
Grouped mode
When groupPath.length < groupBy.length, the model generates a GROUP BY query. The groupPath values become WHERE equality conditions that drill into the hierarchy, and the current level's group field becomes the GROUP BY column:
const vm = await model.getViewModelData({
startRow: 0,
endRow: 50,
groupBy: ["country", "state", "city"],
groupPath: ["USA"],
project: ["country", "state", "city", "revenue"],
sort: [],
filter: [],
});groupPath: ["USA"] means "fetch states inside USA". The depth is 1, so groupBy[1] ("state") is the group field. The equivalent SQL:
SELECT "state", COUNT(*) AS __count__, SUM("revenue") AS "revenue",
COUNT(*) OVER() AS __total__
FROM "sales"
WHERE "country" = 'USA'
GROUP BY "state"
ORDER BY "state"
LIMIT 50 OFFSET 0Step by step:
"country" = 'USA'comes fromgroupPath[0]matchinggroupBy[0]"state"is the GROUP BY field (the current level being fetched)COUNT(*)gives the number of rows in each group (used by the renderer for expand/collapse)SUM("revenue")aggregates measures - the aggregation function comes from the column'saggregateFnin the schemaCOUNT(*) OVER()gives the total number of groups (for pagination)
Only columns whose schema has an aggregateFn are included as aggregated measures. The group field itself and any dimension columns without aggregateFn are excluded from the output in grouped mode.
getRangeOfColumn implementation
getRangeOfColumn returns the domain of a column - used by filter UIs to show available values or numeric/date ranges. The query depends on the column's schema type:
Measures - returns the numeric min and max:
SELECT MIN("revenue") AS min_val, MAX("revenue") AS max_val
FROM "sales"Returns { type: "range", min: number, max: number }.
Temporal dimensions - returns the date range as strings:
SELECT MIN("order_date") AS min_val, MAX("order_date") AS max_val
FROM "sales"Returns { type: "temporal", min: string, max: string }.
Categorical dimensions - returns all distinct values:
SELECT DISTINCT "region" FROM "sales" ORDER BY "region"Returns { type: "categorical", values: string[] }.
How filters are translated
Each ScalarFilter in the IR's filter array becomes a WHERE clause. Multiple filters are combined with AND. The supported operators:
| Operator | SQL output |
|---|---|
eq | "field" = 'value' |
neq | "field" != 'value' |
in | "field" IN ('a', 'b', 'c') |
not_in | "field" NOT IN ('a', 'b', 'c') |
gt, lt, gte, lte | "field" > value (numeric, unquoted) |
between | "field" BETWEEN 'a' AND 'b' |
contains | "field" LIKE '%value%' |
doesNotContain | "field" NOT LIKE '%value%' |
startsWith | "field" LIKE 'value%' |
endsWith | "field" LIKE '%value' |
before, after | "field" < 'value', "field" > 'value' |
empty, notEmpty | "field" IS NULL, "field" IS NOT NULL |
String values are escaped (single quotes doubled) to prevent SQL injection within the WASM engine.
Date-part filters
When a filter has subtype: "date", the model wraps the column with EXTRACT before comparing. A DatePartScalarFilter has an additional part field ("year", "month", "quarter", etc.) that specifies which component to extract:
{ type: "scalar", subtype: "date", field: "order_date", part: "year", op: "eq", value: 2024 }Produces:
EXTRACT(year FROM "order_date") = 2024This enables queries like "orders in Q1" or "year between 2020 and 2024" that operate on date parts rather than raw timestamp values.
How sort is translated
Each SortEntry in the IR's sort array becomes an ORDER BY clause. Entries with direction: "noop" are skipped. The translation depends on the query mode:
Flat mode - sort by the column value directly:
ORDER BY "revenue" DESC, "city" ASCGrouped mode - the sort target depends on what is being sorted:
- Sorting by the group field itself:
ORDER BY "state" ASC - Sorting by a measure (via the
byfield on SortEntry):ORDER BY SUM("revenue") DESC- uses the column'saggregateFnfrom the schema - Sorting by another aggregated field (a column with
aggregateFnin its schema):ORDER BY SUM("quantity") ASC. Fields withoutaggregateFnare silently skipped in grouped mode
If no sort entries are provided and a group field exists, the model defaults to ORDER BY "groupField" to ensure consistent ordering.
API Reference
Extends StandardTableDataModel.
StandardTableDataModel
Produces flat rows with optional row grouping, expand/collapse, and paginated data fetching with cache management.
GridDataViewModel
The source of data for the layout (renderer). The datamodel produces viewmodel data; the renderer consumes it. Holds column-major data arrays and facet metadata for virtualized rendering.