diff --git a/crates/bindings-typescript/src/index.ts b/crates/bindings-typescript/src/index.ts index b21040d450a..7061d1448ce 100644 --- a/crates/bindings-typescript/src/index.ts +++ b/crates/bindings-typescript/src/index.ts @@ -11,4 +11,5 @@ export * from './lib/util'; export * from './lib/identity'; export * from './lib/option'; export * from './lib/result'; +export * from './lib/query'; export * from './sdk'; diff --git a/crates/bindings-typescript/src/lib/query.ts b/crates/bindings-typescript/src/lib/query.ts new file mode 100644 index 00000000000..fa9c543b057 --- /dev/null +++ b/crates/bindings-typescript/src/lib/query.ts @@ -0,0 +1,778 @@ +import { ConnectionId } from './connection_id'; +import { Identity } from './identity'; +import type { ColumnIndex, IndexColumns, IndexOpts } from './indexes'; +import type { UntypedSchemaDef } from './schema'; +import type { TableSchema } from './table_schema'; +import type { + ColumnBuilder, + ColumnMetadata, + RowBuilder, + TypeBuilder, +} from './type_builders'; + +/** + * 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; + +// internal only — NOT exported. +// This is how we make sure queries are only created with our helpers. +const QueryBrand = Symbol('QueryBrand'); + +export interface TableTypedQuery { + readonly [QueryBrand]: true; + readonly __table?: TableDef; +} + +export interface RowTypedQuery { + readonly [QueryBrand]: true; + // Phantom type to track the row type. + readonly __row?: Row; + readonly __algebraicType?: ST; +} + +export type Query = RowTypedQuery< + RowType, + TableDef['rowType'] +>; + +export const isRowTypedQuery = (val: unknown): val is RowTypedQuery => + !!val && typeof val === 'object' && QueryBrand in (val as object); + +export const isTypedQuery = (val: unknown): val is TableTypedQuery => + !!val && typeof val === 'object' && QueryBrand in (val as object); + +export function toSql(q: Query): string { + return (q as unknown as { toSql(): string }).toSql(); +} + +// A query builder with a single table. +type From = Readonly<{ + where( + predicate: (row: RowExpr) => BooleanExpr + ): From; + rightSemijoin( + other: TableRef, + on: ( + left: IndexedRowExpr, + right: IndexedRowExpr + ) => EqExpr + ): SemijoinBuilder; + leftSemijoin( + other: TableRef, + on: ( + left: IndexedRowExpr, + right: IndexedRowExpr + ) => EqExpr + ): SemijoinBuilder; + build(): Query; +}>; + +// A query builder with a semijoin. +type SemijoinBuilder = Readonly<{ + where( + predicate: (row: RowExpr) => BooleanExpr + ): SemijoinBuilder; + build(): Query; +}>; + +class SemijoinImpl + implements SemijoinBuilder, TableTypedQuery +{ + readonly [QueryBrand] = true; + readonly type = 'semijoin' as const; + constructor( + readonly sourceQuery: FromBuilder, + readonly filterQuery: FromBuilder, + readonly joinCondition: EqExpr + ) { + if (sourceQuery.table.name === filterQuery.table.name) { + // TODO: Handle aliasing properly instead of just forbidding it. + throw new Error('Cannot semijoin a table to itself'); + } + } + + build(): Query { + return this as Query; + } + + where( + predicate: (row: RowExpr) => BooleanExpr + ): SemijoinImpl { + const nextSourceQuery = this.sourceQuery.where(predicate); + return new SemijoinImpl( + nextSourceQuery, + this.filterQuery, + this.joinCondition + ); + } + + toSql(): string { + const left = this.filterQuery; + const right = this.sourceQuery; + const leftTable = quoteIdentifier(left.table.name); + const rightTable = quoteIdentifier(right.table.name); + let sql = `SELECT ${rightTable}.* FROM ${leftTable} JOIN ${rightTable} ON ${booleanExprToSql(this.joinCondition)}`; + + const clauses: string[] = []; + if (left.whereClause) { + clauses.push(booleanExprToSql(left.whereClause)); + } + if (right.whereClause) { + clauses.push(booleanExprToSql(right.whereClause)); + } + + if (clauses.length > 0) { + const whereSql = + clauses.length === 1 + ? clauses[0] + : clauses.map(wrapInParens).join(' AND '); + sql += ` WHERE ${whereSql}`; + } + + return sql; + } +} + +class FromBuilder + implements From, TableTypedQuery +{ + readonly [QueryBrand] = true; + constructor( + readonly table: TableRef, + readonly whereClause?: BooleanExpr + ) {} + + where( + predicate: (row: RowExpr) => BooleanExpr + ): FromBuilder { + const newCondition = predicate(this.table.cols); + const nextWhere = this.whereClause + ? and(this.whereClause, newCondition) + : newCondition; + return new FromBuilder(this.table, nextWhere); + } + + rightSemijoin( + right: TableRef, + on: ( + left: IndexedRowExpr, + right: IndexedRowExpr + ) => EqExpr + ): SemijoinBuilder { + const sourceQuery = new FromBuilder(right); + const joinCondition = on( + this.table.indexedCols, + right.indexedCols + ) as EqExpr; + return new SemijoinImpl(sourceQuery, this, joinCondition); + } + + leftSemijoin( + right: TableRef, + on: ( + left: IndexedRowExpr, + right: IndexedRowExpr + ) => EqExpr + ): SemijoinBuilder { + const filterQuery = new FromBuilder(right); + const joinCondition = on( + this.table.indexedCols, + right.indexedCols + ) as EqExpr; + return new SemijoinImpl(this, filterQuery, joinCondition); + } + + toSql(): string { + return renderSelectSqlWithJoins(this.table, this.whereClause); + } + + build(): Query { + return this as Query; + } +} + +export type QueryBuilder = { + readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef & + From; +} & {}; + +/** + * 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; + indexedCols: IndexedRowExpr; + // Maybe redundant. + tableDef: TableDef; +}>; + +class TableRefImpl + implements TableRef, From +{ + readonly type = 'table' as const; + name: string; + cols: RowExpr; + indexedCols: IndexedRowExpr; + tableDef: TableDef; + constructor(tableDef: TableDef) { + this.name = tableDef.name; + this.cols = createRowExpr(tableDef); + // this.indexedCols = createIndexedRowExpr(tableDef, this.cols); + // TODO: we could create an indexedRowExpr to avoid having the extra columns. + // Right now, the objects we pass will actually have all the columns, but the + // type system will consider it an error. + this.indexedCols = this.cols; + this.tableDef = tableDef; + Object.freeze(this); + } + + asFrom(): FromBuilder { + return new FromBuilder(this); + } + + rightSemijoin( + other: TableRef, + on: ( + left: IndexedRowExpr, + right: IndexedRowExpr + ) => EqExpr + ): SemijoinBuilder { + return this.asFrom().rightSemijoin(other, on); + } + + leftSemijoin( + other: TableRef, + on: ( + left: IndexedRowExpr, + right: IndexedRowExpr + ) => EqExpr + ): SemijoinBuilder { + return this.asFrom().leftSemijoin(other, on); + } + + build(): Query { + return this.asFrom().build(); + } + + toSql(): string { + return this.asFrom().toSql(); + } + + where( + predicate: (row: RowExpr) => BooleanExpr + ): FromBuilder { + return this.asFrom().where(predicate); + } +} + +export type RefSource = + | TableRef + | { ref(): TableRef }; + +export function createTableRefFromDef( + tableDef: TableDef +): TableRef { + return new TableRefImpl(tableDef); +} + +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 Object.freeze(qb) as QueryBuilder; +} + +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]; + const column = new ColumnExpression( + tableDef.name, + columnName, + columnBuilder.typeBuilder.algebraicType as InferSpacetimeTypeOfColumn< + TableDef, + typeof columnName + > + ); + row[columnName] = Object.freeze(column); + } + return Object.freeze(row) as RowExpr; +} + +export function from( + source: RefSource +): From { + return new FromBuilder(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 renderSelectSqlWithJoins( + table: TableRef
, + where?: BooleanExpr
, + extraClauses: readonly string[] = [] +): string { + const quotedTable = quoteIdentifier(table.name); + const sql = `SELECT * FROM ${quotedTable}`; + const clauses: string[] = []; + if (where) clauses.push(booleanExprToSql(where)); + clauses.push(...extraClauses); + if (clauses.length === 0) return sql; + const whereSql = + clauses.length === 1 ? clauses[0] : clauses.map(wrapInParens).join(' AND '); + return `${sql} WHERE ${whereSql}`; +} + +// TODO: Just use UntypedTableDef if they end up being the same. +export type TypedTableDef< + Columns extends Record< + string, + ColumnBuilder> + > = Record>>, +> = { + name: string; + columns: Columns; + indexes: readonly IndexOpts[]; + rowType: RowBuilder['algebraicType']['value']; +}; + +export type TableSchemaAsTableDef< + TSchema extends TableSchema, +> = { + name: TSchema['tableName']; + columns: TSchema['rowType']['row']; + indexes: TSchema['idxs']; +}; + +type RowType = { + [K in keyof TableDef['columns']]: TableDef['columns'][K] extends ColumnBuilder< + infer T, + any, + any + > + ? T + : never; +}; + +// TODO: Consider making a smaller version of these types that doesn't expose the internals. +// Restricting it later should not break anyone in practice. +export type ColumnExpr< + TableDef extends TypedTableDef, + ColumnName extends ColumnNames, +> = ColumnExpression; + +type ColumnSpacetimeType> = + Col extends ColumnExpr + ? InferSpacetimeTypeOfColumn + : never; + +// TODO: This checks that they match, but we also need to make sure that they are comparable types, +// since you can use product types at all. +type ColumnSameSpacetime< + ThisTable extends TypedTableDef, + ThisCol extends ColumnNames, + OtherCol extends ColumnExpr, +> = [InferSpacetimeTypeOfColumn] extends [ + ColumnSpacetimeType, +] + ? [ColumnSpacetimeType] extends [ + InferSpacetimeTypeOfColumn, + ] + ? OtherCol + : never + : never; + +// Helper to get the table back from a column. +type ExtractTable> = + Col extends ColumnExpr ? T : never; + +export class ColumnExpression< + TableDef extends TypedTableDef, + ColumnName extends ColumnNames, +> { + readonly type = 'column' as const; + readonly column: ColumnName; + readonly table: TableDef['name']; + // phantom: actual runtime value is undefined + readonly tsValueType?: RowType[ColumnName]; + readonly spacetimeType: InferSpacetimeTypeOfColumn; + + constructor( + table: TableDef['name'], + column: ColumnName, + spacetimeType: InferSpacetimeTypeOfColumn + ) { + this.table = table; + this.column = column; + this.spacetimeType = spacetimeType; + } + + eq(literal: LiteralValue & RowType[ColumnName]): EqExpr; + eq>( + value: ColumnSameSpacetime + ): EqExpr>; + + // These types could be tighted, but since we declare the overloads above, it doesn't weaken the API surface. + eq(x: any): any { + return { + type: 'eq', + left: this as unknown as ValueExpr, + right: normalizeValue(x) as ValueExpr, + } as EqExpr; + } + + lt( + literal: LiteralValue & RowType[ColumnName] + ): BooleanExpr; + lt>( + value: ColumnSameSpacetime + ): BooleanExpr>; + + // These types could be tighted, but since we declare the overloads above, it doesn't weaken the API surface. + lt(x: any): any { + return { + type: 'lt', + left: this as unknown as ValueExpr, + right: normalizeValue(x) as ValueExpr, + } as BooleanExpr; + } + lte( + literal: LiteralValue & RowType[ColumnName] + ): BooleanExpr; + lte>( + value: ColumnSameSpacetime + ): BooleanExpr>; + + // These types could be tighted, but since we declare the overloads above, it doesn't weaken the API surface. + lte(x: any): any { + return { + type: 'lte', + left: this as unknown as ValueExpr, + right: normalizeValue(x) as ValueExpr, + } as BooleanExpr; + } + + gt( + literal: LiteralValue & RowType[ColumnName] + ): BooleanExpr; + gt>( + value: ColumnSameSpacetime + ): BooleanExpr>; + + // These types could be tighted, but since we declare the overloads above, it doesn't weaken the API surface. + gt(x: any): any { + return { + type: 'gt', + left: this as unknown as ValueExpr, + right: normalizeValue(x) as ValueExpr, + } as BooleanExpr; + } + gte( + literal: LiteralValue & RowType[ColumnName] + ): BooleanExpr; + gte>( + value: ColumnSameSpacetime + ): BooleanExpr>; + + // These types could be tighted, but since we declare the overloads above, it doesn't weaken the API surface. + gte(x: any): any { + return { + type: 'gte', + left: this as unknown as ValueExpr, + right: normalizeValue(x) as ValueExpr, + } as BooleanExpr; + } +} + +/** + * 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; + +// For composite indexes, we only consider it as an index over the first column in the index. +type FirstIndexColumn> = + IndexColumns extends readonly [infer Head extends string, ...infer _Rest] + ? Head + : never; + +// Columns that are indexed by something in the indexes: [...] part. +type ExplicitIndexedColumns = + TableDef['indexes'][number] extends infer I + ? I extends IndexOpts> + ? FirstIndexColumn & ColumnNames + : never + : never; + +// Columns with an index defined on the column definition. +type MetadataIndexedColumns = { + [K in ColumnNames]: ColumnIndex< + K, + TableDef['columns'][K]['columnMetadata'] + > extends never + ? never + : K; +}[ColumnNames]; + +export type IndexedColumnNames = + | ExplicitIndexedColumns + | MetadataIndexedColumns; + +export type IndexedRowExpr = Readonly<{ + readonly [C in IndexedColumnNames]: ColumnExpr; +}>; + +/** + * Acts as a row when writing filters for queries. It is a way to get column references. + */ +export type RowExpr = Readonly<{ + readonly [C in ColumnNames]: ColumnExpr; +}>; + +/** + * 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
]; + +type LiteralValue = + | string + | number + | bigint + | boolean + | Identity + | ConnectionId; + +type ValueLike = LiteralValue | ColumnExpr | LiteralExpr; +type ValueInput = + | ValueLike + | ValueExpr; + +export type ValueExpr = + | LiteralExpr + | ColumnExprForValue; + +type LiteralExpr = { + type: 'literal'; + value: Value; +}; + +export function literal( + value: Value +): ValueExpr { + return { type: 'literal', value }; +} + +// This is here to take literal values and wrap them in an AST node. +function normalizeValue(val: ValueInput): ValueExpr { + if ((val as LiteralExpr).type === 'literal') + return val as LiteralExpr; + if ( + typeof val === 'object' && + val != null && + 'type' in (val as any) && + (val as any).type === 'column' + ) { + return val as ColumnExpr; + } + return literal(val as LiteralValue); +} + +type EqExpr
= { + type: 'eq'; + left: ValueExpr; + right: ValueExpr; +} & { + _tableType?: Table; +}; + +type BooleanExpr
= ( + | { + type: 'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte'; + left: ValueExpr; + right: ValueExpr; + } + | { + type: 'and'; + clauses: readonly [ + BooleanExpr
, + BooleanExpr
, + ...BooleanExpr
[], + ]; + } + | { + type: 'or'; + clauses: readonly [ + BooleanExpr
, + BooleanExpr
, + ...BooleanExpr
[], + ]; + } + | { + type: 'not'; + clause: BooleanExpr
; + } +) & { + _tableType?: Table; + // readonly [BooleanExprBrand]: Table?; +}; + +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
, + tableAlias?: string +): string { + switch (expr.type) { + case 'eq': + return `${valueExprToSql(expr.left, tableAlias)} = ${valueExprToSql(expr.right, tableAlias)}`; + case 'ne': + return `${valueExprToSql(expr.left, tableAlias)} <> ${valueExprToSql(expr.right, tableAlias)}`; + case 'gt': + return `${valueExprToSql(expr.left, tableAlias)} > ${valueExprToSql(expr.right, tableAlias)}`; + case 'gte': + return `${valueExprToSql(expr.left, tableAlias)} >= ${valueExprToSql(expr.right, tableAlias)}`; + case 'lt': + return `${valueExprToSql(expr.left, tableAlias)} < ${valueExprToSql(expr.right, tableAlias)}`; + case 'lte': + return `${valueExprToSql(expr.left, tableAlias)} <= ${valueExprToSql(expr.right, tableAlias)}`; + case 'and': + return expr.clauses + .map(c => booleanExprToSql(c, tableAlias)) + .map(wrapInParens) + .join(' AND '); + case 'or': + return expr.clauses + .map(c => booleanExprToSql(c, tableAlias)) + .map(wrapInParens) + .join(' OR '); + case 'not': + return `NOT ${wrapInParens(booleanExprToSql(expr.clause, tableAlias))}`; + } +} + +function wrapInParens(sql: string): string { + return `(${sql})`; +} + +function valueExprToSql
( + expr: ValueExpr, + tableAlias?: string +): string { + if (isLiteralExpr(expr)) { + return literalValueToSql(expr.value); + } + const table = tableAlias ?? expr.table; + return `${quoteIdentifier(table)}.${quoteIdentifier(expr.column)}`; +} + +function literalValueToSql(value: unknown): string { + if (value === null || value === undefined) { + return 'NULL'; + } + if (value instanceof Identity || value instanceof ConnectionId) { + // We use this hex string syntax. + return `0x${value.toHexString()}`; + } + switch (typeof value) { + case 'number': + case 'bigint': + return String(value); + case 'boolean': + return value ? 'TRUE' : 'FALSE'; + case 'string': + return `'${value.replace(/'/g, "''")}'`; + default: + // It might be safer to error here? + 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'; +} + +// TODO: Fix this. +function _createIndexedRowExpr( + tableDef: TableDef, + cols: RowExpr +): IndexedRowExpr { + const indexed = new Set>(); + for (const idx of tableDef.indexes) { + if ('columns' in idx) { + const [first] = idx.columns; + if (first) indexed.add(first); + } else if ('column' in idx) { + indexed.add(idx.column); + } + } + const pickedEntries = [...indexed].map(name => [name, cols[name]]); + return Object.freeze( + Object.fromEntries(pickedEntries) + ) as IndexedRowExpr; +} diff --git a/crates/bindings-typescript/src/lib/views.ts b/crates/bindings-typescript/src/lib/views.ts index 82f9f069393..056f25af665 100644 --- a/crates/bindings-typescript/src/lib/views.ts +++ b/crates/bindings-typescript/src/lib/views.ts @@ -21,7 +21,7 @@ import { type TypeBuilder, } from './type_builders'; import { bsatnBaseSize, toPascalCase } from './util'; -import { type QueryBuilder, type RowTypedQuery } from '../server/query'; +import { type QueryBuilder, type RowTypedQuery } from './query'; export type ViewCtx = Readonly<{ sender: Identity; diff --git a/crates/bindings-typescript/src/sdk/client_api/index.ts b/crates/bindings-typescript/src/sdk/client_api/index.ts index 584808d7bbe..4b644840997 100644 --- a/crates/bindings-typescript/src/sdk/client_api/index.ts +++ b/crates/bindings-typescript/src/sdk/client_api/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.11.2 (commit 902af09c55b418c987000e739eb176a3368296ca). +// This was generated using spacetimedb cli version 1.11.3 (commit 007e6f16a6b1b698d6a8df1f08464dda335184db). /* eslint-disable */ /* tslint:disable */ @@ -12,6 +12,7 @@ import { TypeBuilder as __TypeBuilder, Uuid as __Uuid, convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, procedureSchema as __procedureSchema, procedures as __procedures, reducerSchema as __reducerSchema, @@ -25,6 +26,7 @@ import { type Event as __Event, type EventContextInterface as __EventContextInterface, type Infer as __Infer, + type QueryBuilder as __QueryBuilder, type ReducerEventContextInterface as __ReducerEventContextInterface, type RemoteModule as __RemoteModule, type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, @@ -119,7 +121,7 @@ const proceduresSchema = __procedures(); /** The remote SpacetimeDB module schema, both runtime and type information. */ const REMOTE_MODULE = { versionInfo: { - cliVersion: '1.11.2' as const, + cliVersion: '1.11.3' as const, }, tables: tablesSchema.schemaType.tables, reducers: reducersSchema.reducersType.reducers, @@ -133,6 +135,10 @@ const REMOTE_MODULE = { /** The tables available in this remote SpacetimeDB module. */ export const tables = __convertToAccessorMap(tablesSchema.schemaType.tables); +/** A typed query builder for this remote SpacetimeDB module. */ +export const query: __QueryBuilder = + __makeQueryBuilder(tablesSchema.schemaType); + /** The reducers available in this remote SpacetimeDB module. */ export const reducers = __convertToAccessorMap( reducersSchema.reducersType.reducers diff --git a/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts b/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts index 851b6baffe0..ae3f32071cc 100644 --- a/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts +++ b/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts @@ -5,6 +5,7 @@ import type { } from './event_context'; import { EventEmitter } from './event_emitter'; import type { UntypedRemoteModule } from './spacetime_module'; +import { isRowTypedQuery, toSql, type RowTypedQuery } from '../lib/query'; export class SubscriptionBuilderImpl { #onApplied?: (ctx: SubscriptionEventContextInterface) => void = @@ -78,15 +79,26 @@ export class SubscriptionBuilderImpl { * ``` */ subscribe( - query_sql: string | string[] + query_sql: string | RowTypedQuery + ): SubscriptionHandleImpl; + subscribe( + query_sql: Array> + ): SubscriptionHandleImpl; + subscribe( + query_sql: string | RowTypedQuery | Array> ): SubscriptionHandleImpl { const queries = Array.isArray(query_sql) ? query_sql : [query_sql]; if (queries.length === 0) { throw new Error('Subscriptions must have at least one query'); } + const queryStrings = queries.map(q => { + if (typeof q === 'string') return q; + if (isRowTypedQuery(q)) return toSql(q); + throw new Error('Subscriptions must be SQL strings or typed queries'); + }); return new SubscriptionHandleImpl( this.db, - queries, + queryStrings, this.#onApplied, this.#onError ); diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index 2147b4ab7d1..61df4bb591c 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -5,7 +5,7 @@ export { reducers } from '../lib/reducers'; export { SenderError, SpacetimeHostError, errors } from './errors'; export { type Reducer, type ReducerCtx } from '../lib/reducers'; export { type DbView } from './db_view'; -export { and, or, not } from './query'; +export * from './query'; export type { ProcedureCtx, TransactionCtx } from '../lib/procedures'; export { toCamelCase } from '../lib/util'; export { type Uuid } from '../lib/uuid'; diff --git a/crates/bindings-typescript/src/server/query.ts b/crates/bindings-typescript/src/server/query.ts index 0427e6753e1..afeafcdfe03 100644 --- a/crates/bindings-typescript/src/server/query.ts +++ b/crates/bindings-typescript/src/server/query.ts @@ -1,778 +1 @@ -import { ConnectionId } from '../lib/connection_id'; -import { Identity } from '../lib/identity'; -import type { ColumnIndex, IndexColumns, IndexOpts } from '../lib/indexes'; -import type { UntypedSchemaDef } from '../lib/schema'; -import type { TableSchema } from '../lib/table_schema'; -import type { - ColumnBuilder, - ColumnMetadata, - RowBuilder, - TypeBuilder, -} from '../lib/type_builders'; - -/** - * 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; - -// internal only — NOT exported. -// This is how we make sure queries are only created with our helpers. -const QueryBrand = Symbol('QueryBrand'); - -export interface TableTypedQuery { - readonly [QueryBrand]: true; - readonly __table?: TableDef; -} - -export interface RowTypedQuery { - readonly [QueryBrand]: true; - // Phantom type to track the row type. - readonly __row?: Row; - readonly __algebraicType?: ST; -} - -export type Query = RowTypedQuery< - RowType, - TableDef['rowType'] ->; - -export const isRowTypedQuery = (val: unknown): val is RowTypedQuery => - !!val && typeof val === 'object' && QueryBrand in (val as object); - -export const isTypedQuery = (val: unknown): val is TableTypedQuery => - !!val && typeof val === 'object' && QueryBrand in (val as object); - -export function toSql(q: Query): string { - return (q as unknown as { toSql(): string }).toSql(); -} - -// A query builder with a single table. -type From = Readonly<{ - where( - predicate: (row: RowExpr) => BooleanExpr - ): From; - rightSemijoin( - other: TableRef, - on: ( - left: IndexedRowExpr, - right: IndexedRowExpr - ) => EqExpr - ): SemijoinBuilder; - leftSemijoin( - other: TableRef, - on: ( - left: IndexedRowExpr, - right: IndexedRowExpr - ) => EqExpr - ): SemijoinBuilder; - build(): Query; -}>; - -// A query builder with a semijoin. -type SemijoinBuilder = Readonly<{ - where( - predicate: (row: RowExpr) => BooleanExpr - ): SemijoinBuilder; - build(): Query; -}>; - -class SemijoinImpl - implements SemijoinBuilder, TableTypedQuery -{ - readonly [QueryBrand] = true; - readonly type = 'semijoin' as const; - constructor( - readonly sourceQuery: FromBuilder, - readonly filterQuery: FromBuilder, - readonly joinCondition: EqExpr - ) { - if (sourceQuery.table.name === filterQuery.table.name) { - // TODO: Handle aliasing properly instead of just forbidding it. - throw new Error('Cannot semijoin a table to itself'); - } - } - - build(): Query { - return this as Query; - } - - where( - predicate: (row: RowExpr) => BooleanExpr - ): SemijoinImpl { - const nextSourceQuery = this.sourceQuery.where(predicate); - return new SemijoinImpl( - nextSourceQuery, - this.filterQuery, - this.joinCondition - ); - } - - toSql(): string { - const left = this.filterQuery; - const right = this.sourceQuery; - const leftTable = quoteIdentifier(left.table.name); - const rightTable = quoteIdentifier(right.table.name); - let sql = `SELECT ${rightTable}.* FROM ${leftTable} JOIN ${rightTable} ON ${booleanExprToSql(this.joinCondition)}`; - - const clauses: string[] = []; - if (left.whereClause) { - clauses.push(booleanExprToSql(left.whereClause)); - } - if (right.whereClause) { - clauses.push(booleanExprToSql(right.whereClause)); - } - - if (clauses.length > 0) { - const whereSql = - clauses.length === 1 - ? clauses[0] - : clauses.map(wrapInParens).join(' AND '); - sql += ` WHERE ${whereSql}`; - } - - return sql; - } -} - -class FromBuilder - implements From, TableTypedQuery -{ - readonly [QueryBrand] = true; - constructor( - readonly table: TableRef, - readonly whereClause?: BooleanExpr - ) {} - - where( - predicate: (row: RowExpr) => BooleanExpr - ): FromBuilder { - const newCondition = predicate(this.table.cols); - const nextWhere = this.whereClause - ? and(this.whereClause, newCondition) - : newCondition; - return new FromBuilder(this.table, nextWhere); - } - - rightSemijoin( - right: TableRef, - on: ( - left: IndexedRowExpr, - right: IndexedRowExpr - ) => EqExpr - ): SemijoinBuilder { - const sourceQuery = new FromBuilder(right); - const joinCondition = on( - this.table.indexedCols, - right.indexedCols - ) as EqExpr; - return new SemijoinImpl(sourceQuery, this, joinCondition); - } - - leftSemijoin( - right: TableRef, - on: ( - left: IndexedRowExpr, - right: IndexedRowExpr - ) => EqExpr - ): SemijoinBuilder { - const filterQuery = new FromBuilder(right); - const joinCondition = on( - this.table.indexedCols, - right.indexedCols - ) as EqExpr; - return new SemijoinImpl(this, filterQuery, joinCondition); - } - - toSql(): string { - return renderSelectSqlWithJoins(this.table, this.whereClause); - } - - build(): Query { - return this as Query; - } -} - -export type QueryBuilder = { - readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: TableRef & - From; -} & {}; - -/** - * 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; - indexedCols: IndexedRowExpr; - // Maybe redundant. - tableDef: TableDef; -}>; - -class TableRefImpl - implements TableRef, From -{ - readonly type = 'table' as const; - name: string; - cols: RowExpr; - indexedCols: IndexedRowExpr; - tableDef: TableDef; - constructor(tableDef: TableDef) { - this.name = tableDef.name; - this.cols = createRowExpr(tableDef); - // this.indexedCols = createIndexedRowExpr(tableDef, this.cols); - // TODO: we could create an indexedRowExpr to avoid having the extra columns. - // Right now, the objects we pass will actually have all the columns, but the - // type system will consider it an error. - this.indexedCols = this.cols; - this.tableDef = tableDef; - Object.freeze(this); - } - - asFrom(): FromBuilder { - return new FromBuilder(this); - } - - rightSemijoin( - other: TableRef, - on: ( - left: IndexedRowExpr, - right: IndexedRowExpr - ) => EqExpr - ): SemijoinBuilder { - return this.asFrom().rightSemijoin(other, on); - } - - leftSemijoin( - other: TableRef, - on: ( - left: IndexedRowExpr, - right: IndexedRowExpr - ) => EqExpr - ): SemijoinBuilder { - return this.asFrom().leftSemijoin(other, on); - } - - build(): Query { - return this.asFrom().build(); - } - - toSql(): string { - return this.asFrom().toSql(); - } - - where( - predicate: (row: RowExpr) => BooleanExpr - ): FromBuilder { - return this.asFrom().where(predicate); - } -} - -export type RefSource = - | TableRef - | { ref(): TableRef }; - -export function createTableRefFromDef( - tableDef: TableDef -): TableRef { - return new TableRefImpl(tableDef); -} - -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 Object.freeze(qb) as QueryBuilder; -} - -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]; - const column = new ColumnExpression( - tableDef.name, - columnName, - columnBuilder.typeBuilder.algebraicType as InferSpacetimeTypeOfColumn< - TableDef, - typeof columnName - > - ); - row[columnName] = Object.freeze(column); - } - return Object.freeze(row) as RowExpr; -} - -export function from( - source: RefSource -): From { - return new FromBuilder(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 renderSelectSqlWithJoins
( - table: TableRef
, - where?: BooleanExpr
, - extraClauses: readonly string[] = [] -): string { - const quotedTable = quoteIdentifier(table.name); - const sql = `SELECT * FROM ${quotedTable}`; - const clauses: string[] = []; - if (where) clauses.push(booleanExprToSql(where)); - clauses.push(...extraClauses); - if (clauses.length === 0) return sql; - const whereSql = - clauses.length === 1 ? clauses[0] : clauses.map(wrapInParens).join(' AND '); - return `${sql} WHERE ${whereSql}`; -} - -// TODO: Just use UntypedTableDef if they end up being the same. -export type TypedTableDef< - Columns extends Record< - string, - ColumnBuilder> - > = Record>>, -> = { - name: string; - columns: Columns; - indexes: readonly IndexOpts[]; - rowType: RowBuilder['algebraicType']['value']; -}; - -export type TableSchemaAsTableDef< - TSchema extends TableSchema, -> = { - name: TSchema['tableName']; - columns: TSchema['rowType']['row']; - indexes: TSchema['idxs']; -}; - -type RowType = { - [K in keyof TableDef['columns']]: TableDef['columns'][K] extends ColumnBuilder< - infer T, - any, - any - > - ? T - : never; -}; - -// TODO: Consider making a smaller version of these types that doesn't expose the internals. -// Restricting it later should not break anyone in practice. -export type ColumnExpr< - TableDef extends TypedTableDef, - ColumnName extends ColumnNames, -> = ColumnExpression; - -type ColumnSpacetimeType> = - Col extends ColumnExpr - ? InferSpacetimeTypeOfColumn - : never; - -// TODO: This checks that they match, but we also need to make sure that they are comparable types, -// since you can use product types at all. -type ColumnSameSpacetime< - ThisTable extends TypedTableDef, - ThisCol extends ColumnNames, - OtherCol extends ColumnExpr, -> = [InferSpacetimeTypeOfColumn] extends [ - ColumnSpacetimeType, -] - ? [ColumnSpacetimeType] extends [ - InferSpacetimeTypeOfColumn, - ] - ? OtherCol - : never - : never; - -// Helper to get the table back from a column. -type ExtractTable> = - Col extends ColumnExpr ? T : never; - -export class ColumnExpression< - TableDef extends TypedTableDef, - ColumnName extends ColumnNames, -> { - readonly type = 'column' as const; - readonly column: ColumnName; - readonly table: TableDef['name']; - // phantom: actual runtime value is undefined - readonly tsValueType?: RowType[ColumnName]; - readonly spacetimeType: InferSpacetimeTypeOfColumn; - - constructor( - table: TableDef['name'], - column: ColumnName, - spacetimeType: InferSpacetimeTypeOfColumn - ) { - this.table = table; - this.column = column; - this.spacetimeType = spacetimeType; - } - - eq(literal: LiteralValue & RowType[ColumnName]): EqExpr; - eq>( - value: ColumnSameSpacetime - ): EqExpr>; - - // These types could be tighted, but since we declare the overloads above, it doesn't weaken the API surface. - eq(x: any): any { - return { - type: 'eq', - left: this as unknown as ValueExpr, - right: normalizeValue(x) as ValueExpr, - } as EqExpr; - } - - lt( - literal: LiteralValue & RowType[ColumnName] - ): BooleanExpr; - lt>( - value: ColumnSameSpacetime - ): BooleanExpr>; - - // These types could be tighted, but since we declare the overloads above, it doesn't weaken the API surface. - lt(x: any): any { - return { - type: 'lt', - left: this as unknown as ValueExpr, - right: normalizeValue(x) as ValueExpr, - } as BooleanExpr; - } - lte( - literal: LiteralValue & RowType[ColumnName] - ): BooleanExpr; - lte>( - value: ColumnSameSpacetime - ): BooleanExpr>; - - // These types could be tighted, but since we declare the overloads above, it doesn't weaken the API surface. - lte(x: any): any { - return { - type: 'lte', - left: this as unknown as ValueExpr, - right: normalizeValue(x) as ValueExpr, - } as BooleanExpr; - } - - gt( - literal: LiteralValue & RowType[ColumnName] - ): BooleanExpr; - gt>( - value: ColumnSameSpacetime - ): BooleanExpr>; - - // These types could be tighted, but since we declare the overloads above, it doesn't weaken the API surface. - gt(x: any): any { - return { - type: 'gt', - left: this as unknown as ValueExpr, - right: normalizeValue(x) as ValueExpr, - } as BooleanExpr; - } - gte( - literal: LiteralValue & RowType[ColumnName] - ): BooleanExpr; - gte>( - value: ColumnSameSpacetime - ): BooleanExpr>; - - // These types could be tighted, but since we declare the overloads above, it doesn't weaken the API surface. - gte(x: any): any { - return { - type: 'gte', - left: this as unknown as ValueExpr, - right: normalizeValue(x) as ValueExpr, - } as BooleanExpr; - } -} - -/** - * 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; - -// For composite indexes, we only consider it as an index over the first column in the index. -type FirstIndexColumn> = - IndexColumns extends readonly [infer Head extends string, ...infer _Rest] - ? Head - : never; - -// Columns that are indexed by something in the indexes: [...] part. -type ExplicitIndexedColumns = - TableDef['indexes'][number] extends infer I - ? I extends IndexOpts> - ? FirstIndexColumn & ColumnNames - : never - : never; - -// Columns with an index defined on the column definition. -type MetadataIndexedColumns = { - [K in ColumnNames]: ColumnIndex< - K, - TableDef['columns'][K]['columnMetadata'] - > extends never - ? never - : K; -}[ColumnNames]; - -export type IndexedColumnNames = - | ExplicitIndexedColumns - | MetadataIndexedColumns; - -export type IndexedRowExpr = Readonly<{ - readonly [C in IndexedColumnNames]: ColumnExpr; -}>; - -/** - * Acts as a row when writing filters for queries. It is a way to get column references. - */ -export type RowExpr = Readonly<{ - readonly [C in ColumnNames]: ColumnExpr; -}>; - -/** - * 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
]; - -type LiteralValue = - | string - | number - | bigint - | boolean - | Identity - | ConnectionId; - -type ValueLike = LiteralValue | ColumnExpr | LiteralExpr; -type ValueInput = - | ValueLike - | ValueExpr; - -export type ValueExpr = - | LiteralExpr - | ColumnExprForValue; - -type LiteralExpr = { - type: 'literal'; - value: Value; -}; - -export function literal( - value: Value -): ValueExpr { - return { type: 'literal', value }; -} - -// This is here to take literal values and wrap them in an AST node. -function normalizeValue(val: ValueInput): ValueExpr { - if ((val as LiteralExpr).type === 'literal') - return val as LiteralExpr; - if ( - typeof val === 'object' && - val != null && - 'type' in (val as any) && - (val as any).type === 'column' - ) { - return val as ColumnExpr; - } - return literal(val as LiteralValue); -} - -type EqExpr
= { - type: 'eq'; - left: ValueExpr; - right: ValueExpr; -} & { - _tableType?: Table; -}; - -type BooleanExpr
= ( - | { - type: 'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte'; - left: ValueExpr; - right: ValueExpr; - } - | { - type: 'and'; - clauses: readonly [ - BooleanExpr
, - BooleanExpr
, - ...BooleanExpr
[], - ]; - } - | { - type: 'or'; - clauses: readonly [ - BooleanExpr
, - BooleanExpr
, - ...BooleanExpr
[], - ]; - } - | { - type: 'not'; - clause: BooleanExpr
; - } -) & { - _tableType?: Table; - // readonly [BooleanExprBrand]: Table?; -}; - -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
, - tableAlias?: string -): string { - switch (expr.type) { - case 'eq': - return `${valueExprToSql(expr.left, tableAlias)} = ${valueExprToSql(expr.right, tableAlias)}`; - case 'ne': - return `${valueExprToSql(expr.left, tableAlias)} <> ${valueExprToSql(expr.right, tableAlias)}`; - case 'gt': - return `${valueExprToSql(expr.left, tableAlias)} > ${valueExprToSql(expr.right, tableAlias)}`; - case 'gte': - return `${valueExprToSql(expr.left, tableAlias)} >= ${valueExprToSql(expr.right, tableAlias)}`; - case 'lt': - return `${valueExprToSql(expr.left, tableAlias)} < ${valueExprToSql(expr.right, tableAlias)}`; - case 'lte': - return `${valueExprToSql(expr.left, tableAlias)} <= ${valueExprToSql(expr.right, tableAlias)}`; - case 'and': - return expr.clauses - .map(c => booleanExprToSql(c, tableAlias)) - .map(wrapInParens) - .join(' AND '); - case 'or': - return expr.clauses - .map(c => booleanExprToSql(c, tableAlias)) - .map(wrapInParens) - .join(' OR '); - case 'not': - return `NOT ${wrapInParens(booleanExprToSql(expr.clause, tableAlias))}`; - } -} - -function wrapInParens(sql: string): string { - return `(${sql})`; -} - -function valueExprToSql
( - expr: ValueExpr, - tableAlias?: string -): string { - if (isLiteralExpr(expr)) { - return literalValueToSql(expr.value); - } - const table = tableAlias ?? expr.table; - return `${quoteIdentifier(table)}.${quoteIdentifier(expr.column)}`; -} - -function literalValueToSql(value: unknown): string { - if (value === null || value === undefined) { - return 'NULL'; - } - if (value instanceof Identity || value instanceof ConnectionId) { - // We use this hex string syntax. - return `0x${value.toHexString()}`; - } - switch (typeof value) { - case 'number': - case 'bigint': - return String(value); - case 'boolean': - return value ? 'TRUE' : 'FALSE'; - case 'string': - return `'${value.replace(/'/g, "''")}'`; - default: - // It might be safer to error here? - 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'; -} - -// TODO: Fix this. -function _createIndexedRowExpr( - tableDef: TableDef, - cols: RowExpr -): IndexedRowExpr { - const indexed = new Set>(); - for (const idx of tableDef.indexes) { - if ('columns' in idx) { - const [first] = idx.columns; - if (first) indexed.add(first); - } else if ('column' in idx) { - indexed.add(idx.column); - } - } - const pickedEntries = [...indexed].map(name => [name, cols[name]]); - return Object.freeze( - Object.fromEntries(pickedEntries) - ) as IndexedRowExpr; -} +export * from '../lib/query'; diff --git a/crates/bindings-typescript/test-app/src/main.tsx b/crates/bindings-typescript/test-app/src/main.tsx index 028148b3eb7..fb1ac03c242 100644 --- a/crates/bindings-typescript/test-app/src/main.tsx +++ b/crates/bindings-typescript/test-app/src/main.tsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client'; import App from './App.tsx'; import './index.css'; import { SpacetimeDBProvider } from '../../src/react'; -import { DbConnection } from './module_bindings/index.ts'; +import { DbConnection, query } from './module_bindings/index.ts'; const connectionBuilder = DbConnection.builder() .withUri('ws://localhost:3000') @@ -21,7 +21,7 @@ const connectionBuilder = DbConnection.builder() identity.toHexString() ); - conn.subscriptionBuilder().subscribe('SELECT * FROM player'); + conn.subscriptionBuilder().subscribe(query.player.build()); }); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/crates/bindings-typescript/test-app/src/module_bindings/index.ts b/crates/bindings-typescript/test-app/src/module_bindings/index.ts index 8a960614437..d0da763d072 100644 --- a/crates/bindings-typescript/test-app/src/module_bindings/index.ts +++ b/crates/bindings-typescript/test-app/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.11.2 (commit 902af09c55b418c987000e739eb176a3368296ca). +// This was generated using spacetimedb cli version 1.11.3 (commit 007e6f16a6b1b698d6a8df1f08464dda335184db). /* eslint-disable */ /* tslint:disable */ @@ -12,6 +12,7 @@ import { TypeBuilder as __TypeBuilder, Uuid as __Uuid, convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, procedureSchema as __procedureSchema, procedures as __procedures, reducerSchema as __reducerSchema, @@ -25,6 +26,7 @@ import { type Event as __Event, type EventContextInterface as __EventContextInterface, type Infer as __Infer, + type QueryBuilder as __QueryBuilder, type ReducerEventContextInterface as __ReducerEventContextInterface, type RemoteModule as __RemoteModule, type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, @@ -110,7 +112,7 @@ const proceduresSchema = __procedures(); /** The remote SpacetimeDB module schema, both runtime and type information. */ const REMOTE_MODULE = { versionInfo: { - cliVersion: '1.11.2' as const, + cliVersion: '1.11.3' as const, }, tables: tablesSchema.schemaType.tables, reducers: reducersSchema.reducersType.reducers, @@ -124,6 +126,10 @@ const REMOTE_MODULE = { /** The tables available in this remote SpacetimeDB module. */ export const tables = __convertToAccessorMap(tablesSchema.schemaType.tables); +/** A typed query builder for this remote SpacetimeDB module. */ +export const query: __QueryBuilder = + __makeQueryBuilder(tablesSchema.schemaType); + /** The reducers available in this remote SpacetimeDB module. */ export const reducers = __convertToAccessorMap( reducersSchema.reducersType.reducers diff --git a/crates/bindings-typescript/tests/client_query.test.ts b/crates/bindings-typescript/tests/client_query.test.ts new file mode 100644 index 00000000000..9c8350ac742 --- /dev/null +++ b/crates/bindings-typescript/tests/client_query.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest'; +import { Identity } from '../src/lib/identity'; +import { and, not, or, toSql } from '../src/lib/query'; +import { query } from '../test-app/src/module_bindings'; + +describe('ClientQuery.toSql', () => { + it('renders a full-table scan when no filters are applied', () => { + const sql = toSql(query.player.build()); + + expect(sql).toBe('SELECT * FROM "player"'); + }); + + it('renders a WHERE clause for simple equality filters', () => { + const sql = toSql( + query.player + .where(row => row.name.eq("O'Brian")) + .build() + ); + + expect(sql).toBe( + `SELECT * FROM "player" WHERE "player"."name" = 'O''Brian'` + ); + }); + + it('renders numeric literals and column references', () => { + const sql = toSql( + query.player + .where(row => row.id.eq(42)) + .build() + ); + + expect(sql).toBe(`SELECT * FROM "player" WHERE "player"."id" = 42`); + }); + + it('renders AND clauses across multiple predicates', () => { + const sql = toSql( + query.player + .where(row => and(row.name.eq('Alice'), row.id.eq(30))) + .build() + ); + + expect(sql).toBe( + `SELECT * FROM "player" WHERE ("player"."name" = 'Alice') AND ("player"."id" = 30)` + ); + }); + + it('renders NOT clauses around subpredicates', () => { + const sql = toSql( + query.player + .where(row => not(row.name.eq('Bob'))) + .build() + ); + + expect(sql).toBe( + `SELECT * FROM "player" WHERE NOT ("player"."name" = 'Bob')` + ); + }); + + it('accumulates multiple filters with AND logic', () => { + const sql = toSql( + query.player + .where(row => row.name.eq('Eve')) + .where(row => row.id.eq(25)) + .build() + ); + + expect(sql).toBe( + `SELECT * FROM "player" WHERE ("player"."name" = 'Eve') AND ("player"."id" = 25)` + ); + }); + + it('renders OR clauses across multiple predicates', () => { + const sql = toSql( + query.player + .where(row => or(row.name.eq('Carol'), row.name.eq('Dave'))) + .build() + ); + + expect(sql).toBe( + `SELECT * FROM "player" WHERE ("player"."name" = 'Carol') OR ("player"."name" = 'Dave')` + ); + }); + + it('renders Identity literals using their hex form', () => { + const identity = new Identity( + '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + ); + const sql = toSql( + query.user + .where(row => row.identity.eq(identity)) + .build() + ); + + expect(sql).toBe( + `SELECT * FROM "user" WHERE "user"."identity" = 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` + ); + }); + + it('renders semijoin queries without additional filters', () => { + const sql = toSql( + query.player + .rightSemijoin(query.unindexed_player, (player, other) => + other.id.eq(player.id) + ) + .build() + ); + + expect(sql).toBe( + `SELECT "unindexed_player".* FROM "player" JOIN "unindexed_player" ON "unindexed_player"."id" = "player"."id"` + ); + }); + + it('renders semijoin queries alongside existing predicates', () => { + const sql = toSql( + query.player + .where(row => row.id.eq(42)) + .rightSemijoin(query.unindexed_player, (player, other) => + other.id.eq(player.id) + ) + .build() + ); + + expect(sql).toBe( + `SELECT "unindexed_player".* FROM "player" JOIN "unindexed_player" ON "unindexed_player"."id" = "player"."id" WHERE "player"."id" = 42` + ); + }); + + it('escapes literals when rendering semijoin filters', () => { + const sql = toSql( + query.player + .where(row => row.name.eq("O'Brian")) + .rightSemijoin(query.unindexed_player, (player, other) => + other.id.eq(player.id) + ) + .build() + ); + + expect(sql).toBe( + `SELECT "unindexed_player".* FROM "player" JOIN "unindexed_player" ON "unindexed_player"."id" = "player"."id" WHERE "player"."name" = 'O''Brian'` + ); + }); + + it('renders compound AND filters for semijoin queries', () => { + const sql = toSql( + query.player + .where(row => and(row.name.eq('Alice'), row.id.eq(30))) + .rightSemijoin(query.unindexed_player, (player, other) => + other.id.eq(player.id) + ) + .build() + ); + + expect(sql).toBe( + `SELECT "unindexed_player".* FROM "player" JOIN "unindexed_player" ON "unindexed_player"."id" = "player"."id" WHERE ("player"."name" = 'Alice') AND ("player"."id" = 30)` + ); + }); + + it('basic where', () => { + const sql = toSql(query.player.where(row => row.name.eq('Gadget')).build()); + expect(sql).toBe( + `SELECT * FROM "player" WHERE "player"."name" = 'Gadget'` + ); + }); + + it('basic where lt', () => { + const sql = toSql(query.player.where(row => row.name.lt('Gadget')).build()); + expect(sql).toBe( + `SELECT * FROM "player" WHERE "player"."name" < 'Gadget'` + ); + }); + + it('basic where lte', () => { + const sql = toSql(query.player.where(row => row.name.lte('Gadget')).build()); + expect(sql).toBe( + `SELECT * FROM "player" WHERE "player"."name" <= 'Gadget'` + ); + }); + + it('basic where gt', () => { + const sql = toSql(query.player.where(row => row.name.gt('Gadget')).build()); + expect(sql).toBe( + `SELECT * FROM "player" WHERE "player"."name" > 'Gadget'` + ); + }); + + it('basic where gte', () => { + const sql = toSql(query.player.where(row => row.name.gte('Gadget')).build()); + expect(sql).toBe( + `SELECT * FROM "player" WHERE "player"."name" >= 'Gadget'` + ); + }); + + it('basic semijoin', () => { + const sql = toSql( + query.player + .rightSemijoin(query.unindexed_player, (player, other) => + other.id.eq(player.id) + ) + .build() + ); + expect(sql).toBe( + `SELECT "unindexed_player".* FROM "player" JOIN "unindexed_player" ON "unindexed_player"."id" = "player"."id"` + ); + }); + + it('basic left semijoin', () => { + const sql = toSql( + query.player + .leftSemijoin(query.unindexed_player, (player, other) => + other.id.eq(player.id) + ) + .build() + ); + expect(sql).toBe( + `SELECT "player".* FROM "unindexed_player" JOIN "player" ON "unindexed_player"."id" = "player"."id"` + ); + }); + + it('semijoin with filters on both sides', () => { + const sql = toSql( + query.player + .where(row => row.id.eq(42)) + .rightSemijoin(query.unindexed_player, (player, other) => + other.id.eq(player.id) + ) + .where(row => row.name.eq('Gadget')) + .build() + ); + expect(sql).toBe( + `SELECT "unindexed_player".* FROM "player" JOIN "unindexed_player" ON "unindexed_player"."id" = "player"."id" WHERE ("player"."id" = 42) AND ("unindexed_player"."name" = 'Gadget')` + ); + }); +}); diff --git a/crates/codegen/src/typescript.rs b/crates/codegen/src/typescript.rs index 90788903ff3..5059330882a 100644 --- a/crates/codegen/src/typescript.rs +++ b/crates/codegen/src/typescript.rs @@ -346,6 +346,12 @@ impl Lang for TypeScript { "export const tables = __convertToAccessorMap(tablesSchema.schemaType.tables);" ); writeln!(out); + writeln!(out, "/** A typed query builder for this remote SpacetimeDB module. */"); + writeln!( + out, + "export const query: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType);" + ); + writeln!(out); writeln!(out, "/** The reducers available in this remote SpacetimeDB module. */"); writeln!( out, @@ -455,6 +461,8 @@ fn print_index_imports(out: &mut Indenter) { "Uuid as __Uuid", "DbConnectionBuilder as __DbConnectionBuilder", "convertToAccessorMap as __convertToAccessorMap", + "makeQueryBuilder as __makeQueryBuilder", + "type QueryBuilder as __QueryBuilder", "type EventContextInterface as __EventContextInterface", "type ReducerEventContextInterface as __ReducerEventContextInterface", "type SubscriptionEventContextInterface as __SubscriptionEventContextInterface", diff --git a/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap b/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap index 6ee9962529e..c4fbcde807f 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap @@ -233,6 +233,7 @@ import { TypeBuilder as __TypeBuilder, Uuid as __Uuid, convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, procedureSchema as __procedureSchema, procedures as __procedures, reducerSchema as __reducerSchema, @@ -246,6 +247,7 @@ import { type Event as __Event, type EventContextInterface as __EventContextInterface, type Infer as __Infer, + type QueryBuilder as __QueryBuilder, type ReducerEventContextInterface as __ReducerEventContextInterface, type RemoteModule as __RemoteModule, type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, @@ -559,6 +561,9 @@ const REMOTE_MODULE = { /** The tables available in this remote SpacetimeDB module. */ export const tables = __convertToAccessorMap(tablesSchema.schemaType.tables); +/** A typed query builder for this remote SpacetimeDB module. */ +export const query: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType); + /** The reducers available in this remote SpacetimeDB module. */ export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); diff --git a/templates/basic-react/src/module_bindings/index.ts b/templates/basic-react/src/module_bindings/index.ts index 5aedda6b8bb..93bd03119cb 100644 --- a/templates/basic-react/src/module_bindings/index.ts +++ b/templates/basic-react/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.11.2 (commit 902af09c55b418c987000e739eb176a3368296ca). +// This was generated using spacetimedb cli version 1.11.3 (commit 007e6f16a6b1b698d6a8df1f08464dda335184db). /* eslint-disable */ /* tslint:disable */ @@ -12,6 +12,7 @@ import { TypeBuilder as __TypeBuilder, Uuid as __Uuid, convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, procedureSchema as __procedureSchema, procedures as __procedures, reducerSchema as __reducerSchema, @@ -25,6 +26,7 @@ import { type Event as __Event, type EventContextInterface as __EventContextInterface, type Infer as __Infer, + type QueryBuilder as __QueryBuilder, type ReducerEventContextInterface as __ReducerEventContextInterface, type RemoteModule as __RemoteModule, type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, @@ -85,7 +87,7 @@ const proceduresSchema = __procedures(); /** The remote SpacetimeDB module schema, both runtime and type information. */ const REMOTE_MODULE = { versionInfo: { - cliVersion: '1.11.2' as const, + cliVersion: '1.11.3' as const, }, tables: tablesSchema.schemaType.tables, reducers: reducersSchema.reducersType.reducers, @@ -99,6 +101,10 @@ const REMOTE_MODULE = { /** The tables available in this remote SpacetimeDB module. */ export const tables = __convertToAccessorMap(tablesSchema.schemaType.tables); +/** A typed query builder for this remote SpacetimeDB module. */ +export const query: __QueryBuilder = + __makeQueryBuilder(tablesSchema.schemaType); + /** The reducers available in this remote SpacetimeDB module. */ export const reducers = __convertToAccessorMap( reducersSchema.reducersType.reducers diff --git a/templates/basic-typescript/src/module_bindings/index.ts b/templates/basic-typescript/src/module_bindings/index.ts index 5aedda6b8bb..93bd03119cb 100644 --- a/templates/basic-typescript/src/module_bindings/index.ts +++ b/templates/basic-typescript/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.11.2 (commit 902af09c55b418c987000e739eb176a3368296ca). +// This was generated using spacetimedb cli version 1.11.3 (commit 007e6f16a6b1b698d6a8df1f08464dda335184db). /* eslint-disable */ /* tslint:disable */ @@ -12,6 +12,7 @@ import { TypeBuilder as __TypeBuilder, Uuid as __Uuid, convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, procedureSchema as __procedureSchema, procedures as __procedures, reducerSchema as __reducerSchema, @@ -25,6 +26,7 @@ import { type Event as __Event, type EventContextInterface as __EventContextInterface, type Infer as __Infer, + type QueryBuilder as __QueryBuilder, type ReducerEventContextInterface as __ReducerEventContextInterface, type RemoteModule as __RemoteModule, type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, @@ -85,7 +87,7 @@ const proceduresSchema = __procedures(); /** The remote SpacetimeDB module schema, both runtime and type information. */ const REMOTE_MODULE = { versionInfo: { - cliVersion: '1.11.2' as const, + cliVersion: '1.11.3' as const, }, tables: tablesSchema.schemaType.tables, reducers: reducersSchema.reducersType.reducers, @@ -99,6 +101,10 @@ const REMOTE_MODULE = { /** The tables available in this remote SpacetimeDB module. */ export const tables = __convertToAccessorMap(tablesSchema.schemaType.tables); +/** A typed query builder for this remote SpacetimeDB module. */ +export const query: __QueryBuilder = + __makeQueryBuilder(tablesSchema.schemaType); + /** The reducers available in this remote SpacetimeDB module. */ export const reducers = __convertToAccessorMap( reducersSchema.reducersType.reducers diff --git a/templates/quickstart-chat-typescript/src/module_bindings/index.ts b/templates/quickstart-chat-typescript/src/module_bindings/index.ts index 203c0b496a6..1c38278c8ab 100644 --- a/templates/quickstart-chat-typescript/src/module_bindings/index.ts +++ b/templates/quickstart-chat-typescript/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.11.3 (commit 36dac98fda48c2f3961e11d1c11d4121582f7306). +// This was generated using spacetimedb cli version 1.11.3 (commit 007e6f16a6b1b698d6a8df1f08464dda335184db). /* eslint-disable */ /* tslint:disable */ @@ -12,6 +12,7 @@ import { TypeBuilder as __TypeBuilder, Uuid as __Uuid, convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, procedureSchema as __procedureSchema, procedures as __procedures, reducerSchema as __reducerSchema, @@ -25,6 +26,7 @@ import { type Event as __Event, type EventContextInterface as __EventContextInterface, type Infer as __Infer, + type QueryBuilder as __QueryBuilder, type ReducerEventContextInterface as __ReducerEventContextInterface, type RemoteModule as __RemoteModule, type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, @@ -119,6 +121,10 @@ const REMOTE_MODULE = { /** The tables available in this remote SpacetimeDB module. */ export const tables = __convertToAccessorMap(tablesSchema.schemaType.tables); +/** A typed query builder for this remote SpacetimeDB module. */ +export const query: __QueryBuilder = + __makeQueryBuilder(tablesSchema.schemaType); + /** The reducers available in this remote SpacetimeDB module. */ export const reducers = __convertToAccessorMap( reducersSchema.reducersType.reducers