Skip to content

Commit fc30a66

Browse files
emrberkclaude
andcommitted
Prepare @questdb/sql-parser for 0.1.0 publish
- Rename package from @questdb/questdb-sql-parser to @questdb/sql-parser - Fix parseToAst() silent swallow and _semicolonBoundedRecovery state leak - Use single quotes for identifier escaping (QuestDB convention) - Improve autocomplete: string literal suppression, SELECT FROM fallback, CTE performance threshold (150 tokens) - Remove unsupported SQL: NATURAL JOIN, GROUPS frame mode, EXCLUDE GROUPS - Reorganize review-proofs tests into parser/lexer/autocomplete test files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c82a29e commit fc30a66

20 files changed

+334
-534
lines changed

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# @questdb/questdb-sql-parser
1+
# @questdb/sql-parser
22

33
A production-ready SQL parser for [QuestDB](https://questdb.io/) syntax, built with [Chevrotain](https://chevrotain.io/). Parses SQL into a fully typed AST, converts AST back to SQL, and provides context-aware autocomplete — all in a single package.
44

@@ -17,7 +17,7 @@ A production-ready SQL parser for [QuestDB](https://questdb.io/) syntax, built w
1717
### Parse SQL to AST
1818

1919
```typescript
20-
import { parseToAst } from "@questdb/questdb-sql-parser";
20+
import { parseToAst } from "@questdb/sql-parser";
2121

2222
const result = parseToAst("SELECT * FROM trades WHERE symbol = 'BTC-USD'");
2323

@@ -39,7 +39,7 @@ if (result.errors.length === 0) {
3939
### Convert AST back to SQL
4040

4141
```typescript
42-
import { parseToAst, toSql } from "@questdb/questdb-sql-parser";
42+
import { parseToAst, toSql } from "@questdb/sql-parser";
4343

4444
const result = parseToAst("SELECT avg(price) FROM trades SAMPLE BY 1h FILL(PREV)");
4545
const sql = toSql(result.ast);
@@ -49,7 +49,7 @@ const sql = toSql(result.ast);
4949
### Parse a single statement
5050

5151
```typescript
52-
import { parseOne } from "@questdb/questdb-sql-parser";
52+
import { parseOne } from "@questdb/sql-parser";
5353

5454
const stmt = parseOne("INSERT INTO trades VALUES (now(), 'BTC', 42000.50)");
5555
console.log(stmt.type); // "insert"
@@ -58,7 +58,7 @@ console.log(stmt.type); // "insert"
5858
### Parse multiple statements
5959

6060
```typescript
61-
import { parseStatements } from "@questdb/questdb-sql-parser";
61+
import { parseStatements } from "@questdb/sql-parser";
6262

6363
const statements = parseStatements(`
6464
SELECT * FROM trades
@@ -72,7 +72,7 @@ console.log(statements.length); // 3
7272
### Autocomplete
7373

7474
```typescript
75-
import { createAutocompleteProvider } from "@questdb/questdb-sql-parser";
75+
import { createAutocompleteProvider } from "@questdb/sql-parser";
7676

7777
const provider = createAutocompleteProvider({
7878
tables: [
@@ -204,7 +204,7 @@ The parser uses Chevrotain's [CST pattern](https://chevrotain.io/docs/guide/conc
204204
Arrays of keywords, functions, data types, operators, and constants for syntax highlighting integration:
205205

206206
```typescript
207-
import { keywords, functions, dataTypes, operators, constants } from "@questdb/questdb-sql-parser/grammar";
207+
import { keywords, functions, dataTypes, operators, constants } from "@questdb/sql-parser/grammar";
208208
```
209209

210210
## Development

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "@questdb/questdb-sql-parser",
2+
"name": "@questdb/sql-parser",
33
"version": "0.1.0",
44
"description": "SQL parser for QuestDB syntax using Chevrotain",
55
"type": "module",
@@ -59,11 +59,11 @@
5959
},
6060
"repository": {
6161
"type": "git",
62-
"url": "https://github.com/questdb/questdb-sql-parser.git"
62+
"url": "https://github.com/questdb/sql-parser.git"
6363
},
64-
"homepage": "https://questdb.com",
64+
"homepage": "https://github.com/questdb/sql-parser",
6565
"bugs": {
66-
"url": "https://github.com/questdb/questdb-sql-parser/issues"
66+
"url": "https://github.com/questdb/sql-parser/issues"
6767
},
6868
"license": "Apache-2.0",
6969
"dependencies": {

src/autocomplete/content-assist.ts

Lines changed: 71 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,52 @@
11
import { type ILexingError, IToken, TokenType } from "chevrotain"
22
import { parser, parse as parseRaw } from "../parser/parser"
33
import { visitor } from "../parser/visitor"
4-
import { QuestDBLexer } from "../parser/lexer"
4+
import { QuestDBLexer, IdentifierKeyword } from "../parser/lexer"
55
import type { Statement } from "../parser/ast"
66
import { IDENTIFIER_KEYWORD_TOKENS } from "./token-classification"
77

8+
// =============================================================================
9+
// Constants
10+
// =============================================================================
11+
12+
/**
13+
* When the token count exceeds this threshold, skip Chevrotain's
14+
* computeContentAssist (which is exponential on deeply nested CTEs)
15+
* and return IdentifierKeyword to trigger table/column suggestions.
16+
*/
17+
const CONTENT_ASSIST_TOKEN_LIMIT = 150
18+
19+
const WORD_BOUNDARY_CHARS = new Set([
20+
" ",
21+
"\n",
22+
"\t",
23+
"\r",
24+
"(",
25+
")",
26+
",",
27+
";",
28+
".",
29+
"=",
30+
"<",
31+
">",
32+
"+",
33+
"-",
34+
"*",
35+
"/",
36+
"%",
37+
"'",
38+
'"',
39+
"|",
40+
"&",
41+
"^",
42+
"~",
43+
"!",
44+
"@",
45+
":",
46+
"[",
47+
"]",
48+
])
49+
850
// =============================================================================
951
// Types
1052
// =============================================================================
@@ -315,6 +357,13 @@ function collapseTrailingQualifiedRef(tokens: IToken[]): IToken[] | null {
315357
* from all WITH-capable statement types.
316358
*/
317359
function computeSuggestions(tokens: IToken[]): TokenType[] {
360+
// For large token sequences (deeply nested CTEs), Chevrotain's
361+
// computeContentAssist becomes exponentially slow. Fall back to a
362+
// generic set of suggestions to avoid freezing the editor.
363+
if (tokens.length > CONTENT_ASSIST_TOKEN_LIMIT) {
364+
return [IdentifierKeyword]
365+
}
366+
318367
const ruleName = tokens.some((t) => t.tokenType.name === "Semicolon")
319368
? "statements"
320369
: "statement"
@@ -428,6 +477,26 @@ export function getContentAssist(
428477
fullSql: string,
429478
cursorOffset: number,
430479
): ContentAssistResult {
480+
// Tokenize the full SQL to check if the cursor is inside a string literal.
481+
// If so, suppress suggestions — the user is editing a value, not SQL syntax.
482+
// This also covers single-quoted identifiers (FROM 'table'), which is an
483+
// acceptable trade-off to avoid fragile context-detection heuristics.
484+
const fullTokens = QuestDBLexer.tokenize(fullSql).tokens
485+
for (const token of fullTokens) {
486+
if (token.tokenType.name !== "StringLiteral") continue
487+
const start = token.startOffset
488+
const end = token.startOffset + token.image.length
489+
if (cursorOffset > start && cursorOffset < end) {
490+
return {
491+
nextTokenTypes: [],
492+
tablesInScope: [],
493+
tokensBefore: [],
494+
isMidWord: true,
495+
lexErrors: [],
496+
}
497+
}
498+
}
499+
431500
// Split the text at cursor position
432501
const beforeCursor = fullSql.substring(0, cursorOffset)
433502

@@ -444,36 +513,6 @@ export function getContentAssist(
444513
cursorOffset > 0 && cursorOffset <= fullSql.length
445514
? fullSql[cursorOffset - 1]
446515
: " "
447-
const WORD_BOUNDARY_CHARS = new Set([
448-
" ",
449-
"\n",
450-
"\t",
451-
"\r",
452-
"(",
453-
")",
454-
",",
455-
";",
456-
".",
457-
"=",
458-
"<",
459-
">",
460-
"+",
461-
"-",
462-
"*",
463-
"/",
464-
"%",
465-
"'",
466-
'"',
467-
"|",
468-
"&",
469-
"^",
470-
"~",
471-
"!",
472-
"@",
473-
":",
474-
"[",
475-
"]",
476-
])
477516
const isMidWord = !WORD_BOUNDARY_CHARS.has(lastChar)
478517
const tokensForAssist =
479518
isMidWord && tokens.length > 0 ? tokens.slice(0, -1) : tokens
@@ -487,8 +526,7 @@ export function getContentAssist(
487526
// This can happen with malformed input
488527
}
489528

490-
// Extract tables from the full query
491-
const fullTokens = QuestDBLexer.tokenize(fullSql).tokens
529+
// Extract tables from the full query (reuses fullTokens from above)
492530
const tablesInScope = extractTables(fullSql, fullTokens)
493531

494532
if (tablesInScope.length === 0) {

src/autocomplete/provider.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { getContentAssist } from "./content-assist"
2323
import { buildSuggestions } from "./suggestion-builder"
2424
import { shouldSkipToken } from "./token-classification"
2525
import type { AutocompleteProvider, SchemaInfo, Suggestion } from "./types"
26+
import { SuggestionKind, SuggestionPriority } from "./types"
2627

2728
const TABLE_NAME_TOKENS = new Set([
2829
"From",
@@ -129,6 +130,27 @@ export function createAutocompleteProvider(
129130
)
130131
}
131132

133+
// Fallback: when Chevrotain returns no suggestions (malformed SQL like
134+
// "SELECT FROM |" where columns are missing), check if the cursor follows
135+
// a table-introducing keyword. If so, suggest table names directly.
136+
const fallbackTokens =
137+
isMidWord && tokensBefore.length > 0
138+
? tokensBefore.slice(0, -1)
139+
: tokensBefore
140+
const [lastFallback] = getLastSignificantTokens(fallbackTokens)
141+
if (lastFallback && TABLE_NAME_TOKENS.has(lastFallback)) {
142+
const suggestions: Suggestion[] = []
143+
for (const table of normalizedSchema.tables) {
144+
suggestions.push({
145+
label: table.name,
146+
kind: SuggestionKind.Table,
147+
insertText: table.name,
148+
priority: SuggestionPriority.MediumLow,
149+
})
150+
}
151+
return suggestions
152+
}
153+
132154
return []
133155
},
134156
}

src/grammar/keywords.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ export const keywords: string[] = [
106106
"lt",
107107
"materialized",
108108
"maxUncommittedRows",
109-
"natural",
110109
"no",
111110
"nocache",
112111
"not",

src/index.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,16 @@ export function parseToAst(sql: string): ParseResult {
8787
}
8888

8989
let ast: Statement[] = []
90-
try {
90+
if (errors.length > 0) {
91+
try {
92+
ast = visitor.visit(cst) as Statement[]
93+
} catch {
94+
// The visitor may throw on incomplete CST nodes produced by error recovery
95+
// (e.g. "Unknown primary expression", null dereferences on missing children).
96+
// Since we already have parse errors, return them with whatever AST was built.
97+
}
98+
} else {
9199
ast = visitor.visit(cst) as Statement[]
92-
} catch {
93-
// The visitor throws on incomplete CST nodes produced by error recovery
94-
// (e.g. "Unknown primary expression", null dereferences on missing children).
95-
// Rather than making every visitor method resilient to partial CSTs, we catch
96-
// here and return whatever AST was successfully built, along with the parse
97-
// errors that already describe what went wrong.
98100
}
99101

100102
return { ast, errors }

src/parser/ast.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -959,10 +959,10 @@ export interface WindowSpecification extends AstNode {
959959

960960
export interface WindowFrame extends AstNode {
961961
type: "windowFrame"
962-
mode: "rows" | "range" | "groups" | "cumulative"
962+
mode: "rows" | "range" | "cumulative"
963963
start?: WindowFrameBound
964964
end?: WindowFrameBound
965-
exclude?: "currentRow" | "noOthers" | "groups"
965+
exclude?: "currentRow" | "noOthers"
966966
}
967967

968968
export interface WindowFrameBound extends AstNode {

src/parser/cst-types.d.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,6 @@ export type JoinClauseCstChildren = {
252252
Lt?: IToken[];
253253
Splice?: IToken[];
254254
Window?: IToken[];
255-
Natural?: IToken[];
256255
Prevailing?: (IToken)[];
257256
Outer?: IToken[];
258257
Join: IToken[];
@@ -2146,7 +2145,6 @@ export interface WindowFrameClauseCstNode extends CstNode {
21462145
export type WindowFrameClauseCstChildren = {
21472146
Rows?: IToken[];
21482147
Range?: IToken[];
2149-
Groups?: (IToken)[];
21502148
Cumulative?: IToken[];
21512149
Between?: IToken[];
21522150
windowFrameBound?: (WindowFrameBoundCstNode)[];

src/parser/lexer.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,6 @@ import {
152152
NaN,
153153
Nanosecond,
154154
Nanoseconds,
155-
Natural,
156155
No,
157156
Nocache,
158157
None,
@@ -432,7 +431,6 @@ export {
432431
NaN,
433432
Nanosecond,
434433
Nanoseconds,
435-
Natural,
436434
No,
437435
Nocache,
438436
None,

src/parser/parser.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,6 @@ import {
300300
Exclude,
301301
Cumulative,
302302
Others,
303-
Natural,
304303
Database,
305304
Backup,
306305
Prevailing,
@@ -417,8 +416,11 @@ class QuestDBParser extends CstParser {
417416
public statements = this.RULE("statements", () => {
418417
this.MANY(() => {
419418
this._semicolonBoundedRecovery = true
420-
this.SUBRULE(this.statement)
421-
this._semicolonBoundedRecovery = false
419+
try {
420+
this.SUBRULE(this.statement)
421+
} finally {
422+
this._semicolonBoundedRecovery = false
423+
}
422424
this.OPTION(() => this.CONSUME(Semicolon))
423425
})
424426
})
@@ -785,7 +787,6 @@ class QuestDBParser extends CstParser {
785787
{ ALT: () => this.CONSUME(Lt) },
786788
{ ALT: () => this.CONSUME(Splice) },
787789
{ ALT: () => this.CONSUME(Window) },
788-
{ ALT: () => this.CONSUME(Natural) },
789790
{ ALT: () => this.CONSUME(Prevailing) },
790791
])
791792
this.OPTION1(() => this.CONSUME(Outer))
@@ -3705,7 +3706,6 @@ class QuestDBParser extends CstParser {
37053706
this.OR([
37063707
{ ALT: () => this.CONSUME(Rows) },
37073708
{ ALT: () => this.CONSUME(Range) },
3708-
{ ALT: () => this.CONSUME(Groups) },
37093709
{ ALT: () => this.CONSUME(Cumulative) },
37103710
])
37113711
this.OPTION(() => {
@@ -3723,7 +3723,7 @@ class QuestDBParser extends CstParser {
37233723
},
37243724
])
37253725
})
3726-
// Optional EXCLUDE clause
3726+
// Optional EXCLUDE clause (QuestDB supports EXCLUDE CURRENT ROW and EXCLUDE NO OTHERS)
37273727
this.OPTION1(() => {
37283728
this.CONSUME(Exclude)
37293729
this.OR2([
@@ -3739,7 +3739,6 @@ class QuestDBParser extends CstParser {
37393739
this.CONSUME(Others)
37403740
},
37413741
},
3742-
{ ALT: () => this.CONSUME1(Groups) },
37433742
])
37443743
})
37453744
})

0 commit comments

Comments
 (0)