Skip to content

Commit f160d30

Browse files
Better error message for semijoin predicates (#4605)
# Description of Changes `and` and `or` are scoped to only a single table. So using them in a semijoin with two tables will fail to compile. Previously this resulted in a gnarly error message (see #4586). This patch improves the error message. # API and ABI breaking changes None # Expected complexity level and risk 2 # Testing Adds error message assertion tests.
1 parent c7ef234 commit f160d30

3 files changed

Lines changed: 142 additions & 16 deletions

File tree

crates/bindings-typescript/src/lib/query.ts

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -710,15 +710,38 @@ type BooleanExprData<Table extends TypedTableDef> = (
710710
_tableType?: Table;
711711
};
712712

713+
type AndOrMixedTableScopeError = {
714+
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;
715+
};
716+
717+
type RequireSameAndOrTable<
718+
Expected extends TypedTableDef,
719+
Actual extends TypedTableDef,
720+
> = [Expected] extends [Actual]
721+
? [Actual] extends [Expected]
722+
? unknown
723+
: AndOrMixedTableScopeError
724+
: AndOrMixedTableScopeError;
725+
713726
export class BooleanExpr<Table extends TypedTableDef> {
714727
constructor(readonly data: BooleanExprData<Table>) {}
715728

716-
and(other: BooleanExpr<Table>): BooleanExpr<Table> {
717-
return new BooleanExpr({ type: 'and', clauses: [this.data, other.data] });
729+
and<OtherTable extends TypedTableDef>(
730+
other: BooleanExpr<OtherTable> & RequireSameAndOrTable<Table, OtherTable>
731+
): BooleanExpr<Table> {
732+
return new BooleanExpr({
733+
type: 'and',
734+
clauses: [this.data, other.data as BooleanExprData<Table>],
735+
});
718736
}
719737

720-
or(other: BooleanExpr<Table>): BooleanExpr<Table> {
721-
return new BooleanExpr({ type: 'or', clauses: [this.data, other.data] });
738+
or<OtherTable extends TypedTableDef>(
739+
other: BooleanExpr<OtherTable> & RequireSameAndOrTable<Table, OtherTable>
740+
): BooleanExpr<Table> {
741+
return new BooleanExpr({
742+
type: 'or',
743+
clauses: [this.data, other.data as BooleanExprData<Table>],
744+
});
722745
}
723746

724747
not(): BooleanExpr<Table> {
@@ -732,28 +755,40 @@ export function not<T extends TypedTableDef>(
732755
return new BooleanExpr({ type: 'not', clause: clause.data });
733756
}
734757

735-
export function and<T extends TypedTableDef>(
736-
...clauses: readonly [BooleanExpr<T>, BooleanExpr<T>, ...BooleanExpr<T>[]]
737-
): BooleanExpr<T> {
758+
export function and<
759+
Table extends TypedTableDef,
760+
OtherTable extends TypedTableDef,
761+
>(
762+
first: BooleanExpr<Table>,
763+
second: BooleanExpr<OtherTable> & RequireSameAndOrTable<Table, OtherTable>,
764+
...rest: readonly BooleanExpr<Table>[]
765+
): BooleanExpr<Table> {
766+
const clauses = [first, second, ...rest];
738767
return new BooleanExpr({
739768
type: 'and',
740769
clauses: clauses.map(c => c.data) as [
741-
BooleanExprData<T>,
742-
BooleanExprData<T>,
743-
...BooleanExprData<T>[],
770+
BooleanExprData<Table>,
771+
BooleanExprData<Table>,
772+
...BooleanExprData<Table>[],
744773
],
745774
});
746775
}
747776

748-
export function or<T extends TypedTableDef>(
749-
...clauses: readonly [BooleanExpr<T>, BooleanExpr<T>, ...BooleanExpr<T>[]]
750-
): BooleanExpr<T> {
777+
export function or<
778+
Table extends TypedTableDef,
779+
OtherTable extends TypedTableDef,
780+
>(
781+
first: BooleanExpr<Table>,
782+
second: BooleanExpr<OtherTable> & RequireSameAndOrTable<Table, OtherTable>,
783+
...rest: readonly BooleanExpr<Table>[]
784+
): BooleanExpr<Table> {
785+
const clauses = [first, second, ...rest];
751786
return new BooleanExpr({
752787
type: 'or',
753788
clauses: clauses.map(c => c.data) as [
754-
BooleanExprData<T>,
755-
BooleanExprData<T>,
756-
...BooleanExprData<T>[],
789+
BooleanExprData<Table>,
790+
BooleanExprData<Table>,
791+
...BooleanExprData<Table>[],
757792
],
758793
});
759794
}

crates/bindings-typescript/src/server/view.test-d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ spacetime.anonymousView({ name: 'v5', public: true }, arrayRetValue, ctx => {
161161
.where(row => row.id.eq(5))
162162
.leftSemijoin(ctx.from.order, (p, o) => p.name.eq(o.person_name))
163163
.build();
164+
const _mixedScopeAndInJoinPredicate = ctx.from.person
165+
// @ts-expect-error semijoin on(...) only supports one table scope for and/or clauses.
166+
.leftSemijoin(ctx.from.order, (p, o) => p.id.eq(o.id).and(o.id.eq(5)))
167+
.build();
164168
return ctx.from.person
165169
.where(row => row.id.eq(5))
166170
.leftSemijoin(ctx.from.order, (p, o) => p.id.eq(o.id))
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
import * as ts from 'typescript';
6+
import { describe, expect, it } from 'vitest';
7+
8+
const bindingsRoot = path.resolve(
9+
path.dirname(fileURLToPath(import.meta.url)),
10+
'..'
11+
);
12+
13+
function runTypecheck(semijoinPredicateExpr: string) {
14+
const tmpDir = mkdtempSync(path.join(tmpdir(), 'stdb-query-diag-'));
15+
const reproPath = path.join(tmpDir, 'repro.ts');
16+
17+
const imports = {
18+
query: path.join(bindingsRoot, 'src/lib/query.ts'),
19+
moduleBindings: path.join(
20+
bindingsRoot,
21+
'test-app/src/module_bindings/index.ts'
22+
),
23+
sys: path.join(bindingsRoot, 'src/server/sys.d.ts'),
24+
};
25+
26+
const source = `
27+
import { and } from ${JSON.stringify(imports.query)};
28+
import { tables } from ${JSON.stringify(imports.moduleBindings)};
29+
30+
tables.player
31+
.leftSemijoin(tables.unindexed_player, (l, r) => ${semijoinPredicateExpr})
32+
.build();
33+
`;
34+
35+
writeFileSync(reproPath, source);
36+
37+
try {
38+
const options: ts.CompilerOptions = {
39+
target: ts.ScriptTarget.ESNext,
40+
module: ts.ModuleKind.ESNext,
41+
strict: true,
42+
noEmit: true,
43+
skipLibCheck: true,
44+
forceConsistentCasingInFileNames: true,
45+
allowImportingTsExtensions: true,
46+
noImplicitAny: true,
47+
moduleResolution: ts.ModuleResolutionKind.Bundler,
48+
useDefineForClassFields: true,
49+
verbatimModuleSyntax: true,
50+
isolatedModules: true,
51+
};
52+
53+
const host = ts.createCompilerHost(options);
54+
const program = ts.createProgram([reproPath, imports.sys], options, host);
55+
const diagnostics = ts.getPreEmitDiagnostics(program);
56+
const output = diagnostics
57+
.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'))
58+
.join('\n');
59+
60+
return {
61+
status: diagnostics.length === 0 ? 0 : 1,
62+
output,
63+
};
64+
} finally {
65+
rmSync(tmpDir, { recursive: true, force: true });
66+
}
67+
}
68+
69+
describe('query builder diagnostics', () => {
70+
const messageStart =
71+
'Cannot combine predicates from different table scopes with and/or.';
72+
const messageHint = 'move extra predicates to .where(...)';
73+
74+
it('reports a clear message for free-floating and(...) in semijoin predicates', () => {
75+
const { status, output } = runTypecheck('and(l.id.eq(r.id), r.id.eq(5))');
76+
expect(status).not.toBe(0);
77+
expect(output).toContain(messageStart);
78+
expect(output).toContain(messageHint);
79+
});
80+
81+
it('reports a clear message for method-style .and(...) in semijoin predicates', () => {
82+
const { status, output } = runTypecheck('l.id.eq(r.id).and(r.id.eq(5))');
83+
expect(status).not.toBe(0);
84+
expect(output).toContain(messageStart);
85+
expect(output).toContain(messageHint);
86+
});
87+
});

0 commit comments

Comments
 (0)