Skip to content

Commit b42831a

Browse files
emrberkclaude
andcommitted
fix: PIVOT FOR...IN AS alias parsing, COPY PARTITION BY roundtrip
- Add pivotInValue grammar rule to support aliased values in PIVOT IN lists, e.g. FOR symbol IN ('BTC-USD' AS bitcoin). Both parsing and toSql round-trip now handle the AS alias correctly. - Fix COPY FROM visitor dropping the BY token from PARTITION BY, which caused toSql to emit invalid SQL rejected by QuestDB. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dfd20fc commit b42831a

File tree

6 files changed

+107
-9
lines changed

6 files changed

+107
-9
lines changed

src/parser/ast.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,9 +777,15 @@ export interface PivotForClause extends AstNode {
777777
in: PivotInSource
778778
}
779779

780+
export interface PivotInValue extends AstNode {
781+
type: "pivotInValue"
782+
expression: Expression
783+
alias?: string
784+
}
785+
780786
export interface PivotInSource extends AstNode {
781787
type: "pivotIn"
782-
values?: Expression[]
788+
values?: PivotInValue[]
783789
select?: SelectStatement
784790
}
785791

src/parser/cst-types.d.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1769,12 +1769,23 @@ export type PivotForClauseCstChildren = {
17691769
columnRef: ColumnRefCstNode[];
17701770
In: IToken[];
17711771
LParen: IToken[];
1772-
expression?: (ExpressionCstNode)[];
1772+
pivotInValue?: (PivotInValueCstNode)[];
17731773
Comma?: IToken[];
17741774
selectStatement?: SelectStatementCstNode[];
17751775
RParen: IToken[];
17761776
};
17771777

