feat(sql): support MyBatis foreach collection parameters
This commit is contained in:
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))"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -4400,6 +4400,7 @@ export default withEnglishFallback({
|
||||
type: "型",
|
||||
value: "値",
|
||||
valuePlaceholder: "値を入力",
|
||||
collectionValuePlaceholder: "JSON 配列またはカンマ区切りの値を入力",
|
||||
preview: "SQLプレビュー",
|
||||
execute: "実行",
|
||||
kind: {
|
||||
|
||||
@@ -4085,6 +4085,7 @@ export default withEnglishFallback({
|
||||
type: "유형",
|
||||
value: "값",
|
||||
valuePlaceholder: "값 입력",
|
||||
collectionValuePlaceholder: "JSON 배열 또는 쉼표로 구분된 값 입력",
|
||||
preview: "SQL 미리보기",
|
||||
execute: "실행",
|
||||
kind: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -4505,6 +4505,7 @@ export default withEnglishFallback({
|
||||
type: "类型",
|
||||
value: "参数值",
|
||||
valuePlaceholder: "输入参数值",
|
||||
collectionValuePlaceholder: "输入 JSON 数组或逗号分隔值",
|
||||
preview: "SQL 预览",
|
||||
execute: "执行",
|
||||
kind: {
|
||||
|
||||
@@ -3717,6 +3717,7 @@ export default withEnglishFallback({
|
||||
type: "類型",
|
||||
value: "參數值",
|
||||
valuePlaceholder: "輸入參數值",
|
||||
collectionValuePlaceholder: "輸入 JSON 陣列或逗號分隔值",
|
||||
preview: "SQL 預覽",
|
||||
execute: "執行",
|
||||
kind: {
|
||||
|
||||
+81
-2
@@ -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 >= #{start} and created_at < #{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 '&', '"', ''', '&lt;', '<', '<', '<source>', #{raw_value}, #{string_value} where score > #{minimum} /* <source-comment> */";
|
||||
|
||||
expect(
|
||||
@@ -593,7 +662,17 @@ describe("substituteSqlParameters", () => {
|
||||
},
|
||||
{ enabledSyntaxes: ["mybatis"] },
|
||||
),
|
||||
).toBe("select '&', '"', ''', '&lt;', '<', '<', '<source>', '<raw>', '<string>' where score > 10 /* <source-comment> */");
|
||||
).toBe("select '&', '"', ''', '&lt;', '<', '<', '<source>', '<raw>', '<string>' where score > 10 /* <source-comment> */");
|
||||
});
|
||||
|
||||
it("preserves comparison entities in quoted identifiers, comments, and dollar-quoted strings", () => {
|
||||
const sql = `select "a<b", \`c>d\`, [e<f], $$g>h$$, #{id}
|
||||
where score > 0 /* <block> */ -- > line
|
||||
and score < 10`;
|
||||
|
||||
expect(substituteSqlParameters(sql, { id: { kind: "number", value: "7" } }, { enabledSyntaxes: ["mybatis"] })).toBe(`select "a<b", \`c>d\`, [e<f], $$g>h$$, 7
|
||||
where score > 0 /* <block> */ -- > line
|
||||
and score < 10`);
|
||||
});
|
||||
|
||||
it("does not decode XML entities without an enabled valid MyBatis replacement", () => {
|
||||
|
||||
@@ -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 === "<" ? "<" : ">"));
|
||||
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("<", i) ? "<" : sql.startsWith(">", 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) => ({ "<": "<", ">": ">", "&": "&", """: '"', "'": "'" })[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] ?? "";
|
||||
|
||||
Reference in new issue
Block a user