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

+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] ?? "";