Skip to content

Commit 71f9e7c

Browse files
emrberkclaude
andcommitted
fix implicit select parsing and pivot statement boundaries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7daf7db commit 71f9e7c

6 files changed

Lines changed: 123 additions & 29 deletions

File tree

src/index.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,14 @@ export function parseToAst(sql: string): ParseResult {
4141
});
4242
}
4343

44-
if (errors.length > 0) {
45-
return { ast: [], errors };
44+
let ast: Statement[] = [];
45+
try {
46+
ast = visitor.visit(cst) as Statement[];
47+
} catch (e) {
48+
// Visitor may fail on incomplete CST — return what we have
4649
}
4750

48-
const ast = visitor.visit(cst) as Statement[];
49-
50-
return { ast, errors: [] };
51+
return { ast, errors };
5152
}
5253

5354
/**

src/parser/parser.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -443,10 +443,7 @@ class QuestDBParser extends CstParser {
443443
GATE: () => this.LA(1).tokenType === Compile && this.LA(2).tokenType === View,
444444
ALT: () => this.SUBRULE(this.compileViewStatement),
445445
},
446-
{
447-
GATE: this.BACKTRACK(this.implicitSelectStatement),
448-
ALT: () => this.SUBRULE(this.implicitSelectStatement),
449-
},
446+
{ ALT: () => this.SUBRULE(this.implicitSelectStatement) },
450447
]);
451448
});
452449

@@ -3035,7 +3032,7 @@ class QuestDBParser extends CstParser {
30353032
this.SUBRULE(this.pivotBody);
30363033
this.CONSUME2(RParen);
30373034
this.OPTION2(() => {
3038-
this.OPTION3(() => this.CONSUME(As));
3035+
this.CONSUME(As);
30393036
this.SUBRULE6(this.identifier);
30403037
});
30413038
this.OPTION4(() => this.SUBRULE(this.orderByClause));

src/parser/visitor.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -323,25 +323,25 @@ class QuestDBVisitor extends BaseVisitor {
323323
columns: [{ type: "star" } as AST.SelectItem],
324324
};
325325
if (ctx.fromClause) {
326-
result.from = this.visit(ctx.fromClause);
326+
result.from = this.visitSafe(ctx.fromClause);
327327
}
328328
if (ctx.whereClause) {
329-
result.where = this.visit(ctx.whereClause);
329+
result.where = this.visitSafe(ctx.whereClause);
330330
}
331331
if (ctx.sampleByClause) {
332-
result.sampleBy = this.visit(ctx.sampleByClause);
332+
result.sampleBy = this.visitSafe(ctx.sampleByClause);
333333
}
334334
if (ctx.latestOnClause) {
335-
result.latestOn = this.visit(ctx.latestOnClause);
335+
result.latestOn = this.visitSafe(ctx.latestOnClause);
336336
}
337337
if (ctx.groupByClause) {
338-
result.groupBy = this.visit(ctx.groupByClause);
338+
result.groupBy = this.visitSafe(ctx.groupByClause);
339339
}
340340
if (ctx.orderByClause) {
341-
result.orderBy = this.visit(ctx.orderByClause);
341+
result.orderBy = this.visitSafe(ctx.orderByClause);
342342
}
343343
if (ctx.limitClause) {
344-
result.limit = this.visit(ctx.limitClause);
344+
result.limit = this.visitSafe(ctx.limitClause);
345345
}
346346
return result;
347347
}
@@ -2662,6 +2662,15 @@ class QuestDBVisitor extends BaseVisitor {
26622662
}
26632663
}
26642664

2665+
/** Visit a CST node, returning undefined instead of throwing on incomplete input */
2666+
private visitSafe(node: any): any {
2667+
try {
2668+
return this.visit(node);
2669+
} catch {
2670+
return undefined;
2671+
}
2672+
}
2673+
26652674
/** Get the startOffset of the first token in a CstNode */
26662675
private getFirstTokenOffset(node: any): number {
26672676
if (node.startOffset !== undefined) return node.startOffset;

tests/autocomplete.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,3 +866,24 @@ describe("Expression autocomplete", () => {
866866
expect(labels).toContain("NOT");
867867
});
868868
});
869+
870+
describe("Implicit SELECT autocomplete", () => {
871+
it("should suggest columns after implicit select WHERE", () => {
872+
const labels = getLabelsAt(provider, "trades WHERE ");
873+
expect(labels).toContain("symbol");
874+
expect(labels).toContain("price");
875+
expect(labels).toContain("NOT");
876+
expect(labels).toContain("CASE");
877+
});
878+
879+
it("should suggest keywords after bare table name", () => {
880+
const labels = getLabelsAt(provider, "trades ");
881+
expect(labels).toContain("WHERE");
882+
});
883+
884+
it("should suggest columns for incomplete implicit select in multi-statement context", () => {
885+
const labels = getLabelsAt(provider, "SELECT 1; trades WHERE ");
886+
// Should get suggestions even after semicolon with implicit select
887+
expect(labels.length).toBeGreaterThan(0);
888+
});
889+
});

tests/parser.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,38 @@ describe("QuestDB Parser", () => {
16411641
});
16421642
});
16431643

