fix(sql): resolve MyBatis where tags before execution
This commit is contained in:
1 parent
355f34f3c7
commit
a2c9317c8b
2 files changed
+104
-7
No files matched your search
@@ -666,6 +666,56 @@ describe("substituteSqlParameters", () => {
|
|||||||
expect(substituteSqlParameters(sql, {}, { enabledSyntaxes: ["shell"] })).toBe(sql);
|
expect(substituteSqlParameters(sql, {}, { enabledSyntaxes: ["shell"] })).toBe(sql);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips a MyBatis <where> wrapper and prefixes its body with WHERE", () => {
|
||||||
|
const sql = "select * from tasks <where> status = #{status} </where>";
|
||||||
|
|
||||||
|
expect(substituteSqlParameters(sql, { status: { kind: "string", value: "open" } }, { enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks WHERE status = 'open'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a leading AND/OR from a MyBatis <where> body, case-insensitively", () => {
|
||||||
|
const andSql = "select * from tasks <where> AND status = #{status} </where>";
|
||||||
|
const orSql = "select * from tasks <where> or status = #{status} </where>";
|
||||||
|
|
||||||
|
expect(substituteSqlParameters(andSql, { status: { kind: "string", value: "open" } }, { enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks WHERE status = 'open'");
|
||||||
|
expect(substituteSqlParameters(orSql, { status: { kind: "string", value: "open" } }, { enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks WHERE status = 'open'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty MyBatis <where> body as no WHERE clause at all", () => {
|
||||||
|
const sql = "select * from tasks <where> </where> order by id";
|
||||||
|
|
||||||
|
expect(substituteSqlParameters(sql, {}, { enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks order by id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not surface <where> itself as a SQL parameter, but does surface placeholders nested inside it", () => {
|
||||||
|
const sql = "select * from tasks <where> status = #{status} </where>";
|
||||||
|
|
||||||
|
expect(extractSqlParameterDescriptors(sql, { enabledSyntaxes: ["mybatis"] })).toEqual([{ key: "status", name: "status", syntax: "mybatis", token: "#{status}" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a MyBatis <foreach> nested inside a <where> wrapper", () => {
|
||||||
|
const sql = 'select * from tasks <where> task_id in <foreach collection="taskIds" item="taskId" open="(" separator="," close=")">#{taskId}</foreach> </where>';
|
||||||
|
|
||||||
|
expect(extractSqlParameterDescriptors(sql, { enabledSyntaxes: ["mybatis"] })).toEqual([{ key: "taskIds", name: "taskIds", syntax: "mybatis", token: "<foreach>", collection: true }]);
|
||||||
|
expect(substituteSqlParameters(sql, { taskIds: { kind: "number", value: "[1,2]" } }, { enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks WHERE task_id in (1,2)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores where-like tags in SQL strings and comments when matching the closing tag", () => {
|
||||||
|
const sql = `select * from tasks <where>
|
||||||
|
status = #{status} /* </where> */ || '<where>fake</where>'
|
||||||
|
-- <where>fake</where>
|
||||||
|
</where>`;
|
||||||
|
|
||||||
|
expect(substituteSqlParameters(sql, { status: { kind: "string", value: "open" } }, { enabledSyntaxes: ["mybatis"] })).toBe(`select * from tasks WHERE status = 'open' /* </where> */ || '<where>fake</where>'
|
||||||
|
-- <where>fake</where>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves MyBatis <where> tags untouched when MyBatis substitution is disabled", () => {
|
||||||
|
const sql = "select * from tasks <where> status = #{status} </where>";
|
||||||
|
|
||||||
|
expect(extractSqlParameterDescriptors(sql, { enabledSyntaxes: ["shell"] })).toEqual([]);
|
||||||
|
expect(substituteSqlParameters(sql, {}, { enabledSyntaxes: ["shell"] })).toBe(sql);
|
||||||
|
});
|
||||||
|
|
||||||
it("decodes XML comparison entities when substituting MyBatis parameters", () => {
|
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}";
|
const sql = "select * from orders where created_at >= #{start} and created_at < #{end} and owner_id = #{owner_id} or reviewer_id = #{owner_id}";
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ export interface SqlBracedParameter extends SqlParameterDescriptor {
|
|||||||
interface ParameterOccurrence extends SqlParameterDescriptor {
|
interface ParameterOccurrence extends SqlParameterDescriptor {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
replacement?: "string-fragment" | "mybatis-foreach";
|
replacement?: "string-fragment" | "mybatis-foreach" | "mybatis-where";
|
||||||
foreach?: MyBatisForeach;
|
foreach?: MyBatisForeach;
|
||||||
|
where?: MyBatisWhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyBatisWhere {
|
||||||
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MyBatisForeach {
|
interface MyBatisForeach {
|
||||||
@@ -176,6 +181,10 @@ export function extractSqlParameterDescriptors(sql: string, options?: SqlParamet
|
|||||||
descriptors.push(descriptor);
|
descriptors.push(descriptor);
|
||||||
};
|
};
|
||||||
for (const occurrence of findSqlParameterOccurrences(sql, options)) {
|
for (const occurrence of findSqlParameterOccurrences(sql, options)) {
|
||||||
|
if (occurrence.replacement === "mybatis-where" && occurrence.where) {
|
||||||
|
for (const nested of extractSqlParameterDescriptors(occurrence.where.body, options)) appendDescriptor(nested);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
appendDescriptor({
|
appendDescriptor({
|
||||||
key: occurrence.key,
|
key: occurrence.key,
|
||||||
name: occurrence.name,
|
name: occurrence.name,
|
||||||
@@ -201,6 +210,11 @@ export function substituteSqlParameters(sql: string, values: Record<string, SqlP
|
|||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
for (const occurrence of occurrences) {
|
for (const occurrence of occurrences) {
|
||||||
result += sql.slice(cursor, occurrence.start);
|
result += sql.slice(cursor, occurrence.start);
|
||||||
|
if (occurrence.replacement === "mybatis-where" && occurrence.where) {
|
||||||
|
result += renderMyBatisWhere(occurrence.where, values, options);
|
||||||
|
cursor = occurrence.end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const input = values[occurrence.key] ?? { kind: "string", value: "" };
|
const input = values[occurrence.key] ?? { kind: "string", value: "" };
|
||||||
if (occurrence.replacement === "mybatis-foreach" && occurrence.foreach) {
|
if (occurrence.replacement === "mybatis-foreach" && occurrence.foreach) {
|
||||||
result += renderMyBatisForeach(occurrence.foreach, input, values, options);
|
result += renderMyBatisForeach(occurrence.foreach, input, values, options);
|
||||||
@@ -216,6 +230,14 @@ export function substituteSqlParameters(sql: string, values: Record<string, SqlP
|
|||||||
return decodeMyBatisEntities ? decodeMyBatisXmlComparisonEntities(result) : result;
|
return decodeMyBatisEntities ? decodeMyBatisXmlComparisonEntities(result) : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MYBATIS_WHERE_LEADING_CONJUNCTION_RE = /^(?:AND|OR)\s+/i;
|
||||||
|
|
||||||
|
function renderMyBatisWhere(where: MyBatisWhere, values: Record<string, SqlParameterInput>, options?: SqlParameterOptions): string {
|
||||||
|
const body = substituteSqlParameters(where.body, values, options).trim();
|
||||||
|
if (!body) return "";
|
||||||
|
return `WHERE ${body.replace(MYBATIS_WHERE_LEADING_CONJUNCTION_RE, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderMyBatisForeach(foreach: MyBatisForeach, input: SqlParameterInput, values: Record<string, SqlParameterInput>, options?: SqlParameterOptions): string {
|
function renderMyBatisForeach(foreach: MyBatisForeach, input: SqlParameterInput, values: Record<string, SqlParameterInput>, options?: SqlParameterOptions): string {
|
||||||
const items = parseSqlParameterList(input.value);
|
const items = parseSqlParameterList(input.value);
|
||||||
if (items.length === 0) return `${foreach.open}NULL${foreach.close}`;
|
if (items.length === 0) return `${foreach.open}NULL${foreach.close}`;
|
||||||
@@ -385,6 +407,21 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions)
|
|||||||
i = foreach.end;
|
i = foreach.end;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const where = readMyBatisWhereAt(sql, i);
|
||||||
|
if (where) {
|
||||||
|
occurrences.push({
|
||||||
|
key: "",
|
||||||
|
name: "",
|
||||||
|
syntax: "mybatis",
|
||||||
|
token: "<where>",
|
||||||
|
replacement: "mybatis-where",
|
||||||
|
where: { body: where.body },
|
||||||
|
start: i,
|
||||||
|
end: where.end,
|
||||||
|
});
|
||||||
|
i = where.end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ch === "'" || ch === '"') {
|
if (ch === "'" || ch === '"') {
|
||||||
@@ -519,7 +556,7 @@ function readMyBatisForeachAt(sql: string, start: number): { collection: string;
|
|||||||
const index = attributes.get("index")?.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;
|
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);
|
const close = findMatchingXmlTagClose(sql, openingEnd + 1, "foreach");
|
||||||
if (!close) return null;
|
if (!close) return null;
|
||||||
return {
|
return {
|
||||||
collection,
|
collection,
|
||||||
@@ -535,6 +572,16 @@ function readMyBatisForeachAt(sql: string, start: number): { collection: string;
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMyBatisWhereAt(sql: string, start: number): { body: string; end: number } | null {
|
||||||
|
if (!/^<where(?:\s|>)/i.test(sql.slice(start))) return null;
|
||||||
|
const openingEnd = findXmlTagEnd(sql, start);
|
||||||
|
if (openingEnd === -1) return null;
|
||||||
|
|
||||||
|
const close = findMatchingXmlTagClose(sql, openingEnd + 1, "where");
|
||||||
|
if (!close) return null;
|
||||||
|
return { body: sql.slice(openingEnd + 1, close.start), end: close.end };
|
||||||
|
}
|
||||||
|
|
||||||
function findXmlTagEnd(source: string, start: number): number {
|
function findXmlTagEnd(source: string, start: number): number {
|
||||||
let quote = "";
|
let quote = "";
|
||||||
for (let i = start + 1; i < source.length; i += 1) {
|
for (let i = start + 1; i < source.length; i += 1) {
|
||||||
@@ -577,7 +624,7 @@ function decodeXmlAttribute(value: string): string {
|
|||||||
return value.replace(/&(?:lt|gt|amp|quot|apos);/g, (entity) => ({ "<": "<", ">": ">", "&": "&", """: '"', "'": "'" })[entity] ?? entity);
|
return value.replace(/&(?:lt|gt|amp|quot|apos);/g, (entity) => ({ "<": "<", ">": ">", "&": "&", """: '"', "'": "'" })[entity] ?? entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findMatchingForeachClose(sql: string, start: number): { start: number; end: number } | null {
|
function findMatchingXmlTagClose(sql: string, start: number, tagName: string): { start: number; end: number } | null {
|
||||||
let depth = 1;
|
let depth = 1;
|
||||||
let i = start;
|
let i = start;
|
||||||
|
|
||||||
@@ -614,7 +661,7 @@ function findMatchingForeachClose(sql: string, start: number): { start: number;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ch === "<") {
|
if (ch === "<") {
|
||||||
const tag = readForeachTagAt(sql, i);
|
const tag = readXmlTagAt(sql, i, tagName);
|
||||||
if (tag) {
|
if (tag) {
|
||||||
depth += tag.closing ? -1 : 1;
|
depth += tag.closing ? -1 : 1;
|
||||||
if (depth === 0) return { start: i, end: tag.end };
|
if (depth === 0) return { start: i, end: tag.end };
|
||||||
@@ -627,11 +674,11 @@ function findMatchingForeachClose(sql: string, start: number): { start: number;
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readForeachTagAt(sql: string, start: number): { closing: boolean; end: number } | null {
|
function readXmlTagAt(sql: string, start: number, tagName: string): { closing: boolean; end: number } | null {
|
||||||
const closing = sql[start + 1] === "/";
|
const closing = sql[start + 1] === "/";
|
||||||
const nameStart = start + (closing ? 2 : 1);
|
const nameStart = start + (closing ? 2 : 1);
|
||||||
if (sql.slice(nameStart, nameStart + "foreach".length).toLowerCase() !== "foreach") return null;
|
if (sql.slice(nameStart, nameStart + tagName.length).toLowerCase() !== tagName) return null;
|
||||||
const boundary = sql[nameStart + "foreach".length];
|
const boundary = sql[nameStart + tagName.length];
|
||||||
if (boundary !== ">" && !/\s/.test(boundary ?? "")) return null;
|
if (boundary !== ">" && !/\s/.test(boundary ?? "")) return null;
|
||||||
const tagEnd = findXmlTagEnd(sql, start);
|
const tagEnd = findXmlTagEnd(sql, start);
|
||||||
return tagEnd === -1 ? null : { closing, end: tagEnd + 1 };
|
return tagEnd === -1 ? null : { closing, end: tagEnd + 1 };
|
||||||
|
|||||||
Reference in new issue
Block a user