diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index e65cb55a52..136d81af5c 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -253,6 +253,66 @@ describe("extractSqlParameters", () => { expect(substituteSqlParameters(sql, { real: { kind: "number", value: "7" } }, { databaseType: "doris" })).toBe("create table `broken` (value struct { + const sql = ` + select { + 'compact':column, + 'spaced' : other_column, + bare_key:third_column, + 'nested':{'inner':nested_column}, + 'listed':[{'item':list_column}], + 'mapped':map(['entry'], [{'value':mapped_column}]) + } + from t + `; + + expect(extractSqlParameters(sql, { databaseType: "duckdb" })).toEqual([]); + expect(substituteSqlParameters(sql, {}, { databaseType: "duckdb" })).toBe(sql); + }); + + it("keeps real DuckDB named placeholders in struct values and outside structs", () => { + const sql = "select {'key': :value, 'nested': {'inner':coalesce(:nested_value, fallback_column)}}, :outside from t"; + + expect(extractSqlParameters(sql, { databaseType: "duckdb" })).toEqual(["value", "nested_value", "outside"]); + expect( + substituteSqlParameters( + sql, + { + value: { kind: "number", value: "1" }, + nested_value: { kind: "number", value: "2" }, + outside: { kind: "number", value: "3" }, + }, + { databaseType: "duckdb" }, + ), + ).toBe("select {'key': 1, 'nested': {'inner':coalesce(2, fallback_column)}}, 3 from t"); + }); + + it("does not globally hide DuckDB named placeholders inside braces", () => { + const sql = "select {'key':column}, {fn coalesce(:inside, 1)}, :outside"; + + expect(extractSqlParameters(sql, { databaseType: "duckdb" })).toEqual(["inside", "outside"]); + expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["column", "inside", "outside"]); + }); + + it("ignores DuckDB struct separators around comments and quoted values", () => { + const sql = ` + select { + 'key' /* field separator */ :column, + 'text':'literal :ignored', + 'call':coalesce(:value, {'inner':inner_column}) + }, :outside + -- {'comment':comment_column} + `; + + expect(extractSqlParameters(sql, { databaseType: "duckdb" })).toEqual(["value", "outside"]); + }); + + it("falls back conservatively for an unterminated DuckDB struct literal", () => { + const sql = "select {'key':column, 'nested':{'inner':nested_column}\nunion all select :later"; + + expect(extractSqlParameters(sql, { databaseType: "duckdb" })).toEqual(["column", "nested_column", "later"]); + }); + it("ignores Doris VARIANT field type separators", () => { const sql = ` create table \`events\` ( diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index 47b0b49b04..e54d8005fa 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -27,6 +27,13 @@ interface ParameterOccurrence extends SqlParameterDescriptor { replacement?: "string-fragment"; } +interface DuckDbStructLiteralContext { + bracketDepth: number; + parenthesisDepth: number; + separators: Set; + valid: boolean; +} + type ComplexTypeDeclarationKind = "struct" | "variant"; type TriggerPseudoRecordName = "new" | "old" | "parent" | "eventinfo"; @@ -108,6 +115,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) const enabledSyntaxes = options?.enabledSyntaxes ? new Set(options.enabledSyntaxes) : null; const isSyntaxEnabled = (syntax: SqlParameterSyntax) => !enabledSyntaxes || enabledSyntaxes.has(syntax); const complexTypeFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") ? collectComplexTypeFieldSeparators(sql) : new Set(); + const duckDbStructFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") && options?.databaseType === "duckdb" ? collectDuckDbStructFieldSeparators(sql) : new Set(); const triggerPseudoRecordFieldStarts = supportsNamedParameters && isSyntaxEnabled("named") ? collectTriggerPseudoRecordFieldStarts(sql, options?.databaseType) : new Set(); let i = 0; let dollarQuoteEnd = ""; @@ -168,7 +176,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) } if (ch === ":" && supportsNamedParameters && isSyntaxEnabled("named")) { const name = readParameterName(sql, i + 1); - if (name && sql[i - 1] !== ":" && sql[i + 1] !== "=" && !complexTypeFieldSeparators.has(i) && !triggerPseudoRecordFieldStarts.has(i)) { + if (name && sql[i - 1] !== ":" && sql[i + 1] !== "=" && !complexTypeFieldSeparators.has(i) && !duckDbStructFieldSeparators.has(i) && !triggerPseudoRecordFieldStarts.has(i)) { occurrences.push({ key: name, name, @@ -222,6 +230,104 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) return occurrences; } +function collectDuckDbStructFieldSeparators(sql: string): Set { + const separators = new Set(); + const contexts: DuckDbStructLiteralContext[] = []; + let cursor = 0; + let dollarQuoteEnd = ""; + + while (cursor < sql.length) { + if (dollarQuoteEnd) { + const end = sql.indexOf(dollarQuoteEnd, cursor); + if (end === -1) break; + cursor = end + dollarQuoteEnd.length; + dollarQuoteEnd = ""; + continue; + } + + const ch = sql[cursor]; + const next = sql[cursor + 1]; + if (ch === "'" || ch === '"' || ch === "`") { + cursor = skipQuoted(sql, cursor, ch); + continue; + } + if (ch === "-" && next === "-") { + cursor = skipLine(sql, cursor + 2); + continue; + } + if (ch === "/" && next === "*") { + cursor = skipBlockComment(sql, cursor + 2); + continue; + } + if (isHashLineComment(sql, cursor)) { + cursor = skipLine(sql, cursor + 1); + continue; + } + if (ch === "$") { + const marker = readDollarQuoteMarker(sql, cursor); + if (marker) { + dollarQuoteEnd = marker; + cursor += marker.length; + continue; + } + } + if (ch === "{") { + const separator = readDuckDbStructFieldSeparator(sql, cursor + 1); + contexts.push({ + bracketDepth: 0, + parenthesisDepth: 0, + separators: new Set(separator === null ? [] : [separator]), + valid: separator !== null, + }); + cursor += 1; + continue; + } + if (ch === "}") { + const context = contexts.pop(); + if (context?.valid) { + const parent = contexts[contexts.length - 1]; + const destination = parent ? parent.separators : separators; + for (const separator of context.separators) destination.add(separator); + } + cursor += 1; + continue; + } + + const context = contexts[contexts.length - 1]; + if (!context) { + cursor += 1; + continue; + } + if (ch === "(") context.parenthesisDepth += 1; + else if (ch === ")" && context.parenthesisDepth > 0) context.parenthesisDepth -= 1; + else if (ch === "[") context.bracketDepth += 1; + else if (ch === "]" && context.bracketDepth > 0) context.bracketDepth -= 1; + else if (ch === "," && context.valid && context.parenthesisDepth === 0 && context.bracketDepth === 0) { + const separator = readDuckDbStructFieldSeparator(sql, cursor + 1); + if (separator !== null) context.separators.add(separator); + } + cursor += 1; + } + + return separators; +} + +function readDuckDbStructFieldSeparator(sql: string, start: number): number | null { + const fieldStart = skipSqlWhitespaceAndComments(sql, start); + const ch = sql[fieldStart]; + let fieldNameEnd = fieldStart; + + if (ch === "'" || ch === '"' || ch === "`") fieldNameEnd = skipQuoted(sql, fieldStart, ch); + else { + const fieldName = readParameterName(sql, fieldStart); + if (!fieldName) return null; + fieldNameEnd += fieldName.length; + } + + const separator = skipSqlWhitespaceAndComments(sql, fieldNameEnd); + return sql[separator] === ":" ? separator : null; +} + // Oracle and Dameng expose trigger rows through colon-prefixed pseudo-records, // unlike PostgreSQL's unprefixed NEW/OLD records. Keep ordinary :name binds enabled. function collectTriggerPseudoRecordFieldStarts(sql: string, databaseType?: DatabaseType): Set {