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);
+ });
+});