1644+
describe("PIVOT multi-statement boundary", () => {
1645+
it("should parse two consecutive pivot statements as separate statements", () => {
1646+
const sql = `trades PIVOT (avg(price) FOR symbol IN ('ETH-USDT'))
1647+
trades PIVOT (sum(amount) FOR symbol IN ('BTC-USDT'))`;
1648+
const result = parseToAst(sql);
1649+
expect(result.ast).toHaveLength(2);
1650+
expect(result.ast[0].type).toBe("pivot");
1651+
expect(result.ast[1].type).toBe("pivot");
1652+
});
1653+
1654+
it("should not consume next table name as alias without AS", () => {
1655+
const sql = `trades PIVOT (avg(price) FOR symbol IN ('ETH-USDT'))
1656+
orders PIVOT (sum(amount) FOR status IN ('open'))`;
1657+
const result = parseToAst(sql);
1658+
expect(result.ast).toHaveLength(2);
1659+
if (result.ast[0].type === "pivot") {
1660+
expect(result.ast[0].alias).toBeUndefined();
1661+
}
1662+
});
1663+
1664+
it("should still support PIVOT alias with AS keyword", () => {
1665+
const result = parseToAst(
1666+
"trades PIVOT (avg(price) FOR symbol IN ('ETH-USDT')) AS p"
1667+
);
1668+
expect(result.errors).toHaveLength(0);
1669+
expect(result.ast).toHaveLength(1);
1670+
if (result.ast[0].type === "pivot") {
1671+
expect(result.ast[0].alias).toBe("p");
1672+
}
1673+
});
1674+
});
1675+
16441676
describe("PIVOT round-trip tests", () => {
16451677
const queries = [
16461678
"trades PIVOT (sum(amount) FOR category IN ('food', 'drinks'))",
@@ -3220,6 +3252,40 @@ describe("QuestDB Parser", () => {
32203252
});
32213253
});
32223254

3255+
describe("Incomplete implicit SELECT produces partial AST", () => {
3256+
it("should produce AST for incomplete 'table WHERE'", () => {
3257+
const result = parseToAst("core_price WHERE ");
3258+
// Should produce a partial AST even though the query is incomplete
3259+
expect(result.ast.length).toBeGreaterThan(0);
3260+
const stmt = result.ast[0] as any;
3261+
expect(stmt.type).toBe("select");
3262+
expect(stmt.implicit).toBe(true);
3263+
});
3264+
3265+
it("should produce AST with table reference for incomplete implicit select", () => {
3266+
const result = parseToAst("trades WHERE price > ");
3267+
expect(result.ast.length).toBeGreaterThan(0);
3268+
const stmt = result.ast[0] as any;
3269+
expect(stmt.type).toBe("select");
3270+
expect(stmt.implicit).toBe(true);
3271+
});
3272+
3273+
it("should produce AST for bare table name only", () => {
3274+
const result = parseToAst("trades");
3275+
expect(result.ast.length).toBeGreaterThan(0);
3276+
const stmt = result.ast[0] as any;
3277+
expect(stmt.type).toBe("select");
3278+
expect(stmt.implicit).toBe(true);
3279+
});
3280+
3281+
it("should produce AST for incomplete implicit select after semicolon", () => {
3282+
const result = parseToAst("SELECT 1; core_price WHERE ");
3283+
expect(result.ast.length).toBeGreaterThan(0);
3284+
// First statement is complete SELECT
3285+
expect(result.ast[0].type).toBe("select");
3286+
});
3287+
});
3288+
32233289
describe("Parenthesized SHOW as table source", () => {
32243290
it("should parse (SHOW PARAMETERS) WHERE ...", () => {
32253291
const result = parseToAst(

yarn.lock

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,18 @@ __metadata:
349349
languageName: node
350350
linkType: hard
351351

352+
"@questdb/questdb-sql-parser@workspace:.":
353+
version: 0.0.0-use.local
354+
resolution: "@questdb/questdb-sql-parser@workspace:."
355+
dependencies:
356+
"@types/jest": "npm:^30.0.0"
357+
"@types/node": "npm:^25.2.0"
358+
chevrotain: "npm:^11.1.1"
359+
typescript: "npm:^5.9.3"
360+
vitest: "npm:^4.0.18"
361+
languageName: unknown
362+
linkType: soft
363+
352364
"@rollup/rollup-android-arm-eabi@npm:4.57.1":
353365
version: 4.57.1
354366
resolution: "@rollup/rollup-android-arm-eabi@npm:4.57.1"
@@ -1546,18 +1558,6 @@ __metadata:
15461558
languageName: node
15471559
linkType: hard
15481560

1549-
"questdb-sql-parser@workspace:.":
1550-
version: 0.0.0-use.local
1551-
resolution: "questdb-sql-parser@workspace:."
1552-
dependencies:
1553-
"@types/jest": "npm:^30.0.0"
1554-
"@types/node": "npm:^25.2.0"
1555-
chevrotain: "npm:^11.1.1"
1556-
typescript: "npm:^5.9.3"
1557-
vitest: "npm:^4.0.18"
1558-
languageName: unknown
1559-
linkType: soft
1560-
15611561
"react-is@npm:^18.3.1":
15621562
version: 18.3.1
15631563
resolution: "react-is@npm:18.3.1"

0 commit comments

Comments
 (0)