diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index a8f4a818d6..940fa6791f 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -666,6 +666,56 @@ describe("substituteSqlParameters", () => { expect(substituteSqlParameters(sql, {}, { enabledSyntaxes: ["shell"] })).toBe(sql); }); + it("strips a MyBatis wrapper and prefixes its body with WHERE", () => { + const sql = "select * from tasks status = #{status} "; + + 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 body, case-insensitively", () => { + const andSql = "select * from tasks AND status = #{status} "; + const orSql = "select * from tasks or status = #{status} "; + + 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 body as no WHERE clause at all", () => { + const sql = "select * from tasks order by id"; + + expect(substituteSqlParameters(sql, {}, { enabledSyntaxes: ["mybatis"] })).toBe("select * from tasks order by id"); + }); + + it("does not surface itself as a SQL parameter, but does surface placeholders nested inside it", () => { + const sql = "select * from tasks status = #{status} "; + + expect(extractSqlParameterDescriptors(sql, { enabledSyntaxes: ["mybatis"] })).toEqual([{ key: "status", name: "status", syntax: "mybatis", token: "#{status}" }]); + }); + + it("resolves a MyBatis nested inside a wrapper", () => { + const sql = 'select * from tasks task_id in #{taskId} '; + + expect(extractSqlParameterDescriptors(sql, { enabledSyntaxes: ["mybatis"] })).toEqual([{ key: "taskIds", name: "taskIds", syntax: "mybatis", token: "", 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 + status = #{status} /* */ || 'fake' + -- fake + `; + + expect(substituteSqlParameters(sql, { status: { kind: "string", value: "open" } }, { enabledSyntaxes: ["mybatis"] })).toBe(`select * from tasks WHERE status = 'open' /* */ || 'fake' + -- fake`); + }); + + it("leaves MyBatis tags untouched when MyBatis substitution is disabled", () => { + const sql = "select * from tasks status = #{status} "; + + 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}"; diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index dfe686cca1..c73669ad9a 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -25,8 +25,13 @@ export interface SqlBracedParameter extends SqlParameterDescriptor { interface ParameterOccurrence extends SqlParameterDescriptor { start: number; end: number; - replacement?: "string-fragment" | "mybatis-foreach"; + replacement?: "string-fragment" | "mybatis-foreach" | "mybatis-where"; foreach?: MyBatisForeach; + where?: MyBatisWhere; +} + +interface MyBatisWhere { + body: string; } interface MyBatisForeach { @@ -176,6 +181,10 @@ export function extractSqlParameterDescriptors(sql: string, options?: SqlParamet descriptors.push(descriptor); }; 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({ key: occurrence.key, name: occurrence.name, @@ -201,6 +210,11 @@ export function substituteSqlParameters(sql: string, values: Record, 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, options?: SqlParameterOptions): string { const items = parseSqlParameterList(input.value); if (items.length === 0) return `${foreach.open}NULL${foreach.close}`; @@ -385,6 +407,21 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) i = foreach.end; continue; } + const where = readMyBatisWhereAt(sql, i); + if (where) { + occurrences.push({ + key: "", + name: "", + syntax: "mybatis", + token: "", + replacement: "mybatis-where", + where: { body: where.body }, + start: i, + end: where.end, + }); + i = where.end; + continue; + } } if (ch === "'" || ch === '"') { @@ -519,7 +556,7 @@ function readMyBatisForeachAt(sql: string, start: number): { collection: string; 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); + const close = findMatchingXmlTagClose(sql, openingEnd + 1, "foreach"); if (!close) return null; return { 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 (!/^)/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 { let quote = ""; 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); } -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 i = start; @@ -614,7 +661,7 @@ function findMatchingForeachClose(sql: string, start: number): { start: number; } } if (ch === "<") { - const tag = readForeachTagAt(sql, i); + const tag = readXmlTagAt(sql, i, tagName); if (tag) { depth += tag.closing ? -1 : 1; if (depth === 0) return { start: i, end: tag.end }; @@ -627,11 +674,11 @@ function findMatchingForeachClose(sql: string, start: number): { start: number; 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 nameStart = start + (closing ? 2 : 1); - if (sql.slice(nameStart, nameStart + "foreach".length).toLowerCase() !== "foreach") return null; - const boundary = sql[nameStart + "foreach".length]; + if (sql.slice(nameStart, nameStart + tagName.length).toLowerCase() !== tagName) return null; + const boundary = sql[nameStart + tagName.length]; if (boundary !== ">" && !/\s/.test(boundary ?? "")) return null; const tagEnd = findXmlTagEnd(sql, start); return tagEnd === -1 ? null : { closing, end: tagEnd + 1 };