feat(sql): support quoted braced placeholders
This commit is contained in:
1 parent
87493881c3
commit
a9e5af3535
2 files changed
+131
-3
No files matched your search
+67
-2
@@ -7,9 +7,10 @@ describe("extractSqlParameters", () => {
|
||||
expect(extractSqlParameters(sql)).toEqual(["start_date", "end_date"]);
|
||||
});
|
||||
|
||||
it("ignores placeholders inside strings, quoted identifiers, and comments", () => {
|
||||
it("extracts exact quoted braced placeholders but ignores partial embeds, backticks, and comments", () => {
|
||||
const sql = `
|
||||
select '\${quoted}' as a, "\${identifier}" as b, \`\${mysql_identifier}\`
|
||||
, 'prefix\${embedded}' as c, 'x#{partial}' as d
|
||||
-- \${line_comment}
|
||||
# \${hash_comment}
|
||||
#\${hash_comment_without_space}
|
||||
@@ -18,7 +19,11 @@ describe("extractSqlParameters", () => {
|
||||
from t
|
||||
where id = \${id}
|
||||
`;
|
||||
expect(extractSqlParameters(sql)).toEqual(["id"]);
|
||||
expect(extractSqlParameters(sql)).toEqual(["quoted", "identifier", "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}"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores placeholders inside Postgres dollar-quoted strings", () => {
|
||||
@@ -292,6 +297,59 @@ describe("substituteSqlParameters", () => {
|
||||
).toBe("select * from t where dt >= '2026-06-26' and amount > 100.50 and enabled = TRUE");
|
||||
});
|
||||
|
||||
it("replaces exact quoted braced placeholders as whole tokens without double-quoting", () => {
|
||||
const sql = "select * from t where dt = '${date}' and name = \"${name}\" and flag = '#{enabled}' and id = ${id}";
|
||||
expect(
|
||||
substituteSqlParameters(sql, {
|
||||
date: { kind: "string", value: "2026-06-26" },
|
||||
name: { kind: "string", value: "O'Reilly" },
|
||||
enabled: { kind: "boolean", value: "true" },
|
||||
id: { kind: "number", value: "7" },
|
||||
}),
|
||||
).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("ignores prefixed string literals such as E/U&/B/X/N quotes", () => {
|
||||
const sql = "select E'${path}' as a, U&'${unicode}' as b, B'${flag}' as c, X'${hex}' as d, N'${national}' as e, '${plain}' as f";
|
||||
expect(extractSqlParameters(sql)).toEqual(["plain"]);
|
||||
expect(
|
||||
substituteSqlParameters(sql, {
|
||||
path: { kind: "string", value: "C:\\new" },
|
||||
flag: { kind: "boolean", value: "true" },
|
||||
plain: { kind: "string", value: "ok" },
|
||||
}),
|
||||
).toBe("select E'${path}' as a, U&'${unicode}' as b, B'${flag}' as c, X'${hex}' as d, N'${national}' as e, 'ok' as f");
|
||||
});
|
||||
|
||||
it("ignores MySQL character-set introducers before quoted placeholders", () => {
|
||||
const sql = "select _utf8mb4'${flag}' as a, _binary'#{amount}' as b, _custom_charset'${name}' as c, '${plain}' as d";
|
||||
expect(extractSqlParameters(sql)).toEqual(["plain"]);
|
||||
expect(
|
||||
substituteSqlParameters(sql, {
|
||||
flag: { kind: "boolean", value: "true" },
|
||||
amount: { kind: "number", value: "12" },
|
||||
name: { kind: "string", value: "ignored" },
|
||||
plain: { kind: "string", value: "ok" },
|
||||
}),
|
||||
).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", () => {
|
||||
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");
|
||||
|
||||
const double = 'select "${value}""suffix" as a, ${real}';
|
||||
expect(extractSqlParameters(double)).toEqual(["real"]);
|
||||
expect(substituteSqlParameters(double, { value: { kind: "boolean", value: "true" }, real: { kind: "number", value: "2" } })).toBe('select "${value}""suffix" as a, 2');
|
||||
});
|
||||
|
||||
it("escapes string values and supports null and raw SQL", () => {
|
||||
const sql = "select ${name}, ${empty_value}, ${expression}";
|
||||
expect(
|
||||
@@ -410,6 +468,13 @@ describe("enabledSyntaxes option", () => {
|
||||
// A non-saphana database with named disabled also drops :name.
|
||||
expect(extractSqlParameters(sql, { enabledSyntaxes: ["shell"] })).toEqual(["shell_name"]);
|
||||
});
|
||||
|
||||
it("respects enabledSyntaxes for exact quoted braced placeholders", () => {
|
||||
const sql = "select '${shell_name}' as a, \"#{mybatis_name}\" 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: "x" } }, { enabledSyntaxes: ["named"] })).toBe(sql);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sqlParameterLiteral", () => {
|
||||
|
||||
@@ -101,7 +101,20 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions)
|
||||
const ch = sql[i];
|
||||
const next = sql[i + 1];
|
||||
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
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.
|
||||
const quoted = tryReadQuotedBracedPlaceholder(sql, i, ch as "'" | '"', isSyntaxEnabled);
|
||||
if (quoted) {
|
||||
occurrences.push(quoted);
|
||||
i = quoted.end;
|
||||
continue;
|
||||
}
|
||||
i = skipQuoted(sql, i, ch);
|
||||
continue;
|
||||
}
|
||||
if (ch === "`") {
|
||||
i = skipQuoted(sql, i, ch);
|
||||
continue;
|
||||
}
|
||||
@@ -735,6 +748,56 @@ function readParameterName(sql: string, start: number): string {
|
||||
return sql.slice(start, i);
|
||||
}
|
||||
|
||||
/** Match only unprefixed quote + exact `${name}`/`#{name}` + same quote. Leaves skipQuoted unchanged. */
|
||||
function tryReadQuotedBracedPlaceholder(sql: string, start: number, quote: "'" | '"', isSyntaxEnabled: (syntax: SqlParameterSyntax) => boolean): ParameterOccurrence | null {
|
||||
if (sql[start] !== quote) return null;
|
||||
// Reject E'...', U&'...', B'...', X'...', N'...' — replacing the quoted span would leave the
|
||||
// prefix attached to a typed literal (e.g. E'${path}' → E'C:\new', B'${flag}' → BTRUE).
|
||||
if (hasSqlStringLiteralPrefix(sql, start)) return null;
|
||||
|
||||
const open = sql.slice(start + 1, start + 3);
|
||||
let syntax: SqlParameterSyntax | null = null;
|
||||
if (open === "${") syntax = "shell";
|
||||
else if (open === "#{") syntax = "mybatis";
|
||||
else return null;
|
||||
if (!isSyntaxEnabled(syntax)) return null;
|
||||
|
||||
const nameStart = start + 3;
|
||||
const closeBrace = sql.indexOf("}", nameStart);
|
||||
if (closeBrace === -1 || sql[closeBrace + 1] !== quote) return null;
|
||||
|
||||
const name = sql.slice(nameStart, closeBrace).trim();
|
||||
if (!PARAMETER_NAME_RE.test(name)) return null;
|
||||
|
||||
const end = closeBrace + 2;
|
||||
// The closing quote must be a real string terminator under the same rules as skipQuoted
|
||||
// (doubled quotes / backslash escapes). Otherwise '${value}''suffix' would match '${value}'.
|
||||
if (skipQuoted(sql, start, quote) !== end) return null;
|
||||
|
||||
return {
|
||||
key: name,
|
||||
name,
|
||||
syntax,
|
||||
token: sql.slice(start, end),
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
if (quoteStart >= 2 && sql[quoteStart - 1] === "&" && (sql[quoteStart - 2] === "U" || sql[quoteStart - 2] === "u") && !PARAMETER_NAME_CHAR_RE.test(sql[quoteStart - 3] ?? "")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// SQL dialects attach word-like introducers directly to the quote. Reject the
|
||||
// whole category so typed replacement cannot leave invalid prefixes such as
|
||||
// `_utf8mb4TRUE`, and so future introducers do not require another allowlist.
|
||||
return PARAMETER_NAME_CHAR_RE.test(sql[quoteStart - 1]);
|
||||
}
|
||||
|
||||
function skipQuoted(sql: string, start: number, quote: string): number {
|
||||
let i = start + 1;
|
||||
while (i < sql.length) {
|
||||
|
||||
Reference in new issue
Block a user