diff --git a/crates/bindings-typescript/src/lib/query.ts b/crates/bindings-typescript/src/lib/query.ts index 2d7bcf052c7..e9c6109ccb6 100644 --- a/crates/bindings-typescript/src/lib/query.ts +++ b/crates/bindings-typescript/src/lib/query.ts @@ -710,15 +710,38 @@ type BooleanExprData = ( _tableType?: Table; }; +type AndOrMixedTableScopeError = { + readonly 'Cannot combine predicates from different table scopes with and/or. In semijoin on(...), keep only the join equality and move extra predicates to .where(...).': never; +}; + +type RequireSameAndOrTable< + Expected extends TypedTableDef, + Actual extends TypedTableDef, +> = [Expected] extends [Actual] + ? [Actual] extends [Expected] + ? unknown + : AndOrMixedTableScopeError + : AndOrMixedTableScopeError; + export class BooleanExpr
{ constructor(readonly data: BooleanExprData
) {} - and(other: BooleanExpr
): BooleanExpr
{ - return new BooleanExpr({ type: 'and', clauses: [this.data, other.data] }); + and( + other: BooleanExpr & RequireSameAndOrTable + ): BooleanExpr
{ + return new BooleanExpr({ + type: 'and', + clauses: [this.data, other.data as BooleanExprData
], + }); } - or(other: BooleanExpr
): BooleanExpr
{ - return new BooleanExpr({ type: 'or', clauses: [this.data, other.data] }); + or( + other: BooleanExpr & RequireSameAndOrTable + ): BooleanExpr
{ + return new BooleanExpr({ + type: 'or', + clauses: [this.data, other.data as BooleanExprData
], + }); } not(): BooleanExpr
{ @@ -732,28 +755,40 @@ export function not( return new BooleanExpr({ type: 'not', clause: clause.data }); } -export function and( - ...clauses: readonly [BooleanExpr, BooleanExpr, ...BooleanExpr[]] -): BooleanExpr { +export function and< + Table extends TypedTableDef, + OtherTable extends TypedTableDef, +>( + first: BooleanExpr
, + second: BooleanExpr & RequireSameAndOrTable, + ...rest: readonly BooleanExpr
[] +): BooleanExpr
{ + const clauses = [first, second, ...rest]; return new BooleanExpr({ type: 'and', clauses: clauses.map(c => c.data) as [ - BooleanExprData, - BooleanExprData, - ...BooleanExprData[], + BooleanExprData
, + BooleanExprData
, + ...BooleanExprData
[], ], }); } -export function or( - ...clauses: readonly [BooleanExpr, BooleanExpr, ...BooleanExpr[]] -): BooleanExpr { +export function or< + Table extends TypedTableDef, + OtherTable extends TypedTableDef, +>( + first: BooleanExpr
, + second: BooleanExpr & RequireSameAndOrTable, + ...rest: readonly BooleanExpr
[] +): BooleanExpr
{ + const clauses = [first, second, ...rest]; return new BooleanExpr({ type: 'or', clauses: clauses.map(c => c.data) as [ - BooleanExprData, - BooleanExprData, - ...BooleanExprData[], + BooleanExprData
, + BooleanExprData
, + ...BooleanExprData
[], ], }); } diff --git a/crates/bindings-typescript/src/server/view.test-d.ts b/crates/bindings-typescript/src/server/view.test-d.ts index f2c220fd6d0..1ce372f3190 100644 --- a/crates/bindings-typescript/src/server/view.test-d.ts +++ b/crates/bindings-typescript/src/server/view.test-d.ts @@ -161,6 +161,10 @@ spacetime.anonymousView({ name: 'v5', public: true }, arrayRetValue, ctx => { .where(row => row.id.eq(5)) .leftSemijoin(ctx.from.order, (p, o) => p.name.eq(o.person_name)) .build(); + const _mixedScopeAndInJoinPredicate = ctx.from.person + // @ts-expect-error semijoin on(...) only supports one table scope for and/or clauses. + .leftSemijoin(ctx.from.order, (p, o) => p.id.eq(o.id).and(o.id.eq(5))) + .build(); return ctx.from.person .where(row => row.id.eq(5)) .leftSemijoin(ctx.from.order, (p, o) => p.id.eq(o.id)) diff --git a/crates/bindings-typescript/tests/query_error_message.test.ts b/crates/bindings-typescript/tests/query_error_message.test.ts new file mode 100644 index 00000000000..ffb013853a8 --- /dev/null +++ b/crates/bindings-typescript/tests/query_error_message.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const bindingsRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' +); + +function runTypecheck(semijoinPredicateExpr: string) { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'stdb-query-diag-')); + const reproPath = path.join(tmpDir, 'repro.ts'); + + const imports = { + query: path.join(bindingsRoot, 'src/lib/query.ts'), + moduleBindings: path.join( + bindingsRoot, + 'test-app/src/module_bindings/index.ts' + ), + sys: path.join(bindingsRoot, 'src/server/sys.d.ts'), + }; + + const source = ` +import { and } from ${JSON.stringify(imports.query)}; +import { tables } from ${JSON.stringify(imports.moduleBindings)}; + +tables.player + .leftSemijoin(tables.unindexed_player, (l, r) => ${semijoinPredicateExpr}) + .build(); +`; + + writeFileSync(reproPath, source); + + try { + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + strict: true, + noEmit: true, + skipLibCheck: true, + forceConsistentCasingInFileNames: true, + allowImportingTsExtensions: true, + noImplicitAny: true, + moduleResolution: ts.ModuleResolutionKind.Bundler, + useDefineForClassFields: true, + verbatimModuleSyntax: true, + isolatedModules: true, + }; + + const host = ts.createCompilerHost(options); + const program = ts.createProgram([reproPath, imports.sys], options, host); + const diagnostics = ts.getPreEmitDiagnostics(program); + const output = diagnostics + .map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')) + .join('\n'); + + return { + status: diagnostics.length === 0 ? 0 : 1, + output, + }; + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe('query builder diagnostics', () => { + const messageStart = + 'Cannot combine predicates from different table scopes with and/or.'; + const messageHint = 'move extra predicates to .where(...)'; + + it('reports a clear message for free-floating and(...) in semijoin predicates', () => { + const { status, output } = runTypecheck('and(l.id.eq(r.id), r.id.eq(5))'); + expect(status).not.toBe(0); + expect(output).toContain(messageStart); + expect(output).toContain(messageHint); + }); + + it('reports a clear message for method-style .and(...) in semijoin predicates', () => { + const { status, output } = runTypecheck('l.id.eq(r.id).and(r.id.eq(5))'); + expect(status).not.toBe(0); + expect(output).toContain(messageStart); + expect(output).toContain(messageHint); + }); +});