Skip to content

Commit 2bdbc39

Browse files
emrberkclaude
andcommitted
Support single-quoted identifiers and bare * in select list
- Accept StringLiteral in the identifier parser rule so QuestDB single-quoted identifiers (FROM 'trades', AS 'alias') parse correctly and roundtrip through toSql - Allow bare * anywhere in the select list (SELECT amount, * FROM t), not only as the first item - Suppress autocomplete inside both single- and double-quoted strings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fc30a66 commit 2bdbc39

File tree

5 files changed

+69
-5
lines changed

5 files changed

+69
-5
lines changed

src/autocomplete/content-assist.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -477,13 +477,17 @@ export function getContentAssist(
477477
fullSql: string,
478478
cursorOffset: number,
479479
): 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.
480+
// Suppress suggestions when the cursor is inside any quoted string (single or double).
481+
// This covers both string literal values and single-quoted identifiers — while QuestDB
482+
// accepts single-quoted identifiers, providing completions inside quotes causes more
483+
// problems than it solves (prefix filtering breaks, ambiguity with string values).
484484
const fullTokens = QuestDBLexer.tokenize(fullSql).tokens
485485
for (const token of fullTokens) {
486-
if (token.tokenType.name !== "StringLiteral") continue
486+
if (
487+
token.tokenType.name !== "StringLiteral" &&
488+
token.tokenType.name !== "QuotedIdentifier"
489+
)
490+
continue
487491
const start = token.startOffset
488492
const end = token.startOffset + token.image.length
489493
if (cursorOffset > start && cursorOffset < end) {

src/parser/cst-types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export interface SelectItemCstNode extends CstNode {
142142

143143
export type SelectItemCstChildren = {
144144
qualifiedStar?: QualifiedStarCstNode[];
145+
Star?: IToken[];
145146
expression?: ExpressionCstNode[];
146147
As?: IToken[];
147148
identifier?: IdentifierCstNode[];
@@ -2286,6 +2287,7 @@ export interface IdentifierCstNode extends CstNode {
22862287
export type IdentifierCstChildren = {
22872288
Identifier?: IToken[];
22882289
QuotedIdentifier?: IToken[];
2290+
StringLiteral?: IToken[];
22892291
IdentifierKeyword?: IToken[];
22902292
};
22912293

src/parser/parser.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,8 @@ class QuestDBParser extends CstParser {
609609
GATE: this.BACKTRACK(this.qualifiedStar),
610610
ALT: () => this.SUBRULE(this.qualifiedStar),
611611
},
612+
// Bare * can appear anywhere in the select list (e.g., SELECT amount, *)
613+
{ ALT: () => this.CONSUME(Star) },
612614
{ ALT: () => this.SUBRULE(this.expression) },
613615
])
614616
this.OPTION({
@@ -3881,9 +3883,12 @@ class QuestDBParser extends CstParser {
38813883
// keyword token as an identifier, avoiding a 160+ alternative OR that
38823884
// would make performSelfAnalysis() extremely slow.
38833885
// See tokens.ts IDENTIFIER_KEYWORD_NAMES for the full list.
3886+
// QuestDB accepts single-quoted strings in identifier positions (table names,
3887+
// column names, aliases), so StringLiteral is accepted here alongside QuotedIdentifier.
38843888
this.OR([
38853889
{ ALT: () => this.CONSUME(Identifier) },
38863890
{ ALT: () => this.CONSUME(QuotedIdentifier) },
3891+
{ ALT: () => this.CONSUME(StringLiteral) },
38873892
{ ALT: () => this.CONSUME(IdentifierKeyword) },
38883893
])
38893894
})

src/parser/visitor.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,11 @@ class QuestDBVisitor extends BaseVisitor {
454454
return result
455455
}
456456

457+
// Bare * appearing as a non-first select item (e.g., SELECT amount, *)
458+
if (ctx.Star) {
459+
return { type: "star" }
460+
}
461+
457462
const result: AST.ExpressionSelectItem = {
458463
type: "selectItem",
459464
expression: this.visit(ctx.expression!) as AST.Expression,
@@ -3732,6 +3737,11 @@ class QuestDBVisitor extends BaseVisitor {
37323737
const raw = this.tokenImage(ctx.QuotedIdentifier[0] as IToken)
37333738
return raw.slice(1, -1).replace(/""/g, '"')
37343739
}
3740+
// QuestDB accepts single-quoted strings in identifier positions
3741+
if (ctx.StringLiteral) {
3742+
const raw = this.tokenImage(ctx.StringLiteral[0] as IToken)
3743+
return raw.slice(1, -1).replace(/''/g, "'")
3744+
}
37353745
// Handle keyword tokens used as identifiers
37363746
// Find the first token in the context
37373747
for (const key of Object.keys(ctx)) {

tests/parser.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3343,6 +3343,49 @@ orders PIVOT (sum(amount) FOR status IN ('open'))`
33433343
expect(stmt.type).toBe("select")
33443344
})
33453345

3346+
it("should parse SELECT expr, * (star not first)", () => {
3347+
const result = parseToAst("SELECT amount, * FROM btc_trades")
3348+
expect(result.errors).toHaveLength(0)
3349+
const stmt = result.ast[0]
3350+
expect(stmt.type).toBe("select")
3351+
if (stmt.type === "select") {
3352+
expect(stmt.columns).toHaveLength(2)
3353+
expect(stmt.columns[0].type).toBe("selectItem")
3354+
expect(stmt.columns[1].type).toBe("star")
3355+
}
3356+
})
3357+
3358+
it("should roundtrip SELECT expr, *", () => {
3359+
const result = parseToAst("SELECT amount, * FROM btc_trades")
3360+
expect(result.errors).toHaveLength(0)
3361+
const sql = toSql(result.ast[0])
3362+
expect(sql).toBe("SELECT amount, * FROM btc_trades")
3363+
})
3364+
3365+
it("should parse SELECT with star in middle of select list", () => {
3366+
const result = parseToAst(
3367+
"SELECT symbol, *, price FROM btc_trades",
3368+
)
3369+
expect(result.errors).toHaveLength(0)
3370+
if (result.ast[0].type === "select") {
3371+
expect(result.ast[0].columns).toHaveLength(3)
3372+
expect(result.ast[0].columns[0].type).toBe("selectItem")
3373+
expect(result.ast[0].columns[1].type).toBe("star")
3374+
expect(result.ast[0].columns[2].type).toBe("selectItem")
3375+
}
3376+
})
3377+
3378+
it("should parse SELECT with multiple expressions before star", () => {
3379+
const result = parseToAst(
3380+
"SELECT a, b, c, * FROM t",
3381+
)
3382+
expect(result.errors).toHaveLength(0)
3383+
if (result.ast[0].type === "select") {
3384+
expect(result.ast[0].columns).toHaveLength(4)
3385+
expect(result.ast[0].columns[3].type).toBe("star")
3386+
}
3387+
})
3388+
33463389
// --- Fix 4: Array column types ---
33473390

33483391
it("should parse CREATE TABLE with single-dimension array column type", () => {

0 commit comments

Comments
 (0)