fix(sql): interpolate braced parameters in string literals

This commit is contained in:
t8y2 2026-07-26 15:52:02 +08:00
parent 5e7ab62066
commit 6a796ae97e
No known key found for this signature in database
2 changed files with 94 additions and 15 deletions

View File

@ -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", () => {

View File

@ -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<string, SqlP
let cursor = 0;
for (const occurrence of occurrences) {
result += sql.slice(cursor, occurrence.start);
result += sqlParameterLiteral(values[occurrence.key] ?? { kind: "string", value: "" });
const input = values[occurrence.key] ?? { kind: "string", value: "" };
// Embedded placeholders stay inside the surrounding SQL string, so their value
// must be escaped as text instead of being wrapped in a second SQL literal.
result += occurrence.replacement === "string-fragment" ? sqlParameterStringFragment(input) : sqlParameterLiteral(input);
cursor = occurrence.end;
}
result += sql.slice(cursor);
@ -104,16 +108,21 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions)
const next = sql[i + 1];
if (ch === "'" || ch === '"') {
// Exact quoted braced placeholders only (`'${name}'` / `"#{name}"`).
// Do not scan inside arbitrary quoted text — that path needs dialect-specific
// escaping contracts (see PR #3666) and must not change skipQuoted semantics.
// Exact quoted placeholders use SQL-literal replacement; embedded placeholders
// in ordinary single-quoted values use escaped text replacement below.
const quoted = tryReadQuotedBracedPlaceholder(sql, i, ch as "'" | '"', isSyntaxEnabled);
if (quoted) {
occurrences.push(quoted);
i = quoted.end;
continue;
}
i = skipQuoted(sql, i, ch);
const quotedEnd = skipQuoted(sql, i, ch);
// Double quotes can delimit identifiers, so only ordinary single-quoted
// values opt into embedded interpolation.
if (ch === "'" && !hasSqlStringLiteralPrefix(sql, i)) {
occurrences.push(...collectEmbeddedQuotedBracedPlaceholders(sql, i + 1, quotedEnd, isSyntaxEnabled));
}
i = quotedEnd;
continue;
}
if (ch === "`") {
@ -932,6 +941,48 @@ function tryReadQuotedBracedPlaceholder(sql: string, start: number, quote: "'" |
};
}
function collectEmbeddedQuotedBracedPlaceholders(sql: string, contentStart: number, quotedEnd: number, isSyntaxEnabled: (syntax: SqlParameterSyntax) => 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";