From 6a796ae97e0f963058001ecc2da15779fe98f325 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 26 Jul 2026 15:52:02 +0800 Subject: [PATCH] fix(sql): interpolate braced parameters in string literals --- .../lib/__tests__/sql/sqlParameters.spec.ts | 44 ++++++++++--- apps/desktop/src/lib/sql/sqlParameters.ts | 65 +++++++++++++++++-- 2 files changed, 94 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index b1d174872..50d42cfb1 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -7,7 +7,7 @@ describe("extractSqlParameters", () => { expect(extractSqlParameters(sql)).toEqual(["start_date", "end_date"]); }); - it("extracts exact quoted braced placeholders but ignores partial embeds, backticks, and comments", () => { + it("extracts quoted braced placeholders while ignoring backticks and comments", () => { const sql = ` select '\${quoted}' as a, "\${identifier}" as b, \`\${mysql_identifier}\` , 'prefix\${embedded}' as c, 'x#{partial}' as d @@ -19,8 +19,8 @@ describe("extractSqlParameters", () => { from t where id = \${id} `; - expect(extractSqlParameters(sql)).toEqual(["quoted", "identifier", "id"]); - expect(extractSqlParameterDescriptors("select * from t where dt='\${date}' and flag=\"#{enabled}\"")).toEqual([ + expect(extractSqlParameters(sql)).toEqual(["quoted", "identifier", "embedded", "partial", "id"]); + expect(extractSqlParameterDescriptors("select * from t where dt='${date}' and flag=\"#{enabled}\"")).toEqual([ { key: "date", name: "date", syntax: "shell", token: "'${date}'" }, { key: "enabled", name: "enabled", syntax: "mybatis", token: '"#{enabled}"' }, ]); @@ -400,10 +400,27 @@ describe("substituteSqlParameters", () => { ).toBe("select * from t where dt = '2026-06-26' and name = 'O''Reilly' and flag = TRUE and id = 7"); }); - it("does not treat partial quoted embeds as parameters", () => { - const sql = "select 'prefix${date}' as a, \"x#{id}y\" as b, ${real}"; - expect(extractSqlParameters(sql)).toEqual(["real"]); - expect(substituteSqlParameters(sql, { real: { kind: "number", value: "1" }, date: { kind: "string", value: "x" }, id: { kind: "number", value: "2" } })).toBe("select 'prefix${date}' as a, \"x#{id}y\" as b, 1"); + it("replaces placeholders embedded in ordinary SQL string values", () => { + const sql = "select 'prefix${date}' as a, 'x#{id}y' as b, \"x#{identifier}y\" as c, ${real}"; + expect(extractSqlParameters(sql)).toEqual(["date", "id", "real"]); + expect( + substituteSqlParameters(sql, { + real: { kind: "number", value: "1" }, + date: { kind: "string", value: "O'Reilly" }, + id: { kind: "number", value: "2" }, + identifier: { kind: "string", value: "ignored" }, + }), + ).toBe("select 'prefixO''Reilly' as a, 'x2y' as b, \"x#{identifier}y\" as c, 1"); + }); + + it("supports embedded placeholders in the issue reproduction", () => { + const sql = "INSERT INTO ${dbSchema}.dbx_smoke (note) VALUES ('${FOO} DBX smoke δΈ­ζ–‡ πŸš€')"; + expect( + substituteSqlParameters(sql, { + dbSchema: { kind: "raw", value: "public" }, + FOO: { kind: "string", value: "O'Reilly" }, + }), + ).toBe("INSERT INTO public.dbx_smoke (note) VALUES ('O''Reilly DBX smoke δΈ­ζ–‡ πŸš€')"); }); it("ignores prefixed string literals such as E/U&/B/X/N quotes", () => { @@ -431,10 +448,10 @@ describe("substituteSqlParameters", () => { ).toBe("select _utf8mb4'${flag}' as a, _binary'#{amount}' as b, _custom_charset'${name}' as c, 'ok' as d"); }); - it("ignores doubled-quote continuations that are not exact quoted placeholders", () => { + it("handles doubled-quote continuations inside interpolated strings", () => { const single = "select '${value}''suffix' as a, ${real}"; - expect(extractSqlParameters(single)).toEqual(["real"]); - expect(substituteSqlParameters(single, { value: { kind: "boolean", value: "true" }, real: { kind: "number", value: "1" } })).toBe("select '${value}''suffix' as a, 1"); + expect(extractSqlParameters(single)).toEqual(["value", "real"]); + expect(substituteSqlParameters(single, { value: { kind: "boolean", value: "true" }, real: { kind: "number", value: "1" } })).toBe("select 'true''suffix' as a, 1"); const double = 'select "${value}""suffix" as a, ${real}'; expect(extractSqlParameters(double)).toEqual(["real"]); @@ -566,6 +583,13 @@ describe("enabledSyntaxes option", () => { expect(extractSqlParameters(sql, { enabledSyntaxes: ["mybatis"] })).toEqual(["mybatis_name"]); expect(substituteSqlParameters(sql, { shell_name: { kind: "string", value: "x" } }, { enabledSyntaxes: ["named"] })).toBe(sql); }); + + it("respects enabledSyntaxes for embedded quoted braced placeholders", () => { + const sql = "select 'x${shell_name}y' as a, 'x#{mybatis_name}y' as b"; + expect(extractSqlParameters(sql, { enabledSyntaxes: ["shell"] })).toEqual(["shell_name"]); + expect(extractSqlParameters(sql, { enabledSyntaxes: ["mybatis"] })).toEqual(["mybatis_name"]); + expect(substituteSqlParameters(sql, { shell_name: { kind: "string", value: "a" }, mybatis_name: { kind: "string", value: "b" } }, { enabledSyntaxes: ["named"] })).toBe(sql); + }); }); describe("sqlParameterLiteral", () => { diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index 64ce7492b..c7d433e85 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -19,6 +19,7 @@ export interface SqlParameterDescriptor { interface ParameterOccurrence extends SqlParameterDescriptor { start: number; end: number; + replacement?: "string-fragment"; } type ComplexTypeDeclarationKind = "struct" | "variant"; @@ -63,7 +64,10 @@ export function substituteSqlParameters(sql: string, values: Record boolean): ParameterOccurrence[] { + const occurrences: ParameterOccurrence[] = []; + const contentEnd = sql[quotedEnd - 1] === "'" ? quotedEnd - 1 : quotedEnd; + let i = contentStart; + + while (i < contentEnd) { + const ch = sql[i]; + const next = sql[i + 1]; + let syntax: SqlParameterSyntax | null = null; + if (ch === "$" && next === "{") syntax = "shell"; + else if (ch === "#" && next === "{") syntax = "mybatis"; + if (!syntax || !isSyntaxEnabled(syntax)) { + i += 1; + continue; + } + + const closeBrace = sql.indexOf("}", i + 2); + if (closeBrace === -1 || closeBrace >= contentEnd) { + i += 1; + continue; + } + const name = sql.slice(i + 2, closeBrace).trim(); + if (!PARAMETER_NAME_RE.test(name)) { + i += 1; + continue; + } + + occurrences.push({ + key: name, + name, + syntax, + token: sql.slice(i, closeBrace + 1), + start: i, + end: closeBrace + 1, + replacement: "string-fragment", + }); + i = closeBrace + 1; + } + + return occurrences; +} + /** True when `quoteStart` opens a prefixed literal such as E'...', U&'...', or MySQL _charset'...'. */ function hasSqlStringLiteralPrefix(sql: string, quoteStart: number): boolean { if (quoteStart <= 0) return false; @@ -1024,6 +1075,10 @@ function quoteSqlString(value: string): string { return `'${value.replace(/'/g, "''")}'`; } +function sqlParameterStringFragment(input: SqlParameterInput): string { + return input.value.replace(/'/g, "''"); +} + function normalizeBooleanLiteral(value: string): string { const normalized = value.trim().toLowerCase(); if (normalized === "true" || normalized === "t" || normalized === "yes" || normalized === "y" || normalized === "1") return "TRUE";