feat(sql): support MyBatis foreach collection parameters

This commit is contained in:
zipg authored and GitHub committed 2026-08-13 02:10:51 +08:00
1 parent 433626f5c9
commit d4d6501af6
11 files changed
+399 -14

No files matched your search

@@ -47,6 +47,10 @@ const syntaxLabels: Record<SqlParameterSyntax, string> = {
sqlserver: "@name",
};
function syntaxLabel(parameter: SqlParameterDescriptor): string {
return parameter.collection ? "<foreach>" : syntaxLabels[parameter.syntax];
}
const resolvedSql = computed(() => substituteSqlParameters(props.sql, values.value, { databaseType: props.databaseType, enabledSyntaxes: props.enabledSyntaxes }));
const highlightedSql = computed(() => highlight(resolvedSql.value));
@@ -143,7 +147,7 @@ async function copyResolvedSql() {
</div>
<div v-for="parameter in parameters" :key="parameter.key" class="grid grid-cols-[minmax(140px,1fr)_104px_132px_minmax(180px,1.5fr)] items-center gap-2 border-b px-3 py-2 text-sm last:border-b-0">
<div class="min-w-0 truncate font-mono text-xs">{{ parameter.name }}</div>
<div class="min-w-0 truncate font-mono text-[11px] text-muted-foreground">{{ syntaxLabels[parameter.syntax] }}</div>
<div class="min-w-0 truncate font-mono text-[11px] text-muted-foreground">{{ syntaxLabel(parameter) }}</div>
<Select :model-value="values[parameter.key]?.kind || 'string'" @update:model-value="(value) => updateKind(parameter.key, value as SqlParameterValueKind)">
<SelectTrigger class="h-8 bg-background text-xs">
<SelectValue />
@@ -164,7 +168,7 @@ async function copyResolvedSql() {
autocomplete="off"
data-lpignore="true"
data-form-type="other"
:placeholder="t('sqlParameters.valuePlaceholder')"
:placeholder="parameter.collection ? t('sqlParameters.collectionValuePlaceholder') : t('sqlParameters.valuePlaceholder')"
@focus="focusParameterInput(parameter.key, $event)"
@blur="closeParameterHistory(parameter.key)"
@update:model-value="(value) => updateValue(parameter.key, String(value))"
+1
View File
@@ -4582,6 +4582,7 @@ export default {
type: "Type",
value: "Value",
valuePlaceholder: "Enter value",
collectionValuePlaceholder: "Enter a JSON array or comma-separated values",
preview: "SQL Preview",
execute: "Execute",
kind: {
+1
View File
@@ -4372,6 +4372,7 @@ export default withEnglishFallback({
type: "Tipo",
value: "Valor",
valuePlaceholder: "Introduce un valor",
collectionValuePlaceholder: "Introduce un array JSON o valores separados por comas",
preview: "Vista previa SQL",
execute: "Ejecutar",
kind: {
+1
View File
@@ -4370,6 +4370,7 @@ export default withEnglishFallback({
type: "Tipo",
value: "Valore",
valuePlaceholder: "Inserisci valore",
collectionValuePlaceholder: "Inserisci un array JSON o valori separati da virgole",
preview: "Anteprima SQL",
execute: "Esegui",
kind: {
+1
View File
@@ -4400,6 +4400,7 @@ export default withEnglishFallback({
type: "型",
value: "値",
valuePlaceholder: "値を入力",
collectionValuePlaceholder: "JSON 配列またはカンマ区切りの値を入力",
preview: "SQLプレビュー",
execute: "実行",
kind: {
+1
View File
@@ -4085,6 +4085,7 @@ export default withEnglishFallback({
type: "유형",
value: "값",
valuePlaceholder: "값 입력",
collectionValuePlaceholder: "JSON 배열 또는 쉼표로 구분된 값 입력",
preview: "SQL 미리보기",
execute: "실행",
kind: {
+1
View File
@@ -4372,6 +4372,7 @@ export default withEnglishFallback({
type: "Tipo",
value: "Valor",
valuePlaceholder: "Digite o valor",
collectionValuePlaceholder: "Insira um array JSON ou valores separados por vírgula",
preview: "Prévia SQL",
execute: "Executar",
kind: {
+1
View File
@@ -4505,6 +4505,7 @@ export default withEnglishFallback({
type: "类型",
value: "参数值",
valuePlaceholder: "输入参数值",
collectionValuePlaceholder: "输入 JSON 数组或逗号分隔值",
preview: "SQL 预览",
execute: "执行",
kind: {
+1
View File
@@ -3717,6 +3717,7 @@ export default withEnglishFallback({
type: "類型",
value: "參數值",
valuePlaceholder: "輸入參數值",
collectionValuePlaceholder: "輸入 JSON 陣列或逗號分隔值",
preview: "SQL 預覽",
execute: "執行",
kind: {
+81 -2
View File
@@ -564,6 +564,75 @@ describe("substituteSqlParameters", () => {
).toBe("select 7, 'Alice', 'prefixO''Reilly'");
});
it("extracts a MyBatis foreach collection instead of its item placeholder", () => {
const sql = `select * from tasks where task_id in
<foreach collection="taskIds" item="taskId" open="(" separator="," close=")">
#{taskId}
</foreach>`;
expect(extractSqlParameterDescriptors(sql, { enabledSyntaxes: ["mybatis"] })).toEqual([{ key: "taskIds", name: "taskIds", syntax: "mybatis", token: "<foreach>", collection: true }]);
});
it("expands a MyBatis foreach JSON string collection with escaped SQL literals", () => {
const sql = `select * from tasks where task_id in
<foreach collection='taskIds' item='taskId' open='(' separator=',' close=')'>#{taskId}</foreach>`;
expect(substituteSqlParameters(sql, { taskIds: { kind: "string", value: `["A", "O'Reilly"]` } }, { databaseType: "oracle", enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks where task_id in\n ('A','O''Reilly')");
});
it("expands comma-separated MyBatis foreach values using the selected item type", () => {
const sql = 'select * from tasks where task_id in <foreach collection="taskIds" item="taskId" open="(" separator=", " close=")">#{taskId}</foreach>';
expect(substituteSqlParameters(sql, { taskIds: { kind: "number", value: "1, 2, 3" } }, { enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks where task_id in (1, 2, 3)");
});
it("renders an empty MyBatis foreach collection as a safe legal IN list", () => {
const sql = 'select * from tasks where task_id in <foreach collection="taskIds" item="taskId" open="(" separator="," close=")">#{taskId}</foreach>';
expect(substituteSqlParameters(sql, { taskIds: { kind: "string", value: "[]" } }, { enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks where task_id in (NULL)");
expect(substituteSqlParameters(sql, { taskIds: { kind: "string", value: "[1,2" } }, { enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks where task_id in (NULL)");
expect(substituteSqlParameters(sql, { taskIds: { kind: "string", value: '[{"id":1}]' } }, { enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks where task_id in (NULL)");
});
it("substitutes other placeholders inside each MyBatis foreach body", () => {
const sql = 'select * from task_pairs where (task_id, tenant_id) in <foreach collection="taskIds" item="taskId" open="(" separator="," close=")">(#{taskId}, #{tenantId})</foreach>';
expect(substituteSqlParameters(sql, { taskIds: { kind: "number", value: "[1,2]" }, tenantId: { kind: "number", value: "9" } }, { enabledSyntaxes: ["mybatis"] })).toBe("select * from task_pairs where (task_id, tenant_id) in ((1, 9),(2, 9))");
expect(extractSqlParameterDescriptors(sql, { enabledSyntaxes: ["mybatis"] })).toEqual([
{ key: "taskIds", name: "taskIds", syntax: "mybatis", token: "<foreach>", collection: true },
{ key: "tenantId", name: "tenantId", syntax: "mybatis", token: "#{tenantId}" },
]);
});
it("uses the zero-based MyBatis foreach index without exposing it as a global parameter", () => {
const sql = 'select * from task_pairs where (position, task_id) in <foreach collection="taskIds" item="taskId" index="idx" open="(" separator="," close=")">(#{idx},#{taskId})</foreach>';
expect(extractSqlParameterDescriptors(sql, { enabledSyntaxes: ["mybatis"] })).toEqual([{ key: "taskIds", name: "taskIds", syntax: "mybatis", token: "<foreach>", collection: true }]);
expect(substituteSqlParameters(sql, { taskIds: { kind: "number", value: "[10,20]" }, idx: { kind: "number", value: "99" } }, { enabledSyntaxes: ["mybatis"] })).toBe("select * from task_pairs where (position, task_id) in ((0,10),(1,20))");
});
it("ignores foreach-like tags in SQL strings and comments when matching the closing tag", () => {
const sql = `select * from tasks where task_id in <foreach collection="taskIds" item="taskId" open="(" separator="," close=")">
#{taskId} /* </foreach> */ || '<foreach collection="fake" item="value">'
-- <foreach collection="fake" item="value">
</foreach>`;
expect(substituteSqlParameters(sql, { taskIds: { kind: "number", value: "[1,2]" } }, { enabledSyntaxes: ["mybatis"] })).toBe(`select * from tasks where task_id in (
1 /* </foreach> */ || '<foreach collection="fake" item="value">'
-- <foreach collection="fake" item="value">
,
2 /* </foreach> */ || '<foreach collection="fake" item="value">'
-- <foreach collection="fake" item="value">
)`);
});
it("leaves MyBatis foreach tags untouched when MyBatis substitution is disabled", () => {
const sql = 'select * from tasks where task_id in <foreach collection="taskIds" item="taskId" open="(" separator="," close=")">#{taskId}</foreach>';
expect(extractSqlParameterDescriptors(sql, { enabledSyntaxes: ["shell"] })).toEqual([]);
expect(substituteSqlParameters(sql, {}, { enabledSyntaxes: ["shell"] })).toBe(sql);
});
it("decodes XML comparison entities when substituting MyBatis parameters", () => {
const sql = "select * from orders where created_at &gt;= #{start} and created_at &lt; #{end} and owner_id = #{owner_id} or reviewer_id = #{owner_id}";
@@ -580,7 +649,7 @@ describe("substituteSqlParameters", () => {
).toBe("select * from orders where created_at >= '2026-01-01' and created_at < '2026-02-01' and owner_id = 7 or reviewer_id = 7");
});
it("only decodes exact comparison entities in MyBatis SQL source fragments", () => {
it("only decodes comparison entities in executable MyBatis SQL", () => {
const sql = "select '&amp;', '&quot;', '&apos;', '&amp;lt;', '&LT;', '&lt', '&lt;source&gt;', #{raw_value}, #{string_value} where score &gt; #{minimum} /* &lt;source-comment&gt; */";
expect(
@@ -593,7 +662,17 @@ describe("substituteSqlParameters", () => {
},
{ enabledSyntaxes: ["mybatis"] },
),
).toBe("select '&amp;', '&quot;', '&apos;', '&amp;lt;', '&LT;', '&lt', '<source>', '&lt;raw&gt;', '&lt;string&gt;' where score > 10 /* <source-comment> */");
).toBe("select '&amp;', '&quot;', '&apos;', '&amp;lt;', '&LT;', '&lt', '&lt;source&gt;', '&lt;raw&gt;', '&lt;string&gt;' where score > 10 /* &lt;source-comment&gt; */");
});
it("preserves comparison entities in quoted identifiers, comments, and dollar-quoted strings", () => {
const sql = `select "a&lt;b", \`c&gt;d\`, [e&lt;f], $$g&gt;h$$, #{id}
where score &gt; 0 /* &lt;block&gt; */ -- &gt; line
and score &lt; 10`;
expect(substituteSqlParameters(sql, { id: { kind: "number", value: "7" } }, { enabledSyntaxes: ["mybatis"] })).toBe(`select "a&lt;b", \`c&gt;d\`, [e&lt;f], $$g&gt;h$$, 7
where score > 0 /* &lt;block&gt; */ -- &gt; line
and score < 10`);
});
it("does not decode XML entities without an enabled valid MyBatis replacement", () => {
+304 -10
View File
@@ -14,6 +14,7 @@ export interface SqlParameterDescriptor {
name: string;
syntax: SqlParameterSyntax;
token: string;
collection?: boolean;
}
export interface SqlBracedParameter extends SqlParameterDescriptor {
@@ -24,7 +25,17 @@ export interface SqlBracedParameter extends SqlParameterDescriptor {
interface ParameterOccurrence extends SqlParameterDescriptor {
start: number;
end: number;
replacement?: "string-fragment";
replacement?: "string-fragment" | "mybatis-foreach";
foreach?: MyBatisForeach;
}
interface MyBatisForeach {
item: string;
index?: string;
open: string;
separator: string;
close: string;
body: string;
}
interface DuckDbStructLiteralContext {
@@ -150,15 +161,30 @@ export function extractSqlParameters(sql: string, options?: SqlParameterOptions)
export function extractSqlParameterDescriptors(sql: string, options?: SqlParameterOptions): SqlParameterDescriptor[] {
const names = new Set<string>();
const descriptors: SqlParameterDescriptor[] = [];
const appendDescriptor = (descriptor: SqlParameterDescriptor) => {
if (names.has(descriptor.key)) {
if (descriptor.collection) {
const existing = descriptors.find((item) => item.key === descriptor.key);
if (existing) Object.assign(existing, { token: descriptor.token, collection: true });
}
return;
}
names.add(descriptor.key);
descriptors.push(descriptor);
};
for (const occurrence of findSqlParameterOccurrences(sql, options)) {
if (names.has(occurrence.key)) continue;
names.add(occurrence.key);
descriptors.push({
appendDescriptor({
key: occurrence.key,
name: occurrence.name,
syntax: occurrence.syntax,
token: occurrence.token,
...(occurrence.collection ? { collection: true } : {}),
});
if (occurrence.replacement === "mybatis-foreach" && occurrence.foreach) {
for (const nested of extractSqlParameterDescriptors(occurrence.foreach.body, options)) {
if (nested.key !== occurrence.foreach.item && nested.key !== occurrence.foreach.index) appendDescriptor(nested);
}
}
}
return descriptors;
}
@@ -166,24 +192,143 @@ export function extractSqlParameterDescriptors(sql: string, options?: SqlParamet
export function substituteSqlParameters(sql: string, values: Record<string, SqlParameterInput>, options?: SqlParameterOptions): string {
const occurrences = findSqlParameterOccurrences(sql, options);
if (!occurrences.length) return sql;
const decodeSourceFragment = occurrences.some((occurrence) => occurrence.syntax === "mybatis") ? decodeMyBatisXmlComparisonEntities : (fragment: string) => fragment;
const decodeMyBatisEntities = occurrences.some((occurrence) => occurrence.syntax === "mybatis");
let result = "";
let cursor = 0;
for (const occurrence of occurrences) {
result += decodeSourceFragment(sql.slice(cursor, occurrence.start));
result += sql.slice(cursor, occurrence.start);
const input = values[occurrence.key] ?? { kind: "string", value: "" };
if (occurrence.replacement === "mybatis-foreach" && occurrence.foreach) {
result += renderMyBatisForeach(occurrence.foreach, input, values, options);
cursor = occurrence.end;
continue;
}
// 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 += decodeSourceFragment(sql.slice(cursor));
return result;
result += sql.slice(cursor);
return decodeMyBatisEntities ? decodeMyBatisXmlComparisonEntities(result) : result;
}
function decodeMyBatisXmlComparisonEntities(fragment: string): string {
return fragment.replace(/&(?:lt|gt);/g, (entity) => (entity === "&lt;" ? "<" : ">"));
function renderMyBatisForeach(foreach: MyBatisForeach, input: SqlParameterInput, values: Record<string, SqlParameterInput>, options?: SqlParameterOptions): string {
const items = parseSqlParameterList(input.value);
if (items.length === 0) return `${foreach.open}NULL${foreach.close}`;
const renderedItems = items.map((item, index) => {
const itemInput: SqlParameterInput = item === null ? { kind: "null", value: "NULL" } : { kind: input.kind, value: String(item) };
const iterationValues = { ...values, [foreach.item]: itemInput };
if (foreach.index) iterationValues[foreach.index] = { kind: "number", value: String(index) };
return substituteSqlParameters(foreach.body, iterationValues, options);
});
return `${foreach.open}${renderedItems.join(foreach.separator)}${foreach.close}`;
}
function parseSqlParameterList(raw: string): Array<string | number | boolean | null> {
const value = raw.trim();
if (!value) return [];
if (value.startsWith("[")) {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) && parsed.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item))) return parsed;
} catch {
return [];
}
return [];
}
return splitCommaSeparatedValues(value).map(unquoteListValue);
}
function splitCommaSeparatedValues(value: string): string[] {
const items: string[] = [];
let start = 0;
let quote = "";
for (let i = 0; i < value.length; i += 1) {
const ch = value[i];
if (quote) {
if (ch === "\\" && quote === '"' && i + 1 < value.length) i += 1;
else if (ch === quote) {
if (value[i + 1] === quote) i += 1;
else quote = "";
}
continue;
}
if (ch === "'" || ch === '"') quote = ch;
else if (ch === ",") {
items.push(value.slice(start, i).trim());
start = i + 1;
}
}
items.push(value.slice(start).trim());
return items;
}
function unquoteListValue(value: string): string {
if (value.length < 2 || value[0] !== value[value.length - 1] || (value[0] !== "'" && value[0] !== '"')) return value;
const quote = value[0];
const inner = value.slice(1, -1);
if (quote === "'") return inner.replace(/''/g, "'");
try {
return JSON.parse(value);
} catch {
return inner.replace(/""/g, '"');
}
}
function decodeMyBatisXmlComparisonEntities(sql: string): string {
let result = "";
let cursor = 0;
let i = 0;
while (i < sql.length) {
const ch = sql[i];
const next = sql[i + 1];
if (ch === "'" || ch === '"' || ch === "`") {
i = skipQuoted(sql, i, ch);
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
continue;
}
if (ch === "-" && next === "-") {
i = skipLine(sql, i + 2);
continue;
}
if (ch === "/" && next === "*") {
i = skipBlockComment(sql, i + 2);
continue;
}
if (isHashLineComment(sql, i)) {
i = skipLine(sql, i + 1);
continue;
}
if (ch === "$") {
const marker = readDollarQuoteMarker(sql, i);
if (marker) {
const end = sql.indexOf(marker, i + marker.length);
i = end === -1 ? sql.length : end + marker.length;
continue;
}
}
const replacement = sql.startsWith("&lt;", i) ? "<" : sql.startsWith("&gt;", i) ? ">" : "";
if (replacement) {
result += sql.slice(cursor, i) + replacement;
i += 4;
cursor = i;
continue;
}
i += 1;
}
return result + sql.slice(cursor);
}
export function sqlParameterLiteral(input: SqlParameterInput): string {
@@ -220,6 +365,25 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions)
const ch = sql[i];
const next = sql[i + 1];
if (ch === "<" && isSyntaxEnabled("mybatis")) {
const foreach = readMyBatisForeachAt(sql, i);
if (foreach) {
occurrences.push({
key: foreach.collection,
name: foreach.collection,
syntax: "mybatis",
token: "<foreach>",
collection: true,
replacement: "mybatis-foreach",
foreach: foreach.render,
start: i,
end: foreach.end,
});
i = foreach.end;
continue;
}
}
if (ch === "'" || ch === '"') {
// Exact quoted placeholders use SQL-literal replacement; embedded placeholders
// in ordinary single-quoted values use escaped text replacement below.
@@ -321,6 +485,136 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions)
return occurrences;
}
function readMyBatisForeachAt(sql: string, start: number): { collection: string; render: MyBatisForeach; end: number } | null {
if (!/^<foreach(?:\s|>)/i.test(sql.slice(start))) return null;
const openingEnd = findXmlTagEnd(sql, start);
if (openingEnd === -1) return null;
const attributes = parseXmlAttributes(sql.slice(start + "<foreach".length, openingEnd));
if (!attributes) return null;
const collection = attributes.get("collection")?.trim() ?? "";
const item = attributes.get("item")?.trim() ?? "";
const index = attributes.get("index")?.trim();
if (!PARAMETER_NAME_RE.test(collection) || !PARAMETER_NAME_RE.test(item) || (index !== undefined && !PARAMETER_NAME_RE.test(index))) return null;
const close = findMatchingForeachClose(sql, openingEnd + 1);
if (!close) return null;
return {
collection,
render: {
item,
...(index ? { index } : {}),
open: attributes.get("open") ?? "",
separator: attributes.get("separator") ?? "",
close: attributes.get("close") ?? "",
body: sql.slice(openingEnd + 1, close.start),
},
end: close.end,
};
}
function findXmlTagEnd(source: string, start: number): number {
let quote = "";
for (let i = start + 1; i < source.length; i += 1) {
const ch = source[i];
if (quote) {
if (ch === quote) quote = "";
} else if (ch === "'" || ch === '"') quote = ch;
else if (ch === ">") return i;
}
return -1;
}
function parseXmlAttributes(source: string): Map<string, string> | null {
const attributes = new Map<string, string>();
let i = 0;
while (i < source.length) {
while (/\s/.test(source[i] ?? "")) i += 1;
if (i >= source.length) break;
const nameStart = i;
while (/[\w:-]/.test(source[i] ?? "")) i += 1;
if (i === nameStart) return null;
const name = source.slice(nameStart, i).toLowerCase();
while (/\s/.test(source[i] ?? "")) i += 1;
if (source[i] !== "=") return null;
i += 1;
while (/\s/.test(source[i] ?? "")) i += 1;
const quote = source[i];
if (quote !== "'" && quote !== '"') return null;
const valueStart = ++i;
while (i < source.length && source[i] !== quote) i += 1;
if (i >= source.length) return null;
attributes.set(name, decodeXmlAttribute(source.slice(valueStart, i)));
i += 1;
}
return attributes;
}
function decodeXmlAttribute(value: string): string {
return value.replace(/&(?:lt|gt|amp|quot|apos);/g, (entity) => ({ "&lt;": "<", "&gt;": ">", "&amp;": "&", "&quot;": '"', "&apos;": "'" })[entity] ?? entity);
}
function findMatchingForeachClose(sql: string, start: number): { start: number; end: number } | null {
let depth = 1;
let i = start;
while (i < sql.length) {
const ch = sql[i];
const next = sql[i + 1];
if (ch === "'" || ch === '"' || ch === "`") {
i = skipQuoted(sql, i, ch);
continue;
}
if (ch === "[") {
i = skipBracketIdentifier(sql, i);
continue;
}
if (ch === "-" && next === "-") {
i = skipLine(sql, i + 2);
continue;
}
if (ch === "/" && next === "*") {
i = skipBlockComment(sql, i + 2);
continue;
}
if (isHashLineComment(sql, i)) {
i = skipLine(sql, i + 1);
continue;
}
if (ch === "$") {
const marker = readDollarQuoteMarker(sql, i);
if (marker) {
const end = sql.indexOf(marker, i + marker.length);
i = end === -1 ? sql.length : end + marker.length;
continue;
}
}
if (ch === "<") {
const tag = readForeachTagAt(sql, i);
if (tag) {
depth += tag.closing ? -1 : 1;
if (depth === 0) return { start: i, end: tag.end };
i = tag.end;
continue;
}
}
i += 1;
}
return null;
}
function readForeachTagAt(sql: string, start: number): { closing: boolean; end: number } | null {
const closing = sql[start + 1] === "/";
const nameStart = start + (closing ? 2 : 1);
if (sql.slice(nameStart, nameStart + "foreach".length).toLowerCase() !== "foreach") return null;
const boundary = sql[nameStart + "foreach".length];
if (boundary !== ">" && !/\s/.test(boundary ?? "")) return null;
const tagEnd = findXmlTagEnd(sql, start);
return tagEnd === -1 ? null : { closing, end: tagEnd + 1 };
}
function isDuckDbCompactPrefixAliasSeparator(sql: string, index: number, databaseType?: DatabaseType): boolean {
if (databaseType !== "duckdb") return false;
const previous = sql[index - 1] ?? "";