1778+
export interface PivotInValueCstNode extends CstNode {
1779+
name: "pivotInValue";
1780+
children: PivotInValueCstChildren;
1781+
}
1782+
1783+
export type PivotInValueCstChildren = {
1784+
expression: ExpressionCstNode[];
1785+
As?: IToken[];
1786+
identifier?: IdentifierCstNode[];
1787+
};
1788+
17781789
export interface ExpressionCstNode extends CstNode {
17791790
name: "expression";
17801791
children: ExpressionCstChildren;
@@ -2428,6 +2439,7 @@ export interface ICstNodeVisitor<IN, OUT> extends ICstVisitor<IN, OUT> {
24282439
pivotBody(children: PivotBodyCstChildren, param?: IN): OUT;
24292440
pivotAggregation(children: PivotAggregationCstChildren, param?: IN): OUT;
24302441
pivotForClause(children: PivotForClauseCstChildren, param?: IN): OUT;
2442+
pivotInValue(children: PivotInValueCstChildren, param?: IN): OUT;
24312443
expression(children: ExpressionCstChildren, param?: IN): OUT;
24322444
orExpression(children: OrExpressionCstChildren, param?: IN): OUT;
24332445
andExpression(children: AndExpressionCstChildren, param?: IN): OUT;

src/parser/parser.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3201,10 +3201,10 @@ class QuestDBParser extends CstParser {
32013201
this.OR([
32023202
{
32033203
ALT: () => {
3204-
this.SUBRULE1(this.expression)
3204+
this.SUBRULE1(this.pivotInValue)
32053205
this.MANY(() => {
32063206
this.CONSUME(Comma)
3207-
this.SUBRULE2(this.expression)
3207+
this.SUBRULE2(this.pivotInValue)
32083208
})
32093209
},
32103210
},
@@ -3213,6 +3213,16 @@ class QuestDBParser extends CstParser {
32133213
this.CONSUME(RParen)
32143214
})
32153215

3216+
// A single value inside PIVOT FOR ... IN (...), optionally aliased:
3217+
// 'BTC-USD' AS bitcoin
3218+
private pivotInValue = this.RULE("pivotInValue", () => {
3219+
this.SUBRULE(this.expression)
3220+
this.OPTION(() => {
3221+
this.CONSUME(As)
3222+
this.SUBRULE(this.identifier)
3223+
})
3224+
})
3225+
32163226
// ==========================================================================
32173227
// Expressions
32183228
// ==========================================================================

src/parser/toSql.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,7 +1346,15 @@ function pivotClauseToSql(pivot: AST.PivotClause): string {
13461346
if (p.in.select) {
13471347
forSql += selectToSql(p.in.select)
13481348
} else if (p.in.values) {
1349-
forSql += p.in.values.map(expressionToSql).join(", ")
1349+
forSql += p.in.values
1350+
.map((v) => {
1351+
let sql = expressionToSql(v.expression)
1352+
if (v.alias) {
1353+
sql += ` AS ${escapeIdentifier(v.alias)}`
1354+
}
1355+
return sql
1356+
})
1357+
.join(", ")
13501358
}
13511359
forSql += ")"
13521360
pivotParts.push(forSql)
@@ -1398,7 +1406,15 @@ function pivotToSql(stmt: AST.PivotStatement): string {
13981406
if (pivot.in.select) {
13991407
forSql += selectToSql(pivot.in.select)
14001408
} else if (pivot.in.values) {
1401-
forSql += pivot.in.values.map(expressionToSql).join(", ")
1409+
forSql += pivot.in.values
1410+
.map((v) => {
1411+
let sql = expressionToSql(v.expression)
1412+
if (v.alias) {
1413+
sql += ` AS ${escapeIdentifier(v.alias)}`
1414+
}
1415+
return sql
1416+
})
1417+
.join(", ")
14021418
}
14031419
forSql += ")"
14041420
pivotParts.push(forSql)

src/parser/visitor.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import type {
113113
PivotAggregationCstChildren,
114114
PivotBodyCstChildren,
115115
PivotForClauseCstChildren,
116+
PivotInValueCstChildren,
116117
PivotStatementCstChildren,
117118
PrimaryExpressionCstChildren,
118119
QualifiedNameCstChildren,
@@ -2343,6 +2344,7 @@ class QuestDBVisitor extends BaseVisitor {
23432344

23442345
let key = keyToken?.image ?? "OPTION"
23452346
if (ctx.On && ctx.Error) key = "ON ERROR"
2347+
else if (ctx.Partition && ctx.By) key = "PARTITION BY"
23462348
const result: AST.CopyOption = {
23472349
type: "copyOption",
23482350
key,
@@ -2775,14 +2777,25 @@ class QuestDBVisitor extends BaseVisitor {
27752777
}
27762778
if (ctx.selectStatement) {
27772779
result.in.select = this.visit(ctx.selectStatement) as AST.SelectStatement
2778-
} else if (ctx.expression && ctx.expression.length > 0) {
2779-
result.in.values = ctx.expression.map(
2780-
(e: CstNode) => this.visit(e) as AST.Expression,
2780+
} else if (ctx.pivotInValue && ctx.pivotInValue.length > 0) {
2781+
result.in.values = ctx.pivotInValue.map(
2782+
(e: CstNode) => this.visit(e) as AST.PivotInValue,
27812783
)
27822784
}
27832785
return result
27842786
}
27852787

2788+
pivotInValue(ctx: PivotInValueCstChildren): AST.PivotInValue {
2789+
const result: AST.PivotInValue = {
2790+
type: "pivotInValue",
2791+
expression: this.visit(ctx.expression) as AST.Expression,
2792+
}
2793+
if (ctx.As && ctx.identifier) {
2794+
result.alias = this.extractIdentifierName(ctx.identifier[0].children)
2795+
}
2796+
return result
2797+
}
2798+
27862799
// ==========================================================================
27872800
// Expressions
27882801
// ==========================================================================

tests/parser.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2125,6 +2125,37 @@ describe("QuestDB Parser", () => {
21252125
expect(pivot.groupBy).toHaveLength(1)
21262126
}
21272127
})
2128+
2129+
it("should parse PIVOT FOR...IN with AS aliases on values", () => {
2130+
const result = parseToAst(
2131+
"trades PIVOT (avg(price) FOR symbol IN ('BTC-USD' AS bitcoin, 'ETH-USD' AS ethereum))",
2132+
)
2133+
expect(result.errors).toHaveLength(0)
2134+
expect(result.ast).toHaveLength(1)
2135+
const pivot = result.ast[0]
2136+
expect(pivot.type).toBe("pivot")
2137+
if (pivot.type === "pivot") {
2138+
expect(pivot.pivots).toHaveLength(1)
2139+
const values = pivot.pivots[0].in.values!
2140+
expect(values).toHaveLength(2)
2141+
expect(values[0].alias).toBe("bitcoin")
2142+
expect(values[1].alias).toBe("ethereum")
2143+
}
2144+
})
2145+
2146+
it("should parse PIVOT FOR...IN with mixed aliased and unaliased values", () => {
2147+
const result = parseToAst(
2148+
"trades PIVOT (avg(price) FOR symbol IN ('BTC-USD' AS bitcoin, 'ETH-USD'))",
2149+
)
2150+
expect(result.errors).toHaveLength(0)
2151+
const pivot = result.ast[0]
2152+
if (pivot.type === "pivot") {
2153+
const values = pivot.pivots[0].in.values!
2154+
expect(values).toHaveLength(2)
2155+
expect(values[0].alias).toBe("bitcoin")
2156+
expect(values[1].alias).toBeUndefined()
2157+
}
2158+
})
21282159
})
21292160

21302161
describe("PIVOT multi-statement boundary", () => {
@@ -2168,6 +2199,8 @@ orders PIVOT (sum(amount) FOR status IN ('open'))`
21682199
"(SELECT * FROM trades) PIVOT (sum(amount) FOR category IN ('food', 'drinks'))",
21692200
"trades WHERE price > 100 PIVOT (sum(amount) FOR category IN ('food', 'drinks'))",
21702201
"trades PIVOT (sum(amount) FOR category IN ('food', 'drinks')) AS p",
2202+
"trades PIVOT (avg(price) FOR symbol IN ('BTC-USD' AS bitcoin, 'ETH-USD' AS ethereum))",
2203+
"trades PIVOT (avg(price) FOR symbol IN ('BTC-USD' AS bitcoin, 'ETH-USD'))",
21712204
]
21722205

21732206
for (const query of queries) {
@@ -4745,6 +4778,14 @@ orders PIVOT (sum(amount) FOR status IN ('open'))`
47454778
expect(result.ast[0].type).toBe("copyFrom")
47464779
})
47474780

4781+
it("should round-trip COPY FROM with PARTITION BY", () => {
4782+
const sql = "COPY weather FROM '/tmp/weather.csv' WITH PARTITION BY DAY"
4783+
const { ast, errors } = parseToAst(sql)
4784+
expect(errors).toHaveLength(0)
4785+
const output = toSql(ast)
4786+
expect(output).toContain("PARTITION BY DAY")
4787+
})
4788+
47484789
// BACKUP ABORT
47494790
it("should parse BACKUP ABORT", () => {
47504791
const result = parseToAst("BACKUP ABORT")

0 commit comments

Comments
 (0)