From 7aa4d8a915501d4281db42f58b5a35a570a36959 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Thu, 6 Nov 2025 09:20:31 -0800 Subject: [PATCH 01/21] Fix multicolumn indexes for typescript modules --- .../src/server/constraints.ts | 17 +++---- .../bindings-typescript/src/server/indexes.ts | 43 ++++++++++------- .../src/server/schema.test-d.ts | 36 ++++++++++++++ .../bindings-typescript/src/server/table.ts | 48 +++++++++++++++---- 4 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 crates/bindings-typescript/src/server/schema.test-d.ts diff --git a/crates/bindings-typescript/src/server/constraints.ts b/crates/bindings-typescript/src/server/constraints.ts index 4301137281f..51d80f76bc9 100644 --- a/crates/bindings-typescript/src/server/constraints.ts +++ b/crates/bindings-typescript/src/server/constraints.ts @@ -6,14 +6,15 @@ import type { ColumnMetadata } from './type_builders'; */ export type AllUnique< TableDef extends UntypedTableDef, - Columns extends Array, -> = { - [i in keyof Columns]: ColumnIsUnique< - TableDef['columns'][Columns[i]]['columnMetadata'] - >; -} extends true[] - ? true - : false; + Columns extends ReadonlyArray, +> = Columns extends readonly [ + infer Head extends keyof TableDef['columns'], + ...infer Tail extends ReadonlyArray, +] + ? ColumnIsUnique extends true + ? AllUnique + : false + : true; /** * A helper type to determine if a column is unique based on its metadata. diff --git a/crates/bindings-typescript/src/server/indexes.ts b/crates/bindings-typescript/src/server/indexes.ts index 7af552012f6..20dd0a6cd21 100644 --- a/crates/bindings-typescript/src/server/indexes.ts +++ b/crates/bindings-typescript/src/server/indexes.ts @@ -18,22 +18,22 @@ export type IndexOpts = { /** * An untyped representation of an index definition. */ -type UntypedIndex = { +export type UntypedIndex = { name: string; unique: boolean; algorithm: 'btree' | 'direct'; - columns: AllowedCol[]; + columns: readonly AllowedCol[]; }; /** * A helper type to extract the column names from an index definition. */ export type IndexColumns> = I extends { - columns: string[]; + columns: readonly (infer Names extends string)[]; } - ? I['columns'] - : I extends { column: string } - ? [I['column']] + ? readonly [...I['columns']] + : I extends { column: infer Name extends string } + ? readonly [Name] : never; /** @@ -95,9 +95,18 @@ export type IndexVal< /** * A helper type to extract the types of the columns that make up an index. */ -type _IndexVal = { - [i in keyof Columns]: TableDef['columns'][Columns[i]]['typeBuilder']['type']; -}; +type _IndexVal< + TableDef extends UntypedTableDef, + Columns extends readonly string[], +> = Columns extends readonly [ + infer Head extends string, + ...infer Tail extends readonly string[], +] + ? [ + TableDef['columns'][Head]['typeBuilder']['type'], + ..._IndexVal, + ] + : []; /** * A helper type to define the bounds for scanning an index. @@ -115,7 +124,9 @@ export type IndexScanRangeBounds< * It supports omitting trailing columns if the index is multi-column. * This version only allows omitting the array if the index is single-column to avoid ambiguity. */ -type _IndexScanRangeBounds = Columns extends [infer Term] +type _IndexScanRangeBounds = Columns extends [ + infer Term, +] ? Term | Range : _IndexScanRangeBoundsCase; @@ -124,12 +135,10 @@ type _IndexScanRangeBounds = Columns extends [infer Term] * This type allows for specifying exact values or ranges for each column in the index. * It supports omitting trailing columns if the index is multi-column. */ -type _IndexScanRangeBoundsCase = Columns extends [ - ...infer Prefix, - infer Term, -] - ? [...Prefix, Term | Range] | _IndexScanRangeBounds - : never; +type _IndexScanRangeBoundsCase = + Columns extends [...infer Prefix, infer Term] + ? readonly [...Prefix, Term | Range] | _IndexScanRangeBounds + : never; /** * A helper type representing a column index definition. @@ -141,7 +150,7 @@ export type ColumnIndex< { name: Name; unique: ColumnIsUnique; - columns: [Name]; + columns: readonly [Name]; algorithm: 'btree' | 'direct'; } & (M extends { indexType: infer I extends NonNullable; diff --git a/crates/bindings-typescript/src/server/schema.test-d.ts b/crates/bindings-typescript/src/server/schema.test-d.ts new file mode 100644 index 00000000000..4e5eceefb7c --- /dev/null +++ b/crates/bindings-typescript/src/server/schema.test-d.ts @@ -0,0 +1,36 @@ +import { schema } from './schema'; +import { table } from './table'; +import t from './type_builders'; + +const person = table( + { + name: 'person', + indexes: [ + { + name: 'id_name_idx', + algorithm: 'btree', + columns: ['id', 'name'] as const, + }, + { + name: 'name_idx', + algorithm: 'btree', + columns: ['name'] as const, + }, + ], + }, + { + id: t.u32().primaryKey(), + name: t.string(), + married: t.bool(), + id2: t.identity(), + age: t.u32(), + age2: t.u16(), + } +); + +const spacetimedb = schema(person); + +spacetimedb.init(ctx => { + ctx.db.person.id_name_idx.filter(1); + ctx.db.person.id_name_idx.filter([1, "aname"]); +}); diff --git a/crates/bindings-typescript/src/server/table.ts b/crates/bindings-typescript/src/server/table.ts index 8c78488433b..f029f9bb9dc 100644 --- a/crates/bindings-typescript/src/server/table.ts +++ b/crates/bindings-typescript/src/server/table.ts @@ -54,26 +54,54 @@ type CoerceArray[]> = X; export type UntypedTableDef = { name: string; columns: Record>>; - indexes: IndexOpts[]; + indexes: readonly IndexOpts[]; }; /** * A type representing the indexes defined on a table. */ export type TableIndexes = { - [k in keyof TableDef['columns'] & string]: ColumnIndex< - k, - TableDef['columns'][k]['columnMetadata'] + [K in keyof TableDef['columns'] & string as ColumnIndex< + K, + TableDef['columns'][K]['columnMetadata'] + > extends never + ? never + : K]: ColumnIndex< + K, + TableDef['columns'][K]['columnMetadata'] >; } & { - [I in TableDef['indexes'][number] as I['name'] & {}]: { - name: I['name']; - unique: AllUnique>; - algorithm: Lowercase; - columns: IndexColumns; - }; + [I in TableDef['indexes'][number] as I['name'] & {}]: TableIndexFromDef< + TableDef, + I + >; }; +type TableIndexFromDef< + TableDef extends UntypedTableDef, + I extends IndexOpts, +> = + NormalizeIndexColumns extends infer Cols extends ReadonlyArray< + keyof TableDef['columns'] & string + > + ? { + name: I['name']; + unique: AllUnique; + algorithm: Lowercase; + columns: Cols; + } + : never; + +type NormalizeIndexColumns< + TableDef extends UntypedTableDef, + I extends IndexOpts, +> = + IndexColumns extends ReadonlyArray< + infer Col extends keyof TableDef['columns'] & string + > + ? IndexColumns + : never; + /** * Options for configuring a database table. * - `name`: The name of the table. From 395962b0b9e23ac6f3ac70d73d62d9bb0d1687e6 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Thu, 6 Nov 2025 09:40:32 -0800 Subject: [PATCH 02/21] Add an assertion for unindexed columns. --- crates/bindings-typescript/src/server/schema.test-d.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/bindings-typescript/src/server/schema.test-d.ts b/crates/bindings-typescript/src/server/schema.test-d.ts index 4e5eceefb7c..b1eada78f7d 100644 --- a/crates/bindings-typescript/src/server/schema.test-d.ts +++ b/crates/bindings-typescript/src/server/schema.test-d.ts @@ -33,4 +33,9 @@ const spacetimedb = schema(person); spacetimedb.init(ctx => { ctx.db.person.id_name_idx.filter(1); ctx.db.person.id_name_idx.filter([1, "aname"]); + + // @ts-expect-error id2 is not indexed, so this should not exist at all. + ctx.db.person.id2; + + ctx.db.person.id.find(2); }); From 6d04c4d6108cb52b763ad88103e53e8af7e80432 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Thu, 6 Nov 2025 10:14:35 -0800 Subject: [PATCH 03/21] Fix lint issues. --- crates/bindings-typescript/src/server/indexes.ts | 2 +- .../bindings-typescript/src/server/schema.test-d.ts | 11 +++++++++-- crates/bindings-typescript/src/server/table.ts | 9 ++------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/bindings-typescript/src/server/indexes.ts b/crates/bindings-typescript/src/server/indexes.ts index 20dd0a6cd21..e126d48e136 100644 --- a/crates/bindings-typescript/src/server/indexes.ts +++ b/crates/bindings-typescript/src/server/indexes.ts @@ -29,7 +29,7 @@ export type UntypedIndex = { * A helper type to extract the column names from an index definition. */ export type IndexColumns> = I extends { - columns: readonly (infer Names extends string)[]; + columns: readonly string[]; } ? readonly [...I['columns']] : I extends { column: infer Name extends string } diff --git a/crates/bindings-typescript/src/server/schema.test-d.ts b/crates/bindings-typescript/src/server/schema.test-d.ts index b1eada78f7d..8ffc6f50656 100644 --- a/crates/bindings-typescript/src/server/schema.test-d.ts +++ b/crates/bindings-typescript/src/server/schema.test-d.ts @@ -11,6 +11,11 @@ const person = table( algorithm: 'btree', columns: ['id', 'name'] as const, }, + { + name: 'id_name2_idx', + algorithm: 'btree', + columns: ['id', 'name2'] as const, + }, { name: 'name_idx', algorithm: 'btree', @@ -21,6 +26,7 @@ const person = table( { id: t.u32().primaryKey(), name: t.string(), + name2: t.string().unique(), married: t.bool(), id2: t.identity(), age: t.u32(), @@ -32,10 +38,11 @@ const spacetimedb = schema(person); spacetimedb.init(ctx => { ctx.db.person.id_name_idx.filter(1); - ctx.db.person.id_name_idx.filter([1, "aname"]); + ctx.db.person.id_name_idx.filter([1, 'aname']); + // ctx.db.person.id_name2_idx.find // @ts-expect-error id2 is not indexed, so this should not exist at all. - ctx.db.person.id2; + const _id2 = ctx.db.person.id2; ctx.db.person.id.find(2); }); diff --git a/crates/bindings-typescript/src/server/table.ts b/crates/bindings-typescript/src/server/table.ts index f029f9bb9dc..36dbb8981e3 100644 --- a/crates/bindings-typescript/src/server/table.ts +++ b/crates/bindings-typescript/src/server/table.ts @@ -66,10 +66,7 @@ export type TableIndexes = { TableDef['columns'][K]['columnMetadata'] > extends never ? never - : K]: ColumnIndex< - K, - TableDef['columns'][K]['columnMetadata'] - >; + : K]: ColumnIndex; } & { [I in TableDef['indexes'][number] as I['name'] & {}]: TableIndexFromDef< TableDef, @@ -96,9 +93,7 @@ type NormalizeIndexColumns< TableDef extends UntypedTableDef, I extends IndexOpts, > = - IndexColumns extends ReadonlyArray< - infer Col extends keyof TableDef['columns'] & string - > + IndexColumns extends ReadonlyArray ? IndexColumns : never; From e9d83a7c3e23770cb335f0efc9c81a52aef2089b Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Wed, 5 Nov 2025 16:28:13 -0800 Subject: [PATCH 04/21] WIP on rewrite. RowExpr looks right, indexes aren't working yet --- .../src/server/query.test-d.ts | 52 +++++++++ .../bindings-typescript/src/server/query.ts | 100 ++++++++++++++++++ .../src/server/type_builders.ts | 9 ++ 3 files changed, 161 insertions(+) create mode 100644 crates/bindings-typescript/src/server/query.test-d.ts create mode 100644 crates/bindings-typescript/src/server/query.ts diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts new file mode 100644 index 00000000000..27ad0f5146f --- /dev/null +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -0,0 +1,52 @@ +import type { U32 } from '../lib/autogen/algebraic_type_variants'; +import type { Indexes } from './indexes'; +import type { ColumnExpr, IndexExprs, IndexNameUnion, RowExpr } from './query'; +import { schema } from './schema'; +import { table, type TableIndexes } from './table'; +import t from './type_builders'; + +const person = table( + { + name: 'person', + indexes: [ + { + name: 'id_name_idx', + algorithm: 'btree', + columns: ['id', 'name'] as const, + }, + { + name: 'name_idx', + algorithm: 'btree', + columns: ['name'] as const, + }, + ] as const, + }, + { + id: t.u32().primaryKey(), + name: t.string(), + married: t.bool(), + id2: t.identity(), + age: t.u32(), + age2: t.u16(), + } +); + +const spacetimedb = schema(person); + +type PersonDef = { + name: typeof person.tableName; + columns: typeof person.rowType.row; + indexes: typeof person.idxs; +}; + +declare const row: RowExpr; +const x: U32 = row.age.spacetimeType; + +declare const idxs: IndexExprs; + +declare const xyz: IndexNameUnion; + +declare const xx: TableIndexes; + +declare const idxs2: Indexes>; +idxs2.name_idx; diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts new file mode 100644 index 00000000000..115d27cd864 --- /dev/null +++ b/crates/bindings-typescript/src/server/query.ts @@ -0,0 +1,100 @@ +import type { Index, IndexOpts, UntypedIndex } from './indexes'; +import type { RowType, TableIndexes, TableSchema } from './table'; +import type { + ColumnBuilder, + ColumnMetadata, + InferSpacetimeTypeOfRow, + InferSpacetimeTypeOfTypeBuilder, + TypeBuilder, +} from './type_builders'; +import type { CollapseTuple } from './type_util'; + +// TODO: Just use UntypedTableDef if they end up being the same. +export type TypedTableDef = { + name: string; + columns: Record>>; + indexes: readonly IndexOpts[]; +}; + +type TableSchemaAsTableDef< + TSchema extends TableSchema, +> = { + name: TSchema['tableName']; + columns: TSchema['rowType']['row']; + indexes: TSchema['idxs']; +}; + +export type ColumnExpr< + TableDef extends TypedTableDef, + ColumnName extends ColumnNames, +> = Readonly<{ + type: 'column'; + column: ColumnName; + table: TableDef['name']; + // This is here as a phantom type. You can pull it back with NonNullable<> + tsValueType?: RowType[ColumnName]; + /** + * docs + */ + spacetimeType: InferSpacetimeTypeOfColumn; +}>; + +/** + * Helper to get the spacetime type of a column. + */ +type InferSpacetimeTypeOfColumn< + TableDef extends TypedTableDef, + ColumnName extends ColumnNames, +> = + TableDef['columns'][ColumnName]['typeBuilder'] extends TypeBuilder< + any, + infer U + > + ? U + : never; + +type ColumnNames = keyof RowType & + string; + +/** + * Acts as a row when writing filters for queries. It is a way to get column references. + */ +export type RowExpr = { + readonly [C in ColumnNames]: ColumnExpr; +}; + +type IndexNames = TableIndexes; + +export type IndexNameUnion = Extract< + keyof TableIndexes, + string +>; + +/* +export type IndexExpr = CollapseTuple<_IndexVal[I]['columns']; +*/ + +export type IndexExprs = { + readonly [I in IndexNameUnion]: IndexExpr< + TableDef, + TableIndexes[I] + >; +}; + +/** + * A helper type to extract the types of the columns that make up an index. + */ +type _IndexVal = { + [i in keyof Columns]: TableDef['columns'][Columns[i]]['typeBuilder'] extends TypeBuilder< + any, + infer U + > + ? U + : never; +}; + +export type IndexExpr< + TableDef extends TypedTableDef, + I extends UntypedIndex, +> = CollapseTuple<_IndexVal>; diff --git a/crates/bindings-typescript/src/server/type_builders.ts b/crates/bindings-typescript/src/server/type_builders.ts index ab440838003..dc5fea49a30 100644 --- a/crates/bindings-typescript/src/server/type_builders.ts +++ b/crates/bindings-typescript/src/server/type_builders.ts @@ -41,6 +41,15 @@ export type InferTypeOfRow = { [K in keyof T & string]: InferTypeOfTypeBuilder>; }; +/** + * Helper type to extract the type of a row from an object. + */ +export type InferSpacetimeTypeOfRow = { + [K in keyof T & string]: InferSpacetimeTypeOfTypeBuilder< + CollapseColumn + >; +}; + /** * Helper type to extract the Spacetime type from a row object. */ From e4bcc8b1972c85e5f34964f89f771c2fc300e40a Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Thu, 6 Nov 2025 08:21:53 -0800 Subject: [PATCH 05/21] Make sure we only autocomplete real indexes --- .../bindings-typescript/src/server/query.ts | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 115d27cd864..d52e506c7ea 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -63,6 +63,7 @@ export type RowExpr = { readonly [C in ColumnNames]: ColumnExpr; }; +/* type IndexNames = TableIndexes; export type IndexNameUnion = Extract< @@ -75,26 +76,26 @@ export type IndexExpr = CollapseTuple<_IndexVal[I]['columns']; */ -export type IndexExprs = { - readonly [I in IndexNameUnion]: IndexExpr< - TableDef, - TableIndexes[I] - >; -}; +// export type IndexExprs = { +// readonly [I in IndexNameUnion]: IndexExpr< +// TableDef, +// TableIndexes[I] +// >; +// }; -/** - * A helper type to extract the types of the columns that make up an index. - */ -type _IndexVal = { - [i in keyof Columns]: TableDef['columns'][Columns[i]]['typeBuilder'] extends TypeBuilder< - any, - infer U - > - ? U - : never; -}; +// /** +// * A helper type to extract the types of the columns that make up an index. +// */ +// type _IndexVal = { +// [i in keyof Columns]: TableDef['columns'][Columns[i]]['typeBuilder'] extends TypeBuilder< +// any, +// infer U +// > +// ? U +// : never; +// }; -export type IndexExpr< - TableDef extends TypedTableDef, - I extends UntypedIndex, -> = CollapseTuple<_IndexVal>; +// export type IndexExpr< +// TableDef extends TypedTableDef, +// I extends UntypedIndex, +// > = CollapseTuple<_IndexVal>; From 1a8e83b5d4adb9789b4ed961418c7a1e82f9c030 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Thu, 6 Nov 2025 08:22:27 -0800 Subject: [PATCH 06/21] WIP trying to fix index types --- .../src/server/query.test-d.ts | 27 ++++++++++++++++--- .../bindings-typescript/src/server/query.ts | 15 +++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts index 27ad0f5146f..a018df5b92b 100644 --- a/crates/bindings-typescript/src/server/query.test-d.ts +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -1,6 +1,6 @@ import type { U32 } from '../lib/autogen/algebraic_type_variants'; import type { Indexes } from './indexes'; -import type { ColumnExpr, IndexExprs, IndexNameUnion, RowExpr } from './query'; +import type { ColumnExpr, IndexExprs, IndexNameUnion, RowExpr, TableSchemaAsTableDef } from './query'; import { schema } from './schema'; import { table, type TableIndexes } from './table'; import t from './type_builders'; @@ -33,20 +33,41 @@ const person = table( const spacetimedb = schema(person); +/* type PersonDef = { name: typeof person.tableName; columns: typeof person.rowType.row; indexes: typeof person.idxs; }; +*/ -declare const row: RowExpr; +const tableDef = { + name: person.tableName, + columns: person.rowType.row, + indexes: person.idxs, // keep the typed, literal tuples here +} as TableSchemaAsTableDef; + +type PersonDef = typeof tableDef; + +declare const row: RowExpr; const x: U32 = row.age.spacetimeType; declare const idxs: IndexExprs; +idxs.name_idx declare const xyz: IndexNameUnion; declare const xx: TableIndexes; +//idxs. declare const idxs2: Indexes>; -idxs2.name_idx; +idxs2.id_name_idx +//idxs2. + +spacetimedb.init((ctx) => { + // ctx.db.person. + //ctx.db.person. + //ctx.db + // ctx.db.person.id_name_idx.find + ctx.db.person.id +}) diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index d52e506c7ea..679f4264ef8 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -3,7 +3,6 @@ import type { RowType, TableIndexes, TableSchema } from './table'; import type { ColumnBuilder, ColumnMetadata, - InferSpacetimeTypeOfRow, InferSpacetimeTypeOfTypeBuilder, TypeBuilder, } from './type_builders'; @@ -16,7 +15,7 @@ export type TypedTableDef = { indexes: readonly IndexOpts[]; }; -type TableSchemaAsTableDef< +export type TableSchemaAsTableDef< TSchema extends TableSchema, > = { name: TSchema['tableName']; @@ -95,7 +94,19 @@ I extends IndexNameUnion = CollapseTuple<_IndexVal, // > = CollapseTuple<_IndexVal>; +||||||| parent of 6df213d13 (WIP trying to fix index types) +export type IndexExpr< + TableDef extends TypedTableDef, + I extends UntypedIndex, +> = CollapseTuple<_IndexVal>; +======= +export type IndexExpr< + TableDef extends TypedTableDef, + I extends UntypedIndex, +> = _IndexVal; +>>>>>>> 6df213d13 (WIP trying to fix index types) From 39bbd45f14ec9dc5db88100a0c20a1088036b554 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Thu, 6 Nov 2025 14:20:37 -0800 Subject: [PATCH 07/21] remove index stuff for now --- .../src/server/query.test-d.ts | 24 ++++----- .../bindings-typescript/src/server/query.ts | 51 +------------------ 2 files changed, 10 insertions(+), 65 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts index a018df5b92b..5f9b80a9d50 100644 --- a/crates/bindings-typescript/src/server/query.test-d.ts +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -1,6 +1,10 @@ import type { U32 } from '../lib/autogen/algebraic_type_variants'; import type { Indexes } from './indexes'; -import type { ColumnExpr, IndexExprs, IndexNameUnion, RowExpr, TableSchemaAsTableDef } from './query'; +import type { + ColumnExpr, + RowExpr, + TableSchemaAsTableDef, +} from './query'; import { schema } from './schema'; import { table, type TableIndexes } from './table'; import t from './type_builders'; @@ -44,7 +48,7 @@ type PersonDef = { const tableDef = { name: person.tableName, columns: person.rowType.row, - indexes: person.idxs, // keep the typed, literal tuples here + indexes: person.idxs, // keep the typed, literal tuples here } as TableSchemaAsTableDef; type PersonDef = typeof tableDef; @@ -52,22 +56,12 @@ type PersonDef = typeof tableDef; declare const row: RowExpr; const x: U32 = row.age.spacetimeType; -declare const idxs: IndexExprs; -idxs.name_idx - -declare const xyz: IndexNameUnion; - -declare const xx: TableIndexes; - -//idxs. -declare const idxs2: Indexes>; -idxs2.id_name_idx //idxs2. -spacetimedb.init((ctx) => { +spacetimedb.init(ctx => { // ctx.db.person. //ctx.db.person. //ctx.db // ctx.db.person.id_name_idx.find - ctx.db.person.id -}) + ctx.db.person.id; +}); diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 679f4264ef8..7b345f3e15a 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -60,53 +60,4 @@ type ColumnNames = keyof RowType & */ export type RowExpr = { readonly [C in ColumnNames]: ColumnExpr; -}; - -/* -type IndexNames = TableIndexes; - -export type IndexNameUnion = Extract< - keyof TableIndexes, - string ->; - -/* -export type IndexExpr = CollapseTuple<_IndexVal[I]['columns']; -*/ - -// export type IndexExprs = { -// readonly [I in IndexNameUnion]: IndexExpr< -// TableDef, -// TableIndexes[I] -// >; -// }; - -// /** -// * A helper type to extract the types of the columns that make up an index. -// */ -// type _IndexVal = { -// [i in keyof Columns]: TableDef['columns'][Columns[i]]['typeBuilder'] extends TypeBuilder< -// any, -// infer U -// > -// ? U -// : never; -// }; - -<<<<<<< HEAD -// export type IndexExpr< -// TableDef extends TypedTableDef, -// I extends UntypedIndex, -// > = CollapseTuple<_IndexVal>; -||||||| parent of 6df213d13 (WIP trying to fix index types) -export type IndexExpr< - TableDef extends TypedTableDef, - I extends UntypedIndex, -> = CollapseTuple<_IndexVal>; -======= -export type IndexExpr< - TableDef extends TypedTableDef, - I extends UntypedIndex, -> = _IndexVal; ->>>>>>> 6df213d13 (WIP trying to fix index types) +}; \ No newline at end of file From 5dc7764738448db4404898696d5223bb35692eae Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Thu, 6 Nov 2025 15:36:16 -0800 Subject: [PATCH 08/21] qb --- crates/bindings-typescript/src/server/query.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 7b345f3e15a..35e737ac8a6 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -1,4 +1,5 @@ import type { Index, IndexOpts, UntypedIndex } from './indexes'; +import type { UntypedSchemaDef } from './schema'; import type { RowType, TableIndexes, TableSchema } from './table'; import type { ColumnBuilder, @@ -8,6 +9,19 @@ import type { } from './type_builders'; import type { CollapseTuple } from './type_util'; +export type QueryBuilder = { + readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef; +}; + +/** + * A type representing a + */ +export type TableRef = { + type: "table", + row: RowExpr
; + tableName: Table['name'] +}; + // TODO: Just use UntypedTableDef if they end up being the same. export type TypedTableDef = { name: string; From 0da157a07455e2e26c6f54463c7ca479311df8d0 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Fri, 7 Nov 2025 14:06:57 -0800 Subject: [PATCH 09/21] A version with a lot of different attempts at join enforcement --- .../src/server/query.test-d.ts | 175 ++++++++++- .../bindings-typescript/src/server/query.ts | 277 +++++++++++++++++- .../src/server/reducers.ts | 2 + .../bindings-typescript/src/server/runtime.ts | 2 + .../src/server/schema.test-d.ts | 3 + 5 files changed, 445 insertions(+), 14 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts index 5f9b80a9d50..3706c9ff4e0 100644 --- a/crates/bindings-typescript/src/server/query.test-d.ts +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -1,9 +1,13 @@ import type { U32 } from '../lib/autogen/algebraic_type_variants'; -import type { Indexes } from './indexes'; -import type { - ColumnExpr, - RowExpr, - TableSchemaAsTableDef, +import type { Indexes, UniqueIndex } from './indexes'; +import { + eq, + on, + literal, + type ColumnExpr, + type RowExpr, + type TableNames, + type TableSchemaAsTableDef, } from './query'; import { schema } from './schema'; import { table, type TableIndexes } from './table'; @@ -35,7 +39,19 @@ const person = table( } ); -const spacetimedb = schema(person); +const order = table( + { + name: 'order', + }, + { + order_id: t.u32().primaryKey(), + item_name: t.string(), + person_id: t.u32().index(), + } +); + + +const spacetimedb = schema([person, order]); /* type PersonDef = { @@ -53,9 +69,19 @@ const tableDef = { type PersonDef = typeof tableDef; -declare const row: RowExpr; +declare const row: RowExpr; + const x: U32 = row.age.spacetimeType; +type SchemaTableNames = TableNames<(typeof spacetimedb)['schemaType']>; +const y: SchemaTableNames = 'person'; + +const orderDef = { + name: order.tableName, + columns: order.rowType.row, + indexes: order.idxs, +}; + //idxs2. spacetimedb.init(ctx => { @@ -63,5 +89,136 @@ spacetimedb.init(ctx => { //ctx.db.person. //ctx.db // ctx.db.person.id_name_idx.find - ctx.db.person.id; -}); + + // Downside of the string approach for columns is that if I hover, I don't get the type information. + + // ctx.queryBuilder. + // .filter + // col("age") + + + //ctx.query.from('person') + ctx.queryBuilder + .query('person') + // .query('person') + .filter(row => eq(row['age'], literal(20))) + // .filter(row => eq(row.age, literal(20))) + .join('order', { + leftColumns: ['age'], + rightColumns: ['person_id'], + }); + + ctx.queryBuilder + .query('person') + .filter(row => eq(row.age, literal(20))) + .join('order', { + leftColumns: ['id'], + rightColumns: ['person_id'], + }); + + ctx.queryBuilder + .query('person') + .filter(row => eq(row.age, literal(20))) + .join4('order', { + leftColumns: p => [p.id, p.age] as const, + rightColumns: o => [o.person_id] as const, + }); + + // ctx.queryBuilder + // .query('person') + // .filter(row => eq(row.age, literal(20))) + // .join5('order', { + // leftColumns: p => [p.id], + // rightColumns: o => [o.person_id], + // }); + ctx.queryBuilder + .query('person') + .filter(row => eq(row.age, literal(20))) + .join5('order', { + leftColumns: ["id"] as const, + rightColumns: ["person_id", "item_name"] as const + }); + + ctx.queryBuilder + .query('person') + .filter(row => eq(row.age, literal(20))) + .join7('order', { + leftColumns: ["id"] as const, + rightColumns: ["person_id", "item_name"] as const + }); + + ctx.queryBuilder + .query('person') + .filter(row => eq(row.age, literal(20))) + .join8('order', { + leftColumns: ["id"] as const, + rightColumns: ["person_id"] as const, + // rightColumns: ["person_id", "item_name"] as const, + }); + + ctx.queryBuilder + .query('person') + .filter(row => eq(row.age, literal(20))) + .join9('order', { + leftColumns: ["id"] as const, + rightColumns: ["person_id", "item_name"] as const, + }); + + ctx.queryBuilder + .query('person') + .filter(row => eq(row.age, literal(20))) + .join9('order', on(["id", "name"], ["person_id"]) + ); + + + // const aQuery = ctx.queryBuilder.query('person') + // .filter(row => eq(row.age, literal(20))) + // // Get the context from somewhere else. + + // let q = ctx.query["foobar"] + // .join(ctx.orders, row => eq(row.id, ctx.orders.user_id)) + // // .join(row => eq(row.id, ctx.orders.user_id)) + + // ctx.query['foobar'] + // .filter(row => eq(row.id, literal(5))) + // .join(ctx.query.bar.filter(row => eq(row.id, 20)), (left, right) => eq(left.id, right.id)) + + // ctx.queryBuilder.foo + // ctx.query['foobar'] + // .filter(row => eq(row.id, literal(5))) + // // .join(ts => ts.bar) + // // .join(|x| x.table) + // .join(ctx.) + // // .join(ctx.bar, (left, right) => eq(left.id, right.id)) + + + // ctx.queryBuilder.query('person') + // .filter(row => eq(row.age, literal(20))) + // .join(aQuery); + // //TableName | Query + // ctx.queryBuilder.query('person').join(ctx.query.person) + + // ctx.queryBuilder.query('person').join(ts => ts.query, { + + // }) + + + // /* + // .join((left, right) => eq(left.id, right.person_id)) + // ctx.query.myLeftTable.join(ctx.query.myRightTable) + // */ + // // ctx.queryBuilder.query('person').join(_.query) + // // ctx.queryBuilder.query('person').join((tables) => tables.query) + // // ctx.queryBuilder.query('person').join() + // ctx.queryBuilder.query('person').join2('order', { + // leftColumns: p => [p.id], + // rightColumns: o => [o.person_id], + // }); + + // /* + // ctx.queryBuilder.query('person').join3('order', { + // leftColumns: p => [p.id], + // rightColumns: o => [o.person_id, o.item_name], + // }); + // */ + }); diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 35e737ac8a6..eb90c324043 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -9,17 +9,232 @@ import type { } from './type_builders'; import type { CollapseTuple } from './type_util'; +/** + * Helper to get the set of table names. + */ +export type TableNames = + SchemaDef['tables'][number]['name'] & string; + +/** helper: pick the table def object from the schema by its name */ +export type TableDefByName< + SchemaDef extends UntypedSchemaDef, + Name extends TableNames, +> = Extract; + export type QueryBuilder = { - readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef; + // readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef; + query>( + table: Name + ): TableScan>; + //query(table: TableNames): TableScan
+}; + +export function fakeQueryBuilder< + SchemaDef extends UntypedSchemaDef, +>(): QueryBuilder { + throw 'unimplemented'; +} + +// A static list of column names for a table. +// type ColumnList< +// SchemaDef extends UntypedSchemaDef, +// TableName extends TableNames, +// > = readonly ColumnNames>[]; + +export type ColumnList< + SchemaDef extends UntypedSchemaDef, + Table extends TableNames, + T extends readonly ColumnNames< + TableDefByName + >[] = readonly ColumnNames>[], +> = T; + +export type JoinCondition< + SchemaDef extends UntypedSchemaDef, + LeftTable extends TableNames, + RightTable extends TableNames, +> = { + leftColumns: ColumnList; + rightColumns: ColumnList; +}; + +type EqualLength< + A extends readonly any[], + B extends readonly any[], +> = A['length'] extends B['length'] + ? B['length'] extends A['length'] + ? A + : never + : never; + +export type JoinCondition7< + SchemaDef extends UntypedSchemaDef, + LeftTable extends TableNames, + RightTable extends TableNames, + LCols extends readonly ColumnNames< + TableDefByName + >[] = readonly ColumnNames>[], + RCols extends readonly ColumnNames< + TableDefByName + >[] = readonly ColumnNames>[], +> = + EqualLength extends never + ? never + : { + leftColumns: LCols; + rightColumns: RCols; + }; + +export type JoinCondition5< + SchemaDef extends UntypedSchemaDef, + LeftTable extends TableNames, + RightTable extends TableNames, + LCols extends ColumnList = ColumnList< + SchemaDef, + LeftTable + >, + RCols extends ColumnList = ColumnList< + SchemaDef, + RightTable + >, +> = + HasEqualLength extends never + ? never + : { + leftColumns: LCols; + rightColumns: RCols; + }; + +type ColumnExprList< + SchemaDef extends UntypedSchemaDef, + TableName extends TableNames, +> = readonly AnyColumnExpr>[]; + +type ColumnExprListExtractor< + SchemaDef extends UntypedSchemaDef, + TableName extends TableNames, +> = ( + row: RowExpr> +) => ColumnExprList; +// type ColumnListExtractor>> = ReadonlyArray, + RightTable extends TableNames, +> = { + leftColumns: ColumnExprListExtractor; + rightColumns: ColumnExprListExtractor; +}; + +// type Zip = +// A extends [infer AH, ...infer AT] +// ? B extends [infer BH, ...infer BT] +// ? [[AH, BH], ...Zip] +// : never +// : B extends [] ? [] : never; + +type Zip< + A extends readonly any[], + B extends readonly any[], +> = A extends readonly [infer AH, ...infer AT] + ? B extends readonly [infer BH, ...infer BT] + ? [[AH, BH], ...Zip] + : never + : B extends readonly [] + ? [] + : never; + +export type JoinCondition3< + SchemaDef extends UntypedSchemaDef, + LeftTable extends TableNames, + RightTable extends TableNames, + L extends ColumnExprList, + R extends ColumnExprList, +> = + Zip extends never + ? never + : { + leftColumns: (row: RowExpr>) => L; + rightColumns: ( + row: RowExpr> + ) => R; + }; + +/** Helper type to check if two tuples/arrays have equal length */ +type HasEqualLength< + T extends readonly any[], + U extends readonly any[], +> = T extends { length: infer L } + ? U extends { length: L } + ? true + : false + : false; + +export type JoinIsValid> = + HasEqualLength, ReturnType>; +export type RestrictedJoin> = + HasEqualLength< + ReturnType, + ReturnType + > extends true + ? T + : never; + +type SameLen< + A extends readonly any[], + B extends readonly any[], +> = A['length'] extends B['length'] + ? B['length'] extends A['length'] + ? true + : never + : never; + +// ───────────────────────────────────────────────────────────── +// Helper that *preserves* tuple literal types and enforces length +export function on( + leftColumns: LC, + rightColumns: RC & (SameLen extends never ? never : unknown) +) { + return { leftColumns, rightColumns } as const; +} + +// ───────────────────────────────────────────────────────────── +// JoinCondition type (optional, but nice to export) +export type JoinCondition9< + SD extends UntypedSchemaDef, + LeftTable extends TableNames, + RightTable extends TableNames, + LC extends readonly ColumnNames>[], + RC extends readonly ColumnNames>[], +> = { + leftColumns: LC; + rightColumns: RC; }; +export class TableScan< + SchemaDef extends UntypedSchemaDef, + TableDef extends TypedTableDef, +> { + // readonly filters: readonly BooleanExpr[]; + + filter( + predicate: (row: RowExpr) => BooleanExpr + ): TableScan { + throw 'unimplemented'; + } + + toSql(): string { + throw 'unimplemented'; + } +} + /** - * A type representing a + * A type representing a */ export type TableRef
= { - type: "table", + type: 'table'; row: RowExpr
; - tableName: Table['name'] + tableName: Table['name']; }; // TODO: Just use UntypedTableDef if they end up being the same. @@ -69,9 +284,61 @@ type InferSpacetimeTypeOfColumn< type ColumnNames = keyof RowType & string; +type AnyColumnExpr
= { + [C in ColumnNames
]: ColumnExpr; +}[ColumnNames
]; /** * Acts as a row when writing filters for queries. It is a way to get column references. */ export type RowExpr = { readonly [C in ColumnNames]: ColumnExpr; -}; \ No newline at end of file +}; + +/** + * Union of ColumnExprs from Table whose spacetimeType is compatible with Value + * (produces a union of ColumnExpr for matching columns). + */ +export type ColumnExprForValue
= { + [C in ColumnNames
]: InferSpacetimeTypeOfColumn extends Value + ? ColumnExpr + : never; +}[ColumnNames
]; + +export type ValueExpr = + | LiteralExpr + | ColumnExprForValue; + +type LiteralExpr = { + type: 'literal'; + value: Value; +}; + +type BooleanExpr
= { + type: 'eq'; + left: ValueExpr; + right: ValueExpr; +}; + +export function eq
( + left: ValueExpr, + right: ValueExpr +): BooleanExpr
{ + const lk = 'type' in left && left.type === 'literal'; + const rk = 'type' in right && right.type === 'literal'; + if (lk && !rk) { + return { + type: 'eq', + left: right, + right: left, + }; + } + return { + type: 'eq', + left, + right, + }; +} + +export function literal(value: Value): LiteralExpr { + return { type: 'literal', value }; +} diff --git a/crates/bindings-typescript/src/server/reducers.ts b/crates/bindings-typescript/src/server/reducers.ts index 0735cc004c1..26b530f6472 100644 --- a/crates/bindings-typescript/src/server/reducers.ts +++ b/crates/bindings-typescript/src/server/reducers.ts @@ -4,6 +4,7 @@ import type RawReducerDefV9 from '../lib/autogen/raw_reducer_def_v_9_type'; import type { ConnectionId } from '../lib/connection_id'; import type { Identity } from '../lib/identity'; import type { Timestamp } from '../lib/timestamp'; +import type { QueryBuilder } from './query'; import { MODULE_DEF, type UntypedSchemaDef } from './schema'; import type { Table } from './table'; import type { @@ -116,6 +117,7 @@ export type ReducerCtx = Readonly<{ timestamp: Timestamp; connectionId: ConnectionId | null; db: DbView; + queryBuilder: QueryBuilder; senderAuth: AuthCtx; }>; diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index e9d1ecff775..3ef0dc129bb 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -29,6 +29,7 @@ import { MODULE_DEF } from './schema'; import * as _syscalls from 'spacetime:sys@1.0'; import type { u16, u32, ModuleHooks } from 'spacetime:sys@1.0'; +import { fakeQueryBuilder } from './query'; const { freeze } = Object; @@ -196,6 +197,7 @@ export const hooks: ModuleHooks = { timestamp: new Timestamp(timestamp), connectionId: ConnectionId.nullIfZero(new ConnectionId(connId)), db: getDbView(), + queryBuilder: fakeQueryBuilder(), senderAuth: AuthCtxImpl.fromSystemTables( ConnectionId.nullIfZero(new ConnectionId(connId)), senderIdentity diff --git a/crates/bindings-typescript/src/server/schema.test-d.ts b/crates/bindings-typescript/src/server/schema.test-d.ts index 8ffc6f50656..3dff7811ef5 100644 --- a/crates/bindings-typescript/src/server/schema.test-d.ts +++ b/crates/bindings-typescript/src/server/schema.test-d.ts @@ -29,6 +29,7 @@ const person = table( name2: t.string().unique(), married: t.bool(), id2: t.identity(), + count: t.string().unique(), age: t.u32(), age2: t.u16(), } @@ -45,4 +46,6 @@ spacetimedb.init(ctx => { const _id2 = ctx.db.person.id2; ctx.db.person.id.find(2); + ctx.db.person.count.find('idk'); + ctx.db.person.count(); }); From 1633a013a872a1b5de046fbe6a111130b7a78a23 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Fri, 7 Nov 2025 16:26:25 -0800 Subject: [PATCH 10/21] Add TableDef --- .../src/server/query.test-d.ts | 129 +--------- .../bindings-typescript/src/server/query.ts | 224 +++++------------- 2 files changed, 61 insertions(+), 292 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts index 3706c9ff4e0..c29a8f0b4d0 100644 --- a/crates/bindings-typescript/src/server/query.test-d.ts +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -2,7 +2,6 @@ import type { U32 } from '../lib/autogen/algebraic_type_variants'; import type { Indexes, UniqueIndex } from './indexes'; import { eq, - on, literal, type ColumnExpr, type RowExpr, @@ -50,7 +49,6 @@ const order = table( } ); - const spacetimedb = schema([person, order]); /* @@ -91,134 +89,15 @@ spacetimedb.init(ctx => { // ctx.db.person.id_name_idx.find // Downside of the string approach for columns is that if I hover, I don't get the type information. - + // ctx.queryBuilder. // .filter // col("age") - //ctx.query.from('person') ctx.queryBuilder .query('person') // .query('person') - .filter(row => eq(row['age'], literal(20))) - // .filter(row => eq(row.age, literal(20))) - .join('order', { - leftColumns: ['age'], - rightColumns: ['person_id'], - }); - - ctx.queryBuilder - .query('person') - .filter(row => eq(row.age, literal(20))) - .join('order', { - leftColumns: ['id'], - rightColumns: ['person_id'], - }); - - ctx.queryBuilder - .query('person') - .filter(row => eq(row.age, literal(20))) - .join4('order', { - leftColumns: p => [p.id, p.age] as const, - rightColumns: o => [o.person_id] as const, - }); - - // ctx.queryBuilder - // .query('person') - // .filter(row => eq(row.age, literal(20))) - // .join5('order', { - // leftColumns: p => [p.id], - // rightColumns: o => [o.person_id], - // }); - ctx.queryBuilder - .query('person') - .filter(row => eq(row.age, literal(20))) - .join5('order', { - leftColumns: ["id"] as const, - rightColumns: ["person_id", "item_name"] as const - }); - - ctx.queryBuilder - .query('person') - .filter(row => eq(row.age, literal(20))) - .join7('order', { - leftColumns: ["id"] as const, - rightColumns: ["person_id", "item_name"] as const - }); - - ctx.queryBuilder - .query('person') - .filter(row => eq(row.age, literal(20))) - .join8('order', { - leftColumns: ["id"] as const, - rightColumns: ["person_id"] as const, - // rightColumns: ["person_id", "item_name"] as const, - }); - - ctx.queryBuilder - .query('person') - .filter(row => eq(row.age, literal(20))) - .join9('order', { - leftColumns: ["id"] as const, - rightColumns: ["person_id", "item_name"] as const, - }); - - ctx.queryBuilder - .query('person') - .filter(row => eq(row.age, literal(20))) - .join9('order', on(["id", "name"], ["person_id"]) - ); - - - // const aQuery = ctx.queryBuilder.query('person') - // .filter(row => eq(row.age, literal(20))) - // // Get the context from somewhere else. - - // let q = ctx.query["foobar"] - // .join(ctx.orders, row => eq(row.id, ctx.orders.user_id)) - // // .join(row => eq(row.id, ctx.orders.user_id)) - - // ctx.query['foobar'] - // .filter(row => eq(row.id, literal(5))) - // .join(ctx.query.bar.filter(row => eq(row.id, 20)), (left, right) => eq(left.id, right.id)) - - // ctx.queryBuilder.foo - // ctx.query['foobar'] - // .filter(row => eq(row.id, literal(5))) - // // .join(ts => ts.bar) - // // .join(|x| x.table) - // .join(ctx.) - // // .join(ctx.bar, (left, right) => eq(left.id, right.id)) - - - // ctx.queryBuilder.query('person') - // .filter(row => eq(row.age, literal(20))) - // .join(aQuery); - // //TableName | Query - // ctx.queryBuilder.query('person').join(ctx.query.person) - - // ctx.queryBuilder.query('person').join(ts => ts.query, { - - // }) - - - // /* - // .join((left, right) => eq(left.id, right.person_id)) - // ctx.query.myLeftTable.join(ctx.query.myRightTable) - // */ - // // ctx.queryBuilder.query('person').join(_.query) - // // ctx.queryBuilder.query('person').join((tables) => tables.query) - // // ctx.queryBuilder.query('person').join() - // ctx.queryBuilder.query('person').join2('order', { - // leftColumns: p => [p.id], - // rightColumns: o => [o.person_id], - // }); - - // /* - // ctx.queryBuilder.query('person').join3('order', { - // leftColumns: p => [p.id], - // rightColumns: o => [o.person_id, o.item_name], - // }); - // */ - }); + .filter(row => eq(row['age'], literal(20))); + // .filter(row => eq(row.age, literal(20))) +}); diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index eb90c324043..7c1d62f3484 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -22,11 +22,7 @@ export type TableDefByName< > = Extract; export type QueryBuilder = { - // readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef; - query>( - table: Name - ): TableScan>; - //query(table: TableNames): TableScan
+ readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef; }; export function fakeQueryBuilder< @@ -35,6 +31,60 @@ export function fakeQueryBuilder< throw 'unimplemented'; } +/** + * A runtime reference to a table. This materializes the RowExpr for us. + * TODO: Maybe add the full SchemaDef to the type signature depending on how joins will work. + */ +export type TableRef = Readonly<{ + type: 'table'; + name: TableDef['name']; + cols: RowExpr; + // Maybe redundant. + tableDef: TableDef; +}>; + +function createTableRefFromDef( + tableDef: TableDef +): TableRef { + const cols = createRowExpr(tableDef); + return { + type: 'table', + tableDef: tableDef, + cols: cols, + name: tableDef.name, + }; +} + +export function makeQueryBuilder( + schema: SchemaDef +): QueryBuilder { + const qb = Object.create(null) as QueryBuilder; + for (const table of schema.tables) { + const ref = createTableRefFromDef( + table as TableDefByName> + ); + (qb as Record>)[table.name] = ref; + } + return qb; +} + +function createRowExpr( + tableDef: TableDef +): RowExpr { + const row: Record> = {}; + for (const columnName of Object.keys(tableDef.columns) as Array< + keyof TableDef['columns'] & string + >) { + const columnBuilder = tableDef.columns[columnName]; + row[columnName] = Object.freeze({ + type: 'column', + table: tableDef.name, + column: columnName, + spacetimeType: columnBuilder.typeBuilder.resolveType(), + }) as ColumnExpr; + } + return Object.freeze(row) as RowExpr; +} // A static list of column names for a table. // type ColumnList< // SchemaDef extends UntypedSchemaDef, @@ -58,159 +108,11 @@ export type JoinCondition< rightColumns: ColumnList; }; -type EqualLength< - A extends readonly any[], - B extends readonly any[], -> = A['length'] extends B['length'] - ? B['length'] extends A['length'] - ? A - : never - : never; - -export type JoinCondition7< - SchemaDef extends UntypedSchemaDef, - LeftTable extends TableNames, - RightTable extends TableNames, - LCols extends readonly ColumnNames< - TableDefByName - >[] = readonly ColumnNames>[], - RCols extends readonly ColumnNames< - TableDefByName - >[] = readonly ColumnNames>[], -> = - EqualLength extends never - ? never - : { - leftColumns: LCols; - rightColumns: RCols; - }; - -export type JoinCondition5< - SchemaDef extends UntypedSchemaDef, - LeftTable extends TableNames, - RightTable extends TableNames, - LCols extends ColumnList = ColumnList< - SchemaDef, - LeftTable - >, - RCols extends ColumnList = ColumnList< - SchemaDef, - RightTable - >, -> = - HasEqualLength extends never - ? never - : { - leftColumns: LCols; - rightColumns: RCols; - }; - type ColumnExprList< SchemaDef extends UntypedSchemaDef, TableName extends TableNames, > = readonly AnyColumnExpr>[]; -type ColumnExprListExtractor< - SchemaDef extends UntypedSchemaDef, - TableName extends TableNames, -> = ( - row: RowExpr> -) => ColumnExprList; -// type ColumnListExtractor>> = ReadonlyArray, - RightTable extends TableNames, -> = { - leftColumns: ColumnExprListExtractor; - rightColumns: ColumnExprListExtractor; -}; - -// type Zip = -// A extends [infer AH, ...infer AT] -// ? B extends [infer BH, ...infer BT] -// ? [[AH, BH], ...Zip] -// : never -// : B extends [] ? [] : never; - -type Zip< - A extends readonly any[], - B extends readonly any[], -> = A extends readonly [infer AH, ...infer AT] - ? B extends readonly [infer BH, ...infer BT] - ? [[AH, BH], ...Zip] - : never - : B extends readonly [] - ? [] - : never; - -export type JoinCondition3< - SchemaDef extends UntypedSchemaDef, - LeftTable extends TableNames, - RightTable extends TableNames, - L extends ColumnExprList, - R extends ColumnExprList, -> = - Zip extends never - ? never - : { - leftColumns: (row: RowExpr>) => L; - rightColumns: ( - row: RowExpr> - ) => R; - }; - -/** Helper type to check if two tuples/arrays have equal length */ -type HasEqualLength< - T extends readonly any[], - U extends readonly any[], -> = T extends { length: infer L } - ? U extends { length: L } - ? true - : false - : false; - -export type JoinIsValid> = - HasEqualLength, ReturnType>; -export type RestrictedJoin> = - HasEqualLength< - ReturnType, - ReturnType - > extends true - ? T - : never; - -type SameLen< - A extends readonly any[], - B extends readonly any[], -> = A['length'] extends B['length'] - ? B['length'] extends A['length'] - ? true - : never - : never; - -// ───────────────────────────────────────────────────────────── -// Helper that *preserves* tuple literal types and enforces length -export function on( - leftColumns: LC, - rightColumns: RC & (SameLen extends never ? never : unknown) -) { - return { leftColumns, rightColumns } as const; -} - -// ───────────────────────────────────────────────────────────── -// JoinCondition type (optional, but nice to export) -export type JoinCondition9< - SD extends UntypedSchemaDef, - LeftTable extends TableNames, - RightTable extends TableNames, - LC extends readonly ColumnNames>[], - RC extends readonly ColumnNames>[], -> = { - leftColumns: LC; - rightColumns: RC; -}; - export class TableScan< SchemaDef extends UntypedSchemaDef, TableDef extends TypedTableDef, @@ -228,15 +130,6 @@ export class TableScan< } } -/** - * A type representing a - */ -export type TableRef
= { - type: 'table'; - row: RowExpr
; - tableName: Table['name']; -}; - // TODO: Just use UntypedTableDef if they end up being the same. export type TypedTableDef = { name: string; @@ -261,9 +154,6 @@ export type ColumnExpr< table: TableDef['name']; // This is here as a phantom type. You can pull it back with NonNullable<> tsValueType?: RowType[ColumnName]; - /** - * docs - */ spacetimeType: InferSpacetimeTypeOfColumn; }>; @@ -290,9 +180,9 @@ type AnyColumnExpr
= { /** * Acts as a row when writing filters for queries. It is a way to get column references. */ -export type RowExpr = { +export type RowExpr = Readonly<{ readonly [C in ColumnNames]: ColumnExpr; -}; +}>; /** * Union of ColumnExprs from Table whose spacetimeType is compatible with Value From 02b5b30d366a0fe01fcb611f04ad651518648d3a Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Fri, 7 Nov 2025 17:16:44 -0800 Subject: [PATCH 11/21] Create a querybuilder --- .../src/server/query.test-d.ts | 5 +++-- crates/bindings-typescript/src/server/query.ts | 2 +- .../bindings-typescript/src/server/runtime.ts | 12 +++++++++--- .../bindings-typescript/src/server/schema.ts | 18 ++++++++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts index c29a8f0b4d0..d6fad7c0829 100644 --- a/crates/bindings-typescript/src/server/query.test-d.ts +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -94,9 +94,10 @@ spacetimedb.init(ctx => { // .filter // col("age") + // ctx.db.person[Symbol] + //ctx.query.from('person') - ctx.queryBuilder - .query('person') + ctx.queryBuilder.person // .query('person') .filter(row => eq(row['age'], literal(20))); // .filter(row => eq(row.age, literal(20))) diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 7c1d62f3484..0361720bf54 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -65,7 +65,7 @@ export function makeQueryBuilder( ); (qb as Record>)[table.name] = ref; } - return qb; + return Object.freeze(qb) as QueryBuilder; } function createRowExpr( diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 3ef0dc129bb..5a78caf6f14 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -25,11 +25,11 @@ import { type AuthCtx, type JsonObject, } from './reducers'; -import { MODULE_DEF } from './schema'; +import { MODULE_DEF, getRegisteredSchema } from './schema'; import * as _syscalls from 'spacetime:sys@1.0'; import type { u16, u32, ModuleHooks } from 'spacetime:sys@1.0'; -import { fakeQueryBuilder } from './query'; +import { makeQueryBuilder, type QueryBuilder } from './query'; const { freeze } = Object; @@ -197,7 +197,7 @@ export const hooks: ModuleHooks = { timestamp: new Timestamp(timestamp), connectionId: ConnectionId.nullIfZero(new ConnectionId(connId)), db: getDbView(), - queryBuilder: fakeQueryBuilder(), + queryBuilder: getQueryBuilder(), senderAuth: AuthCtxImpl.fromSystemTables( ConnectionId.nullIfZero(new ConnectionId(connId)), senderIdentity @@ -220,6 +220,12 @@ function getDbView() { return DB_VIEW; } +let QUERY_BUILDER: QueryBuilder | null = null; +function getQueryBuilder() { + QUERY_BUILDER ??= makeQueryBuilder(getRegisteredSchema()); + return QUERY_BUILDER; +} + function makeDbView(module_def: RawModuleDefV9): DbView { return freeze( Object.fromEntries( diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index 6684091b788..97df2f4da9f 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -40,6 +40,17 @@ const COMPOUND_TYPES = new Map< AlgebraicTypeVariants.Ref >(); +let REGISTERED_SCHEMA: UntypedSchemaDef | null = null; + +export function getRegisteredSchema(): UntypedSchemaDef { + if (REGISTERED_SCHEMA == null) { + throw new Error( + 'No schema has been registered yet. Call schema() before accessing it.' + ); + } + return REGISTERED_SCHEMA; +} + export function addType( name: string | undefined, ty: T @@ -365,6 +376,13 @@ export function schema( // Modify the `MODULE_DEF` which will be read by // __describe_module__ MODULE_DEF.tables.push(...tableDefs); + REGISTERED_SCHEMA = { + tables: handles.map(handle => ({ + name: handle.tableName, + columns: handle.rowType.row, + indexes: handle.idxs, + })), + }; // MODULE_DEF.typespace = typespace; // throw new Error( // MODULE_DEF.tables From 4f32ff67e0c504a9a29c8597987ae9ddfdb8868e Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Fri, 7 Nov 2025 17:32:41 -0800 Subject: [PATCH 12/21] Add a scan function to start queries --- .../bindings-typescript/src/server/query.test-d.ts | 3 ++- crates/bindings-typescript/src/server/query.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts index d6fad7c0829..73ef44e5555 100644 --- a/crates/bindings-typescript/src/server/query.test-d.ts +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -97,7 +97,8 @@ spacetimedb.init(ctx => { // ctx.db.person[Symbol] //ctx.query.from('person') - ctx.queryBuilder.person + ctx.queryBuilder + .scan('person') // .query('person') .filter(row => eq(row['age'], literal(20))); // .filter(row => eq(row.age, literal(20))) diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 0361720bf54..44c4c40575f 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -23,6 +23,10 @@ export type TableDefByName< export type QueryBuilder = { readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef; +} & { + scan>( + table: Name + ): TableScan>; }; export function fakeQueryBuilder< @@ -65,6 +69,13 @@ export function makeQueryBuilder( ); (qb as Record>)[table.name] = ref; } + const builder = qb as QueryBuilder; + builder.scan = function >( + table: Name + ): TableScan> { + const ref = this[table] as TableRef>; + return new TableScan>(ref); + }; return Object.freeze(qb) as QueryBuilder; } @@ -117,7 +128,7 @@ export class TableScan< SchemaDef extends UntypedSchemaDef, TableDef extends TypedTableDef, > { - // readonly filters: readonly BooleanExpr[]; + constructor(readonly table: TableRef) {} filter( predicate: (row: RowExpr) => BooleanExpr From 072fbebfe7b76ddc76a2efb4bb9a34afb6ef11cd Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Fri, 7 Nov 2025 17:36:17 -0800 Subject: [PATCH 13/21] rename scan to query --- crates/bindings-typescript/src/server/query.test-d.ts | 2 +- crates/bindings-typescript/src/server/query.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts index 73ef44e5555..b07244193e3 100644 --- a/crates/bindings-typescript/src/server/query.test-d.ts +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -98,7 +98,7 @@ spacetimedb.init(ctx => { //ctx.query.from('person') ctx.queryBuilder - .scan('person') + .query('person') // .query('person') .filter(row => eq(row['age'], literal(20))); // .filter(row => eq(row.age, literal(20))) diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 44c4c40575f..efa98aa4d2f 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -24,7 +24,7 @@ export type TableDefByName< export type QueryBuilder = { readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef; } & { - scan>( + query>( table: Name ): TableScan>; }; @@ -70,7 +70,7 @@ export function makeQueryBuilder( (qb as Record>)[table.name] = ref; } const builder = qb as QueryBuilder; - builder.scan = function >( + builder.query = function >( table: Name ): TableScan> { const ref = this[table] as TableRef>; From 80bc886aadd0504f218bbe3cd842ee59dc382b24 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Sat, 8 Nov 2025 09:34:11 -0800 Subject: [PATCH 14/21] Add some more booleans and adds some sql tests. --- .../bindings-typescript/src/server/query.ts | 118 +++++++++++++++--- .../bindings-typescript/tests/query.test.ts | 90 +++++++++++++ 2 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 crates/bindings-typescript/tests/query.test.ts diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index efa98aa4d2f..b68206f37b8 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -7,7 +7,6 @@ import type { InferSpacetimeTypeOfTypeBuilder, TypeBuilder, } from './type_builders'; -import type { CollapseTuple } from './type_util'; /** * Helper to get the set of table names. @@ -29,12 +28,6 @@ export type QueryBuilder = { ): TableScan>; }; -export function fakeQueryBuilder< - SchemaDef extends UntypedSchemaDef, ->(): QueryBuilder { - throw 'unimplemented'; -} - /** * A runtime reference to a table. This materializes the RowExpr for us. * TODO: Maybe add the full SchemaDef to the type signature depending on how joins will work. @@ -128,16 +121,25 @@ export class TableScan< SchemaDef extends UntypedSchemaDef, TableDef extends TypedTableDef, > { - constructor(readonly table: TableRef) {} + constructor( + readonly table: TableRef, + readonly where?: BooleanExpr + ) {} filter( predicate: (row: RowExpr) => BooleanExpr ): TableScan { - throw 'unimplemented'; + const nextWhere = predicate(this.table.cols); + return new TableScan(this.table, nextWhere); } toSql(): string { - throw 'unimplemented'; + const tableName = quoteIdentifier(this.table.name); + const base = `SELECT * FROM ${tableName}`; + if (!this.where) { + return base; + } + return `${base} WHERE ${booleanExprToSql(this.where)}`; } } @@ -214,16 +216,30 @@ type LiteralExpr = { value: Value; }; -type BooleanExpr
= { - type: 'eq'; - left: ValueExpr; - right: ValueExpr; -}; +type BooleanExpr
= + | { + type: 'eq'; + left: ValueExpr; + right: ValueExpr; + } + | { + type: 'and'; + clauses: readonly [BooleanExpr
, BooleanExpr
, ...BooleanExpr
[]]; + } + | { + type: 'or'; + clauses: readonly [BooleanExpr
, BooleanExpr
, ...BooleanExpr
[]]; + } + | { + type: 'not'; + clause: BooleanExpr
; + }; export function eq
( left: ValueExpr, right: ValueExpr ): BooleanExpr
{ + // TODO: Not sure if normalizing like this is actually helpful. const lk = 'type' in left && left.type === 'literal'; const rk = 'type' in right && right.type === 'literal'; if (lk && !rk) { @@ -243,3 +259,75 @@ export function eq
( export function literal(value: Value): LiteralExpr { return { type: 'literal', value }; } + +export function not
( + clause: BooleanExpr
+): BooleanExpr
{ + return { type: 'not', clause }; +} + +export function and
( + ...clauses: readonly [BooleanExpr
, BooleanExpr
, ...BooleanExpr
[]] +): BooleanExpr
{ + return { type: 'and', clauses }; +} + +export function or
( + ...clauses: readonly [BooleanExpr
, BooleanExpr
, ...BooleanExpr
[]] +): BooleanExpr
{ + return { type: 'or', clauses }; +} + +function booleanExprToSql
( + expr: BooleanExpr
+): string { + switch (expr.type) { + case 'eq': + return `${valueExprToSql(expr.left)} = ${valueExprToSql(expr.right)}`; + case 'and': + return expr.clauses.map(booleanExprToSql).map(wrapInParens).join(' AND '); + case 'or': + return expr.clauses.map(booleanExprToSql).map(wrapInParens).join(' OR '); + case 'not': + return `NOT ${wrapInParens(booleanExprToSql(expr.clause))}`; + } +} +function wrapInParens(sql: string): string { + return `(${sql})`; +} + +function valueExprToSql
( + expr: ValueExpr +): string { + if (isLiteralExpr(expr)) { + return literalValueToSql(expr.value); + } + return `${quoteIdentifier(expr.table)}.${quoteIdentifier(expr.column)}`; +} + +function literalValueToSql(value: unknown): string { + if (value === null || value === undefined) { + return 'NULL'; + } + switch (typeof value) { + case 'number': + case 'bigint': + return String(value); + case 'boolean': + return value ? 'TRUE' : 'FALSE'; + case 'string': + return `'${value.replace(/'/g, "''")}'`; + default: + return `'${JSON.stringify(value).replace(/'/g, "''")}'`; + } +} + +function quoteIdentifier(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +function isLiteralExpr( + expr: ValueExpr +): expr is LiteralExpr { + return (expr as LiteralExpr).type === 'literal'; +} diff --git a/crates/bindings-typescript/tests/query.test.ts b/crates/bindings-typescript/tests/query.test.ts new file mode 100644 index 00000000000..1375138a8c7 --- /dev/null +++ b/crates/bindings-typescript/tests/query.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { makeQueryBuilder, eq, literal, and, or, not } from '../src/server/query'; +import type { UntypedSchemaDef } from '../src/server/schema'; +import { table } from '../src/server/table'; +import { t } from '../src/server/type_builders'; + +const personTable = table( + { name: 'person' }, + { + id: t.identity(), + name: t.string(), + age: t.u32(), + } +); + +const schemaDef: UntypedSchemaDef = { + tables: [ + { + name: personTable.tableName, + columns: personTable.rowType.row, + indexes: personTable.idxs, + }, + ], +}; + +describe('TableScan.toSql', () => { + it('renders a full-table scan when no filters are applied', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb.query('person').toSql(); + + expect(sql).toBe('SELECT * FROM "person"'); + }); + + it('renders a WHERE clause for simple equality filters', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb + .query('person') + .filter(row => eq(row.name, literal("O'Brian"))) + .toSql(); + + expect(sql).toBe( + `SELECT * FROM "person" WHERE "person"."name" = 'O''Brian'` + ); + }); + + it('renders numeric literals and column references', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb + .query('person') + .filter(row => eq(row.age, literal(42))) + .toSql(); + + expect(sql).toBe(`SELECT * FROM "person" WHERE "person"."age" = 42`); + }); + + it('renders AND clauses across multiple predicates', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb + .query('person') + .filter(row => and(eq(row.name, literal('Alice')), eq(row.age, literal(30)))) + .toSql(); + + expect(sql).toBe( + `SELECT * FROM "person" WHERE ("person"."name" = 'Alice') AND ("person"."age" = 30)` + ); + }); + + it('renders NOT clauses around subpredicates', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb + .query('person') + .filter(row => not(eq(row.name, literal('Bob')))) + .toSql(); + + expect(sql).toBe( + `SELECT * FROM "person" WHERE NOT ("person"."name" = 'Bob')` + ); + }); +}); + it('renders OR clauses across multiple predicates', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb + .query('person') + .filter(row => or(eq(row.name, literal('Carol')), eq(row.name, literal('Dave')))) + .toSql(); + + expect(sql).toBe( + `SELECT * FROM "person" WHERE ("person"."name" = 'Carol') OR ("person"."name" = 'Dave')` + ); + }); From eae413ead689f86941a3c205711b86aeaf481509 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Sat, 8 Nov 2025 10:41:03 -0800 Subject: [PATCH 15/21] Support identities --- .../bindings-typescript/src/server/query.ts | 39 +++++++++++++++---- .../bindings-typescript/tests/query.test.ts | 36 +++++++++++++++-- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index b68206f37b8..f6c217d7abe 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -1,3 +1,4 @@ +import { Identity } from '../lib/identity'; import type { Index, IndexOpts, UntypedIndex } from './indexes'; import type { UntypedSchemaDef } from './schema'; import type { RowType, TableIndexes, TableSchema } from './table'; @@ -207,8 +208,10 @@ export type ColumnExprForValue
= { : never; }[ColumnNames
]; +type LiteralValue = string | number | bigint | boolean | Identity; + export type ValueExpr = - | LiteralExpr + | LiteralExpr | ColumnExprForValue; type LiteralExpr = { @@ -224,11 +227,19 @@ type BooleanExpr
= } | { type: 'and'; - clauses: readonly [BooleanExpr
, BooleanExpr
, ...BooleanExpr
[]]; + clauses: readonly [ + BooleanExpr
, + BooleanExpr
, + ...BooleanExpr
[], + ]; } | { type: 'or'; - clauses: readonly [BooleanExpr
, BooleanExpr
, ...BooleanExpr
[]]; + clauses: readonly [ + BooleanExpr
, + BooleanExpr
, + ...BooleanExpr
[], + ]; } | { type: 'not'; @@ -256,7 +267,9 @@ export function eq
( }; } -export function literal(value: Value): LiteralExpr { +export function literal( + value: Value +): LiteralExpr { return { type: 'literal', value }; } @@ -267,13 +280,21 @@ export function not
( } export function and
( - ...clauses: readonly [BooleanExpr
, BooleanExpr
, ...BooleanExpr
[]] + ...clauses: readonly [ + BooleanExpr
, + BooleanExpr
, + ...BooleanExpr
[], + ] ): BooleanExpr
{ return { type: 'and', clauses }; } export function or
( - ...clauses: readonly [BooleanExpr
, BooleanExpr
, ...BooleanExpr
[]] + ...clauses: readonly [ + BooleanExpr
, + BooleanExpr
, + ...BooleanExpr
[], + ] ): BooleanExpr
{ return { type: 'or', clauses }; } @@ -292,6 +313,7 @@ function booleanExprToSql
( return `NOT ${wrapInParens(booleanExprToSql(expr.clause))}`; } } + function wrapInParens(sql: string): string { return `(${sql})`; } @@ -309,6 +331,9 @@ function literalValueToSql(value: unknown): string { if (value === null || value === undefined) { return 'NULL'; } + if (value instanceof Identity) { + return `0x${value.toHexString()}`; + } switch (typeof value) { case 'number': case 'bigint': @@ -328,6 +353,6 @@ function quoteIdentifier(name: string): string { function isLiteralExpr( expr: ValueExpr -): expr is LiteralExpr { +): expr is LiteralExpr { return (expr as LiteralExpr).type === 'literal'; } diff --git a/crates/bindings-typescript/tests/query.test.ts b/crates/bindings-typescript/tests/query.test.ts index 1375138a8c7..460d2ded3c8 100644 --- a/crates/bindings-typescript/tests/query.test.ts +++ b/crates/bindings-typescript/tests/query.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { makeQueryBuilder, eq, literal, and, or, not } from '../src/server/query'; +import { Identity } from '../src/lib/identity'; +import { + makeQueryBuilder, + eq, + literal, + and, + or, + not, +} from '../src/server/query'; import type { UntypedSchemaDef } from '../src/server/schema'; import { table } from '../src/server/table'; import { t } from '../src/server/type_builders'; @@ -57,7 +65,9 @@ describe('TableScan.toSql', () => { const qb = makeQueryBuilder(schemaDef); const sql = qb .query('person') - .filter(row => and(eq(row.name, literal('Alice')), eq(row.age, literal(30)))) + .filter(row => + and(eq(row.name, literal('Alice')), eq(row.age, literal(30))) + ) .toSql(); expect(sql).toBe( @@ -76,15 +86,33 @@ describe('TableScan.toSql', () => { `SELECT * FROM "person" WHERE NOT ("person"."name" = 'Bob')` ); }); -}); + it('renders OR clauses across multiple predicates', () => { const qb = makeQueryBuilder(schemaDef); const sql = qb .query('person') - .filter(row => or(eq(row.name, literal('Carol')), eq(row.name, literal('Dave')))) + .filter(row => + or(eq(row.name, literal('Carol')), eq(row.name, literal('Dave'))) + ) .toSql(); expect(sql).toBe( `SELECT * FROM "person" WHERE ("person"."name" = 'Carol') OR ("person"."name" = 'Dave')` ); }); + + it('renders Identity literals using their hex form', () => { + const qb = makeQueryBuilder(schemaDef); + const identity = new Identity( + '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + ); + const sql = qb + .query('person') + .filter(row => eq(row.id, literal(identity))) + .toSql(); + + expect(sql).toBe( + `SELECT * FROM "person" WHERE "person"."id" = 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` + ); + }); +}); From 861c52c509836e564be0023a196776b485ea3851 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Sun, 9 Nov 2025 09:24:54 -0800 Subject: [PATCH 16/21] fix push reducer type --- .../src/server/reducers.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/crates/bindings-typescript/src/server/reducers.ts b/crates/bindings-typescript/src/server/reducers.ts index 26b530f6472..378db5f7c7b 100644 --- a/crates/bindings-typescript/src/server/reducers.ts +++ b/crates/bindings-typescript/src/server/reducers.ts @@ -7,12 +7,7 @@ import type { Timestamp } from '../lib/timestamp'; import type { QueryBuilder } from './query'; import { MODULE_DEF, type UntypedSchemaDef } from './schema'; import type { Table } from './table'; -import type { - InferTypeOfRow, - RowBuilder, - RowObj, - TypeBuilder, -} from './type_builders'; +import type { InferTypeOfRow, RowObj, TypeBuilder } from './type_builders'; /** * Helper to extract the parameter types from an object type @@ -129,10 +124,13 @@ export type ReducerCtx = Readonly<{ * @param fn - The reducer function. * @param lifecycle - Optional lifecycle hooks for the reducer. */ -export function pushReducer( +export function pushReducer< + S extends UntypedSchemaDef, + Params extends ParamsObj | RowObj, +>( name: string, - params: RowObj | RowBuilder, - fn: Reducer, + params: Params, + fn: Reducer, lifecycle?: RawReducerDefV9['lifecycle'] ): void { if (existingReducers.has(name)) @@ -152,7 +150,7 @@ export function pushReducer( lifecycle, // <- lifecycle flag lands here }); - REDUCERS.push(fn); + REDUCERS.push(fn as Reducer); } const existingReducers = new Set(); @@ -196,11 +194,7 @@ export const REDUCERS: Reducer[] = []; export function reducer< S extends UntypedSchemaDef, Params extends ParamsObj | RowObj, ->( - name: string, - params: Params, - fn: (ctx: ReducerCtx, payload: ParamsAsObject) => void -): void { +>(name: string, params: Params, fn: Reducer): void { pushReducer(name, params, fn); } From e5e8e487db964c35cb545bd3774b38c6ed12cb74 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Mon, 10 Nov 2025 08:47:18 -0800 Subject: [PATCH 17/21] Exists in WIP --- .../bindings-typescript/src/server/query.ts | 170 +++++++++++++++--- .../bindings-typescript/tests/query.test.ts | 34 ++++ 2 files changed, 184 insertions(+), 20 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index f6c217d7abe..32a3afc4c05 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -1,3 +1,4 @@ +import { ConnectionId } from '../lib/connection_id'; import { Identity } from '../lib/identity'; import type { Index, IndexOpts, UntypedIndex } from './indexes'; import type { UntypedSchemaDef } from './schema'; @@ -97,50 +98,148 @@ function createRowExpr( // > = readonly ColumnNames>[]; export type ColumnList< - SchemaDef extends UntypedSchemaDef, - Table extends TableNames, + TableDef extends TypedTableDef, T extends readonly ColumnNames< - TableDefByName - >[] = readonly ColumnNames>[], + TableDef + >[] = readonly ColumnNames[], > = T; export type JoinCondition< - SchemaDef extends UntypedSchemaDef, - LeftTable extends TableNames, - RightTable extends TableNames, + LeftTable extends TypedTableDef, + RightTable extends TypedTableDef, > = { - leftColumns: ColumnList; - rightColumns: ColumnList; + leftColumns: ColumnList; + rightColumns: ColumnList; }; +type JoinExpr
= Readonly>; + +type SemiJoinExpr
= Readonly<{ + type: 'semi'; + table: TableRef; + on: readonly JoinOnClause
[]; + innerWhere?: BooleanExpr; +}>; + +type JoinOnClause
= Readonly<{ + left: ColumnExpr>; + right: ColumnExpr>; +}>; + +type TableNameFromDef< + SchemaDef extends UntypedSchemaDef, + TableDef extends TypedTableDef, +> = TableDef extends TableDefByName> + ? Name + : never; + type ColumnExprList< SchemaDef extends UntypedSchemaDef, TableName extends TableNames, > = readonly AnyColumnExpr>[]; +/** + * Represents a query of a full table. + */ export class TableScan< SchemaDef extends UntypedSchemaDef, TableDef extends TypedTableDef, > { constructor( readonly table: TableRef, - readonly where?: BooleanExpr + readonly where?: BooleanExpr, + readonly joins: readonly JoinExpr[] = [] ) {} filter( predicate: (row: RowExpr) => BooleanExpr ): TableScan { const nextWhere = predicate(this.table.cols); - return new TableScan(this.table, nextWhere); + return new TableScan(this.table, nextWhere, this.joins); + } + + existsIn< + OtherTable extends TableDef, + CurrentName extends TableNameFromDef = TableNameFromDef< + SchemaDef, + TableDef + >, + >( + _other: TableScan, + _join: CurrentName extends never + ? never + : JoinCondition + ): Semijoin { + const { leftColumns, rightColumns } = _join as JoinCondition< + SchemaDef, + CurrentName, + OtherName + >; + if (leftColumns.length !== rightColumns.length) { + throw new Error('Join conditions must pair the same number of columns.'); + } + const joinedColumns = leftColumns.map((leftColumn, idx) => { + const rightColumn = rightColumns[idx]; + const leftExpr = this.table.cols[leftColumn]; + const rightExpr = _other.table.cols[rightColumn]; + if (!leftExpr || !rightExpr) { + throw new Error( + `Invalid join columns: ${String(leftColumn)} -> ${String(rightColumn)}.` + ); + } + return { + left: leftExpr, + right: rightExpr as ColumnExpr< + TypedTableDef, + ColumnNames + >, + }; + }); + + return new Semijoin(this, _other, joinedColumns); } toSql(): string { const tableName = quoteIdentifier(this.table.name); const base = `SELECT * FROM ${tableName}`; - if (!this.where) { + if (!this.where && this.joins.length === 0) { return base; } - return `${base} WHERE ${booleanExprToSql(this.where)}`; + const clauses: string[] = []; + if (this.where) { + clauses.push(booleanExprToSql(this.where)); + } + for (const join of this.joins) { + clauses.push(joinExprToSql(join)); + } + const whereSql = + clauses.length === 1 + ? clauses[0] + : clauses.map(wrapInParens).join(' AND '); + return `${base} WHERE ${whereSql}`; + } +} + +export class Semijoin< + SchemaDef extends UntypedSchemaDef, + LeftTable extends TypedTableDef, + RightTable extends TypedTableDef, +> extends TableScan { + constructor( + left: TableScan, + readonly right: TableScan, + readonly joinColumns: readonly JoinOnClause[] + ) { + super( + left.table, + left.where, + left.joins.concat({ + type: 'semi', + table: right.table as TableRef, + on: joinColumns, + innerWhere: right.where as BooleanExpr | undefined, + }) + ); } } @@ -208,7 +307,7 @@ export type ColumnExprForValue
= { : never; }[ColumnNames
]; -type LiteralValue = string | number | bigint | boolean | Identity; +type LiteralValue = string | number | bigint | boolean | Identity | ConnectionId; export type ValueExpr = | LiteralExpr @@ -219,6 +318,12 @@ type LiteralExpr = { value: Value; }; +export function literal( + value: Value +): LiteralExpr { + return { type: 'literal', value }; +} + type BooleanExpr
= | { type: 'eq'; @@ -267,11 +372,6 @@ export function eq
( }; } -export function literal( - value: Value -): LiteralExpr { - return { type: 'literal', value }; -} export function not
( clause: BooleanExpr
@@ -314,6 +414,34 @@ function booleanExprToSql
( } } +function joinExprToSql
( + join: JoinExpr
+): string { + switch (join.type) { + case 'semi': + return semiJoinToSql(join); + } +} + +function semiJoinToSql
( + join: SemiJoinExpr
+): string { + const base = `SELECT 1 FROM ${quoteIdentifier(join.table.name)}`; + const conditions: string[] = join.on.map(({ left, right }) => { + return `${valueExprToSql
(left)} = ${valueExprToSql( + right + )}`; + }); + if (join.innerWhere) { + conditions.push(booleanExprToSql(join.innerWhere)); + } + const whereSql = + conditions.length > 0 + ? ` WHERE ${conditions.map(wrapInParens).join(' AND ')}` + : ''; + return `EXISTS (${base}${whereSql})`; +} + function wrapInParens(sql: string): string { return `(${sql})`; } @@ -331,7 +459,8 @@ function literalValueToSql(value: unknown): string { if (value === null || value === undefined) { return 'NULL'; } - if (value instanceof Identity) { + if (value instanceof Identity || value instanceof ConnectionId) { + // We use this hex string syntax. return `0x${value.toHexString()}`; } switch (typeof value) { @@ -343,6 +472,7 @@ function literalValueToSql(value: unknown): string { case 'string': return `'${value.replace(/'/g, "''")}'`; default: + // It might be safer to error here? return `'${JSON.stringify(value).replace(/'/g, "''")}'`; } } diff --git a/crates/bindings-typescript/tests/query.test.ts b/crates/bindings-typescript/tests/query.test.ts index 460d2ded3c8..ece054072bf 100644 --- a/crates/bindings-typescript/tests/query.test.ts +++ b/crates/bindings-typescript/tests/query.test.ts @@ -21,6 +21,15 @@ const personTable = table( } ); +const ordersTable = table( + { name: 'orders' }, + { + order_id: t.identity(), + person_id: t.identity(), + item_name: t.string(), + } +); + const schemaDef: UntypedSchemaDef = { tables: [ { @@ -28,6 +37,11 @@ const schemaDef: UntypedSchemaDef = { columns: personTable.rowType.row, indexes: personTable.idxs, }, + { + name: ordersTable.tableName, + columns: ordersTable.rowType.row, + indexes: ordersTable.idxs, + }, ], }; @@ -115,4 +129,24 @@ describe('TableScan.toSql', () => { `SELECT * FROM "person" WHERE "person"."id" = 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` ); }); + + it('renders EXISTS clauses built via existsIn', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb + .query('person') + .existsIn( + qb + .query('orders') + .filter(orderRow => eq(orderRow.item_name, literal('Widget'))), + { + leftColumns: ['id'] as const, + rightColumns: ['person_id'] as const, + } + ) + .toSql(); + + expect(sql).toBe( + `SELECT * FROM "person" WHERE EXISTS (SELECT 1 FROM "orders" WHERE ("person"."id" = "orders"."person_id") AND ("orders"."item_name" = 'Widget'))` + ); + }); }); From 3615744af1185f8dadd38a2e80128e30955a11ac Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Mon, 10 Nov 2025 16:37:23 -0800 Subject: [PATCH 18/21] Get semijoin working --- .../src/server/query.test-d.ts | 10 +- .../bindings-typescript/src/server/query.ts | 256 ++++++++---------- .../bindings-typescript/tests/query.test.ts | 69 ++++- modules/quickstart-chat/src/lib.rs | 3 +- 4 files changed, 188 insertions(+), 150 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts index b07244193e3..99009a7c700 100644 --- a/crates/bindings-typescript/src/server/query.test-d.ts +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -97,9 +97,15 @@ spacetimedb.init(ctx => { // ctx.db.person[Symbol] //ctx.query.from('person') - ctx.queryBuilder + + const filteredQuery = ctx.queryBuilder .query('person') // .query('person') .filter(row => eq(row['age'], literal(20))); - // .filter(row => eq(row.age, literal(20))) + + filteredQuery.semijoinTo( + ctx.queryBuilder.order, + p => p.age, + o => o.item_name + ); }); diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 32a3afc4c05..1c4486f546e 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -91,47 +91,41 @@ function createRowExpr( } return Object.freeze(row) as RowExpr; } -// A static list of column names for a table. -// type ColumnList< -// SchemaDef extends UntypedSchemaDef, -// TableName extends TableNames, -// > = readonly ColumnNames>[]; export type ColumnList< TableDef extends TypedTableDef, - T extends readonly ColumnNames< - TableDef - >[] = readonly ColumnNames[], + T extends readonly ColumnNames[] = readonly ColumnNames[], > = T; export type JoinCondition< LeftTable extends TypedTableDef, RightTable extends TypedTableDef, > = { - leftColumns: ColumnList; - rightColumns: ColumnList; + leftColumn: AnyColumnExpr; + rightColumn: AnyColumnExpr; }; -type JoinExpr
= Readonly>; - -type SemiJoinExpr
= Readonly<{ - type: 'semi'; - table: TableRef; - on: readonly JoinOnClause
[]; - innerWhere?: BooleanExpr; -}>; - -type JoinOnClause
= Readonly<{ - left: ColumnExpr>; - right: ColumnExpr>; +type JoinOnClause< + T1 extends TypedTableDef, + T2 extends TypedTableDef, +> = Readonly<{ + left: ColumnExprList2; + right: ColumnExprList2; }>; type TableNameFromDef< SchemaDef extends UntypedSchemaDef, TableDef extends TypedTableDef, -> = TableDef extends TableDefByName> - ? Name - : never; +> = + TableDef extends TableDefByName< + SchemaDef, + infer Name extends TableNames + > + ? Name + : never; + +type ColumnExprList2 = + readonly AnyColumnExpr[]; type ColumnExprList< SchemaDef extends UntypedSchemaDef, @@ -147,100 +141,102 @@ export class TableScan< > { constructor( readonly table: TableRef, - readonly where?: BooleanExpr, - readonly joins: readonly JoinExpr[] = [] + readonly where?: BooleanExpr ) {} filter( predicate: (row: RowExpr) => BooleanExpr ): TableScan { const nextWhere = predicate(this.table.cols); - return new TableScan(this.table, nextWhere, this.joins); + return new TableScan(this.table, nextWhere); } - existsIn< - OtherTable extends TableDef, - CurrentName extends TableNameFromDef = TableNameFromDef< - SchemaDef, - TableDef - >, - >( - _other: TableScan, - _join: CurrentName extends never - ? never - : JoinCondition + semijoinTo( + other: TableRef, + leftCol: (left: RowExpr) => AnyColumnExpr, + rightCol: (right: RowExpr) => AnyColumnExpr ): Semijoin { - const { leftColumns, rightColumns } = _join as JoinCondition< - SchemaDef, - CurrentName, - OtherName - >; - if (leftColumns.length !== rightColumns.length) { - throw new Error('Join conditions must pair the same number of columns.'); - } - const joinedColumns = leftColumns.map((leftColumn, idx) => { - const rightColumn = rightColumns[idx]; - const leftExpr = this.table.cols[leftColumn]; - const rightExpr = _other.table.cols[rightColumn]; - if (!leftExpr || !rightExpr) { - throw new Error( - `Invalid join columns: ${String(leftColumn)} -> ${String(rightColumn)}.` - ); - } - return { - left: leftExpr, - right: rightExpr as ColumnExpr< - TypedTableDef, - ColumnNames - >, - }; - }); - - return new Semijoin(this, _other, joinedColumns); + const leftColumn = leftCol(this.table.cols); + const rightColumn = rightCol(other.cols); + + const semijoin: Semijoin = { + type: 'semijoin', + left: this.table, + right: other, + leftWhereClause: this.where, + joinClause: { + leftColumn, + rightColumn, + }, + toSql() { + return renderSemijoinToSql(semijoin); + }, + }; + return semijoin; } toSql(): string { - const tableName = quoteIdentifier(this.table.name); - const base = `SELECT * FROM ${tableName}`; - if (!this.where && this.joins.length === 0) { - return base; - } - const clauses: string[] = []; - if (this.where) { - clauses.push(booleanExprToSql(this.where)); - } - for (const join of this.joins) { - clauses.push(joinExprToSql(join)); - } - const whereSql = - clauses.length === 1 - ? clauses[0] - : clauses.map(wrapInParens).join(' AND '); - return `${base} WHERE ${whereSql}`; + return renderSelectSql(this.table.name, this.where); } } -export class Semijoin< +export type Semijoin< SchemaDef extends UntypedSchemaDef, LeftTable extends TypedTableDef, RightTable extends TypedTableDef, -> extends TableScan { - constructor( - left: TableScan, - readonly right: TableScan, - readonly joinColumns: readonly JoinOnClause[] - ) { - super( - left.table, - left.where, - left.joins.concat({ - type: 'semi', - table: right.table as TableRef, - on: joinColumns, - innerWhere: right.where as BooleanExpr | undefined, - }) - ); +> = Readonly<{ + type: 'semijoin'; + left: TableRef; + right: TableRef; + joinClause: JoinCondition; + leftWhereClause?: BooleanExpr; + toSql(): string; +}>; + +export function renderSemijoinToSql< + SchemaDef extends UntypedSchemaDef, + LeftTable extends TypedTableDef, + RightTable extends TypedTableDef, +>(semijoin: Semijoin): string { + const leftAlias = quoteIdentifier('left'); + const rightAlias = quoteIdentifier('right'); + const quotedRightTable = quoteIdentifier(semijoin.right.name); + const quotedLeftTable = quoteIdentifier(semijoin.left.name); + const base = `SELECT ${rightAlias}.* from ${quotedLeftTable} ${leftAlias} join ${quotedRightTable} ${rightAlias} on `; + const joinClause = `${leftAlias}.${quoteIdentifier(semijoin.joinClause.leftColumn.column)} = ${rightAlias}.${quoteIdentifier(semijoin.joinClause.rightColumn.column)}`; + return ( + base + joinClause + renderWhereClauseSql('left', semijoin.leftWhereClause) + ); +} + +function renderWhereClauseSql( + tableName: string, + where?: BooleanExpr +): string { + if (where == undefined) { + return ''; + } + return ` WHERE ${booleanExprToSql(where, tableName)}`; +} + +function renderSelectSql
( + tableName: string, + where?: BooleanExpr
, + extraClauses: readonly string[] = [] +): string { + const quotedTable = quoteIdentifier(tableName); + const base = `SELECT * FROM ${quotedTable}`; + const clauses: string[] = []; + if (where) { + clauses.push(booleanExprToSql(where)); } + clauses.push(...extraClauses); + if (clauses.length === 0) { + return base; + } + const whereSql = + clauses.length === 1 ? clauses[0] : clauses.map(wrapInParens).join(' AND '); + return `${base} WHERE ${whereSql}`; } // TODO: Just use UntypedTableDef if they end up being the same. @@ -307,7 +303,13 @@ export type ColumnExprForValue
= { : never; }[ColumnNames
]; -type LiteralValue = string | number | bigint | boolean | Identity | ConnectionId; +type LiteralValue = + | string + | number + | bigint + | boolean + | Identity + | ConnectionId; export type ValueExpr = | LiteralExpr @@ -372,7 +374,6 @@ export function eq
( }; } - export function not
( clause: BooleanExpr
): BooleanExpr
{ @@ -400,59 +401,40 @@ export function or
( } function booleanExprToSql
( - expr: BooleanExpr
+ expr: BooleanExpr
, + tableAlias?: string ): string { switch (expr.type) { case 'eq': - return `${valueExprToSql(expr.left)} = ${valueExprToSql(expr.right)}`; + return `${valueExprToSql(expr.left, tableAlias)} = ${valueExprToSql(expr.right, tableAlias)}`; case 'and': - return expr.clauses.map(booleanExprToSql).map(wrapInParens).join(' AND '); + return expr.clauses + .map(c => booleanExprToSql(c, tableAlias)) + .map(wrapInParens) + .join(' AND '); case 'or': - return expr.clauses.map(booleanExprToSql).map(wrapInParens).join(' OR '); + return expr.clauses + .map(c => booleanExprToSql(c, tableAlias)) + .map(wrapInParens) + .join(' OR '); case 'not': - return `NOT ${wrapInParens(booleanExprToSql(expr.clause))}`; - } -} - -function joinExprToSql
( - join: JoinExpr
-): string { - switch (join.type) { - case 'semi': - return semiJoinToSql(join); + return `NOT ${wrapInParens(booleanExprToSql(expr.clause, tableAlias))}`; } } -function semiJoinToSql
( - join: SemiJoinExpr
-): string { - const base = `SELECT 1 FROM ${quoteIdentifier(join.table.name)}`; - const conditions: string[] = join.on.map(({ left, right }) => { - return `${valueExprToSql
(left)} = ${valueExprToSql( - right - )}`; - }); - if (join.innerWhere) { - conditions.push(booleanExprToSql(join.innerWhere)); - } - const whereSql = - conditions.length > 0 - ? ` WHERE ${conditions.map(wrapInParens).join(' AND ')}` - : ''; - return `EXISTS (${base}${whereSql})`; -} - function wrapInParens(sql: string): string { return `(${sql})`; } function valueExprToSql
( - expr: ValueExpr + expr: ValueExpr, + tableAlias?: string ): string { if (isLiteralExpr(expr)) { return literalValueToSql(expr.value); } - return `${quoteIdentifier(expr.table)}.${quoteIdentifier(expr.column)}`; + const table = tableAlias ?? expr.table; + return `${quoteIdentifier(table)}.${quoteIdentifier(expr.column)}`; } function literalValueToSql(value: unknown): string { diff --git a/crates/bindings-typescript/tests/query.test.ts b/crates/bindings-typescript/tests/query.test.ts index ece054072bf..48aeecc9bf0 100644 --- a/crates/bindings-typescript/tests/query.test.ts +++ b/crates/bindings-typescript/tests/query.test.ts @@ -130,23 +130,72 @@ describe('TableScan.toSql', () => { ); }); - it('renders EXISTS clauses built via existsIn', () => { + it('renders semijoin queries without additional filters', () => { const qb = makeQueryBuilder(schemaDef); const sql = qb .query('person') - .existsIn( - qb - .query('orders') - .filter(orderRow => eq(orderRow.item_name, literal('Widget'))), - { - leftColumns: ['id'] as const, - rightColumns: ['person_id'] as const, - } + .semijoinTo( + qb.orders, + person => person.id, + order => order.person_id ) .toSql(); expect(sql).toBe( - `SELECT * FROM "person" WHERE EXISTS (SELECT 1 FROM "orders" WHERE ("person"."id" = "orders"."person_id") AND ("orders"."item_name" = 'Widget'))` + `SELECT "right".* from "person" "left" join "orders" "right" on "left"."id" = "right"."person_id"` + ); + }); + + it('renders semijoin queries alongside existing predicates', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb + .query('person') + .filter(row => eq(row.age, literal(42))) + .semijoinTo( + qb.orders, + person => person.id, + order => order.person_id + ) + .toSql(); + + expect(sql).toBe( + `SELECT "right".* from "person" "left" join "orders" "right" on "left"."id" = "right"."person_id" WHERE "left"."age" = 42` + ); + }); + + it('escapes literals when rendering semijoin filters', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb + .query('person') + .filter(row => eq(row.name, literal("O'Brian"))) + .semijoinTo( + qb.orders, + person => person.id, + order => order.person_id + ) + .toSql(); + + expect(sql).toBe( + `SELECT "right".* from "person" "left" join "orders" "right" on "left"."id" = "right"."person_id" WHERE "left"."name" = 'O''Brian'` + ); + }); + + it('renders compound AND filters for semijoin queries', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb + .query('person') + .filter(row => + and(eq(row.name, literal('Alice')), eq(row.age, literal(30))) + ) + .semijoinTo( + qb.orders, + person => person.id, + order => order.person_id + ) + .toSql(); + + expect(sql).toBe( + `SELECT "right".* from "person" "left" join "orders" "right" on "left"."id" = "right"."person_id" WHERE ("left"."name" = 'Alice') AND ("left"."age" = 30)` ); }); }); diff --git a/modules/quickstart-chat/src/lib.rs b/modules/quickstart-chat/src/lib.rs index de77f34bde2..dc29ae8f421 100644 --- a/modules/quickstart-chat/src/lib.rs +++ b/modules/quickstart-chat/src/lib.rs @@ -1,4 +1,4 @@ -use spacetimedb::{Identity, ReducerContext, Table, Timestamp}; +use spacetimedb::{Identity, ReducerContext, Table, Timestamp, rand::seq::index}; #[spacetimedb::table(name = user, public)] pub struct User { @@ -11,6 +11,7 @@ pub struct User { #[spacetimedb::table(name = message, public)] pub struct Message { sender: Identity, + #[index(btree)] sent: Timestamp, text: String, } From a66fa3a2bf095319ab376bc5f169442008b7be20 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Mon, 10 Nov 2025 16:56:14 -0800 Subject: [PATCH 19/21] Get semijoin rendering working. --- .../bindings-typescript/src/server/query.ts | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 1c4486f546e..fcaa269f4e5 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -92,11 +92,6 @@ function createRowExpr( return Object.freeze(row) as RowExpr; } -export type ColumnList< - TableDef extends TypedTableDef, - T extends readonly ColumnNames[] = readonly ColumnNames[], -> = T; - export type JoinCondition< LeftTable extends TypedTableDef, RightTable extends TypedTableDef, @@ -105,33 +100,6 @@ export type JoinCondition< rightColumn: AnyColumnExpr; }; -type JoinOnClause< - T1 extends TypedTableDef, - T2 extends TypedTableDef, -> = Readonly<{ - left: ColumnExprList2; - right: ColumnExprList2; -}>; - -type TableNameFromDef< - SchemaDef extends UntypedSchemaDef, - TableDef extends TypedTableDef, -> = - TableDef extends TableDefByName< - SchemaDef, - infer Name extends TableNames - > - ? Name - : never; - -type ColumnExprList2 = - readonly AnyColumnExpr[]; - -type ColumnExprList< - SchemaDef extends UntypedSchemaDef, - TableName extends TableNames, -> = readonly AnyColumnExpr>[]; - /** * Represents a query of a full table. */ From 6133b8c2f9802308a6b4f81d3d584540fb2efac2 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Mon, 10 Nov 2025 20:44:57 -0800 Subject: [PATCH 20/21] Cleanup --- .../src/server/query.test-d.ts | 38 ++--- .../bindings-typescript/src/server/query.ts | 133 ++++++++++++------ .../bindings-typescript/tests/query.test.ts | 13 ++ 3 files changed, 114 insertions(+), 70 deletions(-) diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts index 99009a7c700..7100c4ff155 100644 --- a/crates/bindings-typescript/src/server/query.test-d.ts +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -3,7 +3,6 @@ import type { Indexes, UniqueIndex } from './indexes'; import { eq, literal, - type ColumnExpr, type RowExpr, type TableNames, type TableSchemaAsTableDef, @@ -51,14 +50,6 @@ const order = table( const spacetimedb = schema([person, order]); -/* -type PersonDef = { - name: typeof person.tableName; - columns: typeof person.rowType.row; - indexes: typeof person.idxs; -}; -*/ - const tableDef = { name: person.tableName, columns: person.rowType.row, @@ -80,30 +71,19 @@ const orderDef = { indexes: order.idxs, }; -//idxs2. - spacetimedb.init(ctx => { - // ctx.db.person. - //ctx.db.person. - //ctx.db - // ctx.db.person.id_name_idx.find - - // Downside of the string approach for columns is that if I hover, I don't get the type information. - - // ctx.queryBuilder. - // .filter - // col("age") - - // ctx.db.person[Symbol] - - //ctx.query.from('person') - + const firstQuery = ctx.queryBuilder.query('person'); + firstQuery.semijoinTo( + ctx.queryBuilder.order, + p => p.age, + o => o.item_name + ); const filteredQuery = ctx.queryBuilder .query('person') - // .query('person') - .filter(row => eq(row['age'], literal(20))); + .filter(row => eq(row.age, literal(20))); - filteredQuery.semijoinTo( + // Eventually this should not type check. + const _semijoin = filteredQuery.semijoinTo( ctx.queryBuilder.order, p => p.age, o => o.item_name diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index fcaa269f4e5..7d0d0fe78c2 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -22,12 +22,45 @@ export type TableDefByName< Name extends TableNames, > = Extract; +export type Query< + SchemaDef extends UntypedSchemaDef, + TableDef extends TypedTableDef, +> = ScanQuery; + +type ScanQuery< + SchemaDef extends UntypedSchemaDef, + TableDef extends TypedTableDef, +> = Readonly<{ + filter( + predicate: (row: RowExpr) => BooleanExpr + ): ScanQuery; + /** + * Query for rows in a different table that match the results of this query. + * @param other The table we want results from. + * @param leftCol The column from the existing query that we are using to join. + * @param rightCol The column from the result table that we are using to join. + */ + semijoinTo( + other: TableRef, + leftCol: (left: RowExpr) => AnyColumnExpr, + rightCol: (right: RowExpr) => AnyColumnExpr + ): SemijoinQuery; + toSql(): string; +}>; + +type SemijoinQuery< + SchemaDef extends UntypedSchemaDef, + LeftTable extends TypedTableDef, +> = Readonly<{ + toSql(): string; +}>; + export type QueryBuilder = { readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef; } & { query>( table: Name - ): TableScan>; + ): Query>; }; /** @@ -67,9 +100,11 @@ export function makeQueryBuilder( const builder = qb as QueryBuilder; builder.query = function >( table: Name - ): TableScan> { + ): Query> { const ref = this[table] as TableRef>; - return new TableScan>(ref); + return createScanQuery( + new TableScan>(ref) + ); }; return Object.freeze(qb) as QueryBuilder; } @@ -100,10 +135,7 @@ export type JoinCondition< rightColumn: AnyColumnExpr; }; -/** - * Represents a query of a full table. - */ -export class TableScan< +class TableScan< SchemaDef extends UntypedSchemaDef, TableDef extends TypedTableDef, > { @@ -111,41 +143,61 @@ export class TableScan< readonly table: TableRef, readonly where?: BooleanExpr ) {} +} - filter( - predicate: (row: RowExpr) => BooleanExpr - ): TableScan { - const nextWhere = predicate(this.table.cols); - return new TableScan(this.table, nextWhere); - } - - semijoinTo( - other: TableRef, - leftCol: (left: RowExpr) => AnyColumnExpr, - rightCol: (right: RowExpr) => AnyColumnExpr - ): Semijoin { - const leftColumn = leftCol(this.table.cols); - const rightColumn = rightCol(other.cols); - - const semijoin: Semijoin = { - type: 'semijoin', - left: this.table, - right: other, - leftWhereClause: this.where, - joinClause: { - leftColumn, - rightColumn, - }, - toSql() { - return renderSemijoinToSql(semijoin); - }, - }; - return semijoin; - } +function createScanQuery< + SchemaDef extends UntypedSchemaDef, + TableDef extends TypedTableDef, +>(scan: TableScan): ScanQuery { + return Object.freeze({ + filter( + predicate: (row: RowExpr) => BooleanExpr + ): ScanQuery { + const newCondition = predicate(scan.table.cols); + const nextWhere = scan.where + ? and(scan.where, newCondition) + : newCondition; + return createScanQuery( + new TableScan(scan.table, nextWhere) + ); + }, + semijoinTo( + other: TableRef, + leftCol: (left: RowExpr) => AnyColumnExpr, + rightCol: (right: RowExpr) => AnyColumnExpr + ): SemijoinQuery { + const leftColumn = leftCol(scan.table.cols); + const rightColumn = rightCol(other.cols); + const semijoin: Semijoin = { + type: 'semijoin', + left: scan.table, + right: other, + leftWhereClause: scan.where, + joinClause: { + leftColumn, + rightColumn, + }, + }; + return createSemijoinQuery(semijoin); + }, + toSql(): string { + return renderSelectSql(scan.table.name, scan.where); + }, + }); +} - toSql(): string { - return renderSelectSql(this.table.name, this.where); - } +function createSemijoinQuery< + SchemaDef extends UntypedSchemaDef, + LeftTable extends TypedTableDef, + RightTable extends TypedTableDef, +>( + semijoin: Semijoin +): SemijoinQuery { + return Object.freeze({ + toSql(): string { + return renderSemijoinToSql(semijoin); + }, + }); } export type Semijoin< @@ -158,7 +210,6 @@ export type Semijoin< right: TableRef; joinClause: JoinCondition; leftWhereClause?: BooleanExpr; - toSql(): string; }>; export function renderSemijoinToSql< diff --git a/crates/bindings-typescript/tests/query.test.ts b/crates/bindings-typescript/tests/query.test.ts index 48aeecc9bf0..49c6be8585e 100644 --- a/crates/bindings-typescript/tests/query.test.ts +++ b/crates/bindings-typescript/tests/query.test.ts @@ -101,6 +101,19 @@ describe('TableScan.toSql', () => { ); }); + it('accumulates multiple filters with AND logic', () => { + const qb = makeQueryBuilder(schemaDef); + const sql = qb + .query('person') + .filter(row => eq(row.name, literal('Eve'))) + .filter(row => eq(row.age, literal(25))) + .toSql(); + + expect(sql).toBe( + `SELECT * FROM "person" WHERE ("person"."name" = 'Eve') AND ("person"."age" = 25)` + ); + }); + it('renders OR clauses across multiple predicates', () => { const qb = makeQueryBuilder(schemaDef); const sql = qb From 811e524d5f6991fa46d73b2e52c3052dd19f65be Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Wed, 12 Nov 2025 10:25:49 -0800 Subject: [PATCH 21/21] Fix the table name equality check --- .../bindings-typescript/src/server/index.ts | 1 + .../src/server/query.test-d.ts | 11 +- .../bindings-typescript/src/server/query.ts | 103 ++++++++---------- .../src/server/reducers.ts | 1 - .../bindings-typescript/src/server/runtime.ts | 17 ++- .../bindings-typescript/src/server/table.ts | 4 + .../bindings-typescript/tests/query.test.ts | 62 +++++------ 7 files changed, 99 insertions(+), 100 deletions(-) diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index c602d704184..a7ade3db108 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -1,6 +1,7 @@ export * from './type_builders'; export { schema, type InferSchema } from './schema'; export { table } from './table'; +export { from } from './query'; export * as errors from './errors'; export { SenderError } from './errors'; export { type Reducer, type ReducerCtx } from './reducers'; diff --git a/crates/bindings-typescript/src/server/query.test-d.ts b/crates/bindings-typescript/src/server/query.test-d.ts index 7100c4ff155..14217071f79 100644 --- a/crates/bindings-typescript/src/server/query.test-d.ts +++ b/crates/bindings-typescript/src/server/query.test-d.ts @@ -2,6 +2,7 @@ import type { U32 } from '../lib/autogen/algebraic_type_variants'; import type { Indexes, UniqueIndex } from './indexes'; import { eq, + from, literal, type RowExpr, type TableNames, @@ -72,19 +73,17 @@ const orderDef = { }; spacetimedb.init(ctx => { - const firstQuery = ctx.queryBuilder.query('person'); + const firstQuery = from(ctx.db.person); firstQuery.semijoinTo( - ctx.queryBuilder.order, + ctx.db.order, p => p.age, o => o.item_name ); - const filteredQuery = ctx.queryBuilder - .query('person') - .filter(row => eq(row.age, literal(20))); + const filteredQuery = firstQuery.where(row => eq(row.age, literal(20))); // Eventually this should not type check. const _semijoin = filteredQuery.semijoinTo( - ctx.queryBuilder.order, + ctx.db.order, p => p.age, o => o.item_name ); diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 7d0d0fe78c2..617286481ad 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -22,18 +22,12 @@ export type TableDefByName< Name extends TableNames, > = Extract; -export type Query< - SchemaDef extends UntypedSchemaDef, - TableDef extends TypedTableDef, -> = ScanQuery; +export type Query = ScanQuery; -type ScanQuery< - SchemaDef extends UntypedSchemaDef, - TableDef extends TypedTableDef, -> = Readonly<{ - filter( +type ScanQuery = Readonly<{ + where( predicate: (row: RowExpr) => BooleanExpr - ): ScanQuery; + ): ScanQuery; /** * Query for rows in a different table that match the results of this query. * @param other The table we want results from. @@ -41,27 +35,20 @@ type ScanQuery< * @param rightCol The column from the result table that we are using to join. */ semijoinTo( - other: TableRef, + other: RefSource, leftCol: (left: RowExpr) => AnyColumnExpr, rightCol: (right: RowExpr) => AnyColumnExpr - ): SemijoinQuery; + ): SemijoinQuery; toSql(): string; }>; -type SemijoinQuery< - SchemaDef extends UntypedSchemaDef, - LeftTable extends TypedTableDef, -> = Readonly<{ +type SemijoinQuery = Readonly<{ toSql(): string; }>; export type QueryBuilder = { readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef; -} & { - query>( - table: Name - ): Query>; -}; +} & {}; /** * A runtime reference to a table. This materializes the RowExpr for us. @@ -75,7 +62,11 @@ export type TableRef = Readonly<{ tableDef: TableDef; }>; -function createTableRefFromDef( +export type RefSource = + | TableRef + | { ref(): TableRef }; + +export function createTableRefFromDef( tableDef: TableDef ): TableRef { const cols = createRowExpr(tableDef); @@ -87,6 +78,11 @@ function createTableRefFromDef( }; } +/** + * This is only used in tests as a helper to get the TableRefs. + * @param schema + * @returns + */ export function makeQueryBuilder( schema: SchemaDef ): QueryBuilder { @@ -98,14 +94,6 @@ export function makeQueryBuilder( (qb as Record>)[table.name] = ref; } const builder = qb as QueryBuilder; - builder.query = function >( - table: Name - ): Query> { - const ref = this[table] as TableRef>; - return createScanQuery( - new TableScan>(ref) - ); - }; return Object.freeze(qb) as QueryBuilder; } @@ -135,43 +123,53 @@ export type JoinCondition< rightColumn: AnyColumnExpr; }; -class TableScan< - SchemaDef extends UntypedSchemaDef, - TableDef extends TypedTableDef, -> { +class TableScan { constructor( readonly table: TableRef, readonly where?: BooleanExpr ) {} } -function createScanQuery< - SchemaDef extends UntypedSchemaDef, - TableDef extends TypedTableDef, ->(scan: TableScan): ScanQuery { +export function from( + source: RefSource +): ScanQuery { + return createScanQuery(new TableScan(resolveTableRef(source))); +} + +function resolveTableRef( + source: RefSource +): TableRef { + if (typeof (source as { ref?: unknown }).ref === 'function') { + return (source as { ref(): TableRef }).ref(); + } + return source as TableRef; +} + +function createScanQuery( + scan: TableScan +): ScanQuery { return Object.freeze({ - filter( + where( predicate: (row: RowExpr) => BooleanExpr - ): ScanQuery { + ): ScanQuery { const newCondition = predicate(scan.table.cols); const nextWhere = scan.where ? and(scan.where, newCondition) : newCondition; - return createScanQuery( - new TableScan(scan.table, nextWhere) - ); + return createScanQuery(new TableScan(scan.table, nextWhere)); }, semijoinTo( - other: TableRef, + other: RefSource, leftCol: (left: RowExpr) => AnyColumnExpr, rightCol: (right: RowExpr) => AnyColumnExpr - ): SemijoinQuery { + ): SemijoinQuery { + const otherRef = resolveTableRef(other); const leftColumn = leftCol(scan.table.cols); - const rightColumn = rightCol(other.cols); - const semijoin: Semijoin = { + const rightColumn = rightCol(otherRef.cols); + const semijoin: Semijoin = { type: 'semijoin', left: scan.table, - right: other, + right: otherRef, leftWhereClause: scan.where, joinClause: { leftColumn, @@ -187,12 +185,9 @@ function createScanQuery< } function createSemijoinQuery< - SchemaDef extends UntypedSchemaDef, LeftTable extends TypedTableDef, RightTable extends TypedTableDef, ->( - semijoin: Semijoin -): SemijoinQuery { +>(semijoin: Semijoin): SemijoinQuery { return Object.freeze({ toSql(): string { return renderSemijoinToSql(semijoin); @@ -201,7 +196,6 @@ function createSemijoinQuery< } export type Semijoin< - SchemaDef extends UntypedSchemaDef, LeftTable extends TypedTableDef, RightTable extends TypedTableDef, > = Readonly<{ @@ -213,10 +207,9 @@ export type Semijoin< }>; export function renderSemijoinToSql< - SchemaDef extends UntypedSchemaDef, LeftTable extends TypedTableDef, RightTable extends TypedTableDef, ->(semijoin: Semijoin): string { +>(semijoin: Semijoin): string { const leftAlias = quoteIdentifier('left'); const rightAlias = quoteIdentifier('right'); const quotedRightTable = quoteIdentifier(semijoin.right.name); diff --git a/crates/bindings-typescript/src/server/reducers.ts b/crates/bindings-typescript/src/server/reducers.ts index 378db5f7c7b..8247d8c91e8 100644 --- a/crates/bindings-typescript/src/server/reducers.ts +++ b/crates/bindings-typescript/src/server/reducers.ts @@ -112,7 +112,6 @@ export type ReducerCtx = Readonly<{ timestamp: Timestamp; connectionId: ConnectionId | null; db: DbView; - queryBuilder: QueryBuilder; senderAuth: AuthCtx; }>; diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 0650a91b25d..1413e36c968 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -29,7 +29,12 @@ import { MODULE_DEF, getRegisteredSchema } from './schema'; import * as _syscalls from 'spacetime:sys@1.0'; import type { u16, u32, ModuleHooks } from 'spacetime:sys@1.0'; -import { makeQueryBuilder, type QueryBuilder } from './query'; +import { + createTableRefFromDef, + makeQueryBuilder, + type QueryBuilder, +} from './query'; +import type { TableRef } from './query'; import { ANON_VIEWS, VIEWS, @@ -204,7 +209,6 @@ export const hooks: ModuleHooks = { timestamp: new Timestamp(timestamp), connectionId: ConnectionId.nullIfZero(new ConnectionId(connId)), db: getDbView(), - queryBuilder: getQueryBuilder(), senderAuth: AuthCtxImpl.fromSystemTables( ConnectionId.nullIfZero(new ConnectionId(connId)), senderIdentity @@ -273,6 +277,13 @@ function getQueryBuilder() { return QUERY_BUILDER; } +function makeTableRef(tableName: string): TableRef { + const schema = getRegisteredSchema(); + const tableDef = schema.tables.find(td => tableName == td.name); + if (!tableDef) throw `Unregistered table ${tableName}.`; + return createTableRefFromDef(tableDef); +} + function makeDbView(module_def: RawModuleDefV9): DbView { return freeze( Object.fromEntries( @@ -290,6 +301,7 @@ function makeTableView(typespace: Typespace, table: RawTableDefV9): Table { if (rowType.tag !== 'Product') throw 'impossible'; const baseSize = bsatnBaseSize(typespace, rowType); + const tableRef = makeTableRef(table.name); const sequences = table.sequences.map(seq => { const col = rowType.value.elements[seq.column]; @@ -337,6 +349,7 @@ function makeTableView(typespace: Typespace, table: RawTableDefV9): Table { ); return count > 0; }, + ref: () => tableRef, }; const tableView = Object.assign( diff --git a/crates/bindings-typescript/src/server/table.ts b/crates/bindings-typescript/src/server/table.ts index 6c2d584166a..b8ccceb954b 100644 --- a/crates/bindings-typescript/src/server/table.ts +++ b/crates/bindings-typescript/src/server/table.ts @@ -13,6 +13,7 @@ import type { ReadonlyIndexes, } from './indexes'; import { MODULE_DEF, splitName } from './schema'; +import type { TableRef } from './query'; import { RowBuilder, type ColumnBuilder, @@ -149,6 +150,9 @@ export interface ReadonlyTableMethods { /** Iterate over all rows in the TX state. Rust Iterator → TS IterableIterator. */ iter(): IterableIterator>; [Symbol.iterator](): IterableIterator>; + + /** Return a typed reference that can be used with the query builder. */ + ref(): TableRef; } /** diff --git a/crates/bindings-typescript/tests/query.test.ts b/crates/bindings-typescript/tests/query.test.ts index 49c6be8585e..38edf445816 100644 --- a/crates/bindings-typescript/tests/query.test.ts +++ b/crates/bindings-typescript/tests/query.test.ts @@ -7,6 +7,7 @@ import { and, or, not, + from, } from '../src/server/query'; import type { UntypedSchemaDef } from '../src/server/schema'; import { table } from '../src/server/table'; @@ -30,7 +31,7 @@ const ordersTable = table( } ); -const schemaDef: UntypedSchemaDef = { +const schemaDef = { tables: [ { name: personTable.tableName, @@ -43,21 +44,20 @@ const schemaDef: UntypedSchemaDef = { indexes: ordersTable.idxs, }, ], -}; +} as const satisfies UntypedSchemaDef; describe('TableScan.toSql', () => { it('renders a full-table scan when no filters are applied', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb.query('person').toSql(); + const sql = from(qb.person).toSql(); expect(sql).toBe('SELECT * FROM "person"'); }); it('renders a WHERE clause for simple equality filters', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb - .query('person') - .filter(row => eq(row.name, literal("O'Brian"))) + const sql = from(qb.person) + .where(row => eq(row.name, literal("O'Brian"))) .toSql(); expect(sql).toBe( @@ -67,9 +67,8 @@ describe('TableScan.toSql', () => { it('renders numeric literals and column references', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb - .query('person') - .filter(row => eq(row.age, literal(42))) + const sql = from(qb.person) + .where(row => eq(row.age, literal(42))) .toSql(); expect(sql).toBe(`SELECT * FROM "person" WHERE "person"."age" = 42`); @@ -77,9 +76,8 @@ describe('TableScan.toSql', () => { it('renders AND clauses across multiple predicates', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb - .query('person') - .filter(row => + const sql = from(qb.person) + .where(row => and(eq(row.name, literal('Alice')), eq(row.age, literal(30))) ) .toSql(); @@ -91,9 +89,8 @@ describe('TableScan.toSql', () => { it('renders NOT clauses around subpredicates', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb - .query('person') - .filter(row => not(eq(row.name, literal('Bob')))) + const sql = from(qb.person) + .where(row => not(eq(row.name, literal('Bob')))) .toSql(); expect(sql).toBe( @@ -103,10 +100,9 @@ describe('TableScan.toSql', () => { it('accumulates multiple filters with AND logic', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb - .query('person') - .filter(row => eq(row.name, literal('Eve'))) - .filter(row => eq(row.age, literal(25))) + const sql = from(qb.person) + .where(row => eq(row.name, literal('Eve'))) + .where(row => eq(row.age, literal(25))) .toSql(); expect(sql).toBe( @@ -116,9 +112,8 @@ describe('TableScan.toSql', () => { it('renders OR clauses across multiple predicates', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb - .query('person') - .filter(row => + const sql = from(qb.person) + .where(row => or(eq(row.name, literal('Carol')), eq(row.name, literal('Dave'))) ) .toSql(); @@ -133,9 +128,8 @@ describe('TableScan.toSql', () => { const identity = new Identity( '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' ); - const sql = qb - .query('person') - .filter(row => eq(row.id, literal(identity))) + const sql = from(qb.person) + .where(row => eq(row.id, literal(identity))) .toSql(); expect(sql).toBe( @@ -145,8 +139,7 @@ describe('TableScan.toSql', () => { it('renders semijoin queries without additional filters', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb - .query('person') + const sql = from(qb.person) .semijoinTo( qb.orders, person => person.id, @@ -161,9 +154,8 @@ describe('TableScan.toSql', () => { it('renders semijoin queries alongside existing predicates', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb - .query('person') - .filter(row => eq(row.age, literal(42))) + const sql = from(qb.person) + .where(row => eq(row.age, literal(42))) .semijoinTo( qb.orders, person => person.id, @@ -178,9 +170,8 @@ describe('TableScan.toSql', () => { it('escapes literals when rendering semijoin filters', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb - .query('person') - .filter(row => eq(row.name, literal("O'Brian"))) + const sql = from(qb.person) + .where(row => eq(row.name, literal("O'Brian"))) .semijoinTo( qb.orders, person => person.id, @@ -195,9 +186,8 @@ describe('TableScan.toSql', () => { it('renders compound AND filters for semijoin queries', () => { const qb = makeQueryBuilder(schemaDef); - const sql = qb - .query('person') - .filter(row => + const sql = from(qb.person) + .where(row => and(eq(row.name, literal('Alice')), eq(row.age, literal(30))) ) .semijoinTo(