Skip to content

Commit 9046e27

Browse files
committed
fix copy options serialization
1 parent e224c37 commit 9046e27

File tree

6 files changed

+86
-2
lines changed

6 files changed

+86
-2
lines changed

src/parser/ast.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,8 @@ export interface CopyOption extends AstNode {
627627
type: "copyOption"
628628
key: string
629629
value?: string | number | boolean | string[]
630+
/** When true, the string value originated from a string literal and should be quoted in toSql. */
631+
quoted?: boolean
630632
}
631633

632634
export interface CheckpointStatement extends AstNode {

src/parser/cst-types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,6 +1452,7 @@ export type CopyOptionCstChildren = {
14521452
DataPageSize?: IToken[];
14531453
StatisticsEnabled?: IToken[];
14541454
ParquetVersion?: IToken[];
1455+
NumberLiteral?: IToken[];
14551456
RawArrayEncoding?: IToken[];
14561457
};
14571458

src/parser/parser.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2746,7 +2746,10 @@ class QuestDBParser extends CstParser {
27462746
{
27472747
ALT: () => {
27482748
this.CONSUME(ParquetVersion)
2749-
this.SUBRULE2(this.stringOrIdentifier)
2749+
this.OR5([
2750+
{ ALT: () => this.CONSUME(NumberLiteral) },
2751+
{ ALT: () => this.SUBRULE2(this.stringOrIdentifier) },
2752+
])
27502753
},
27512754
},
27522755
{

src/parser/toSql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1268,7 +1268,7 @@ function copyOptionToSql(opt: AST.CopyOption): string {
12681268
if (opt.value === true) return `${opt.key} TRUE`
12691269
if (opt.value === false) return `${opt.key} FALSE`
12701270
if (typeof opt.value === "string")
1271-
return `${opt.key} ${escapeString(opt.value)}`
1271+
return `${opt.key} ${opt.quoted ? escapeString(opt.value) : opt.value}`
12721272
if (Array.isArray(opt.value)) return `${opt.key} ${opt.value.join(" ")}`
12731273
return `${opt.key} ${opt.value}`
12741274
}

src/parser/visitor.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2298,16 +2298,26 @@ class QuestDBVisitor extends BaseVisitor {
22982298

22992299
if (ctx.booleanLiteral) {
23002300
result.value = this.visit(ctx.booleanLiteral) as boolean
2301+
} else if (ctx.NumberLiteral) {
2302+
// PARQUET_VERSION with bare number literal (e.g., PARQUET_VERSION 2)
2303+
result.value = parseInt(ctx.NumberLiteral[0].image, 10)
23012304
} else if (ctx.stringOrIdentifier) {
23022305
result.value = this.extractMaybeString(ctx.stringOrIdentifier[0])
2306+
// Mark as quoted when the stringOrIdentifier resolved to a string literal
2307+
const soiChildren = (ctx.stringOrIdentifier[0] as CstNode).children
2308+
if (soiChildren.StringLiteral) {
2309+
result.quoted = true
2310+
}
23032311
} else if (ctx.StringLiteral) {
23042312
result.value = ctx.StringLiteral[0].image.slice(1, -1)
2313+
result.quoted = true
23052314
} else if (ctx.expression) {
23062315
const expr = this.visit(ctx.expression) as AST.Expression
23072316
if (expr?.type === "literal" && expr.literalType === "number") {
23082317
result.value = expr.value as number
23092318
} else if (expr?.type === "literal" && expr.literalType === "string") {
23102319
result.value = expr.value as string
2320+
result.quoted = true
23112321
} else if (expr?.type === "literal") {
23122322
result.value = expr.raw ?? String(expr.value ?? "")
23132323
} else {

tests/parser.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3210,6 +3210,74 @@ orders PIVOT (sum(amount) FOR status IN ('open'))`
32103210
expect(result2.ast[0].type).toBe(result1.ast[0].type)
32113211
})
32123212
}
3213+
3214+
it("COPY TO round-trip: PARTITION_BY value is NOT quoted", () => {
3215+
const sql = "COPY trades TO '/export/trades' WITH PARTITION_BY MONTH"
3216+
const result = parseToAst(sql)
3217+
expect(result.errors).toHaveLength(0)
3218+
3219+
const roundtrip = toSql(result.ast[0])
3220+
expect(roundtrip).toContain("PARTITION_BY MONTH")
3221+
expect(roundtrip).not.toContain("PARTITION_BY 'MONTH'")
3222+
3223+
const result2 = parseToAst(roundtrip)
3224+
expect(result2.errors).toHaveLength(0)
3225+
})
3226+
3227+
it("COPY TO round-trip: FORMAT, PARTITION_BY and COMPRESSION_CODEC are NOT quoted", () => {
3228+
const sql =
3229+
"COPY trades TO '/export/trades' WITH FORMAT PARQUET " +
3230+
"PARTITION_BY MONTH COMPRESSION_CODEC ZSTD"
3231+
const result = parseToAst(sql)
3232+
expect(result.errors).toHaveLength(0)
3233+
3234+
const roundtrip = toSql(result.ast[0])
3235+
expect(roundtrip).toContain("FORMAT PARQUET")
3236+
expect(roundtrip).toContain("PARTITION_BY MONTH")
3237+
expect(roundtrip).toContain("COMPRESSION_CODEC ZSTD")
3238+
3239+
const result2 = parseToAst(roundtrip)
3240+
expect(result2.errors).toHaveLength(0)
3241+
})
3242+
3243+
it("COPY TO round-trip: PARQUET_VERSION accepts bare number literal", () => {
3244+
const sql =
3245+
"COPY trades TO '/export/trades' WITH FORMAT PARQUET PARQUET_VERSION 2"
3246+
const result = parseToAst(sql)
3247+
expect(result.errors).toHaveLength(0)
3248+
3249+
const roundtrip = toSql(result.ast[0])
3250+
expect(roundtrip).toContain("PARQUET_VERSION 2")
3251+
3252+
const result2 = parseToAst(roundtrip)
3253+
expect(result2.errors).toHaveLength(0)
3254+
})
3255+
3256+
it("COPY FROM round-trip: string literal options are still quoted", () => {
3257+
const sql = "COPY trades FROM '/data/trades.csv' WITH DELIMITER ','"
3258+
const result = parseToAst(sql)
3259+
expect(result.errors).toHaveLength(0)
3260+
3261+
const roundtrip = toSql(result.ast[0])
3262+
expect(roundtrip).toContain("DELIMITER ','")
3263+
3264+
const result2 = parseToAst(roundtrip)
3265+
expect(result2.errors).toHaveLength(0)
3266+
})
3267+
3268+
it("COPY TO with subquery and all Parquet options", () => {
3269+
const sql =
3270+
"COPY (SELECT * FROM trades WHERE timestamp IN '2024') TO '/export/trades' " +
3271+
"WITH FORMAT PARQUET PARTITION_BY MONTH COMPRESSION_CODEC ZSTD COMPRESSION_LEVEL 3 " +
3272+
"ROW_GROUP_SIZE 100000 DATA_PAGE_SIZE 1048576 STATISTICS_ENABLED true PARQUET_VERSION 2 " +
3273+
"RAW_ARRAY_ENCODING true"
3274+
const result = parseToAst(sql)
3275+
expect(result.errors).toHaveLength(0)
3276+
3277+
const roundtrip = toSql(result.ast[0])
3278+
const result2 = parseToAst(roundtrip)
3279+
expect(result2.errors).toHaveLength(0)
3280+
})
32133281
})
32143282

32153283
// ===========================================================================

0 commit comments

Comments
 (0)