From 55e3616938388682d3ef167156b7720287b29c11 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 12 Aug 2026 09:34:30 +0000 Subject: [PATCH] feat(sql): decode MyBatis XML comparison entities Closes #4312 --- .../lib/__tests__/sql/sqlParameters.spec.ts | 52 +++++++++++++++++++ apps/desktop/src/lib/sql/sqlParameters.ts | 9 +++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index 9c86de1ab5..c07fad4998 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -521,6 +521,58 @@ describe("Oracle and Dameng trigger pseudo-records", () => { }); describe("substituteSqlParameters", () => { + 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}"; + + expect( + substituteSqlParameters( + sql, + { + start: { kind: "string", value: "2026-01-01" }, + end: { kind: "string", value: "2026-02-01" }, + owner_id: { kind: "number", value: "7" }, + }, + { enabledSyntaxes: ["mybatis"] }, + ), + ).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", () => { + const sql = "select '&', '"', ''', '&lt;', '<', '<', '<source>', #{raw_value}, #{string_value} where score > #{minimum} /* <source-comment> */"; + + expect( + substituteSqlParameters( + sql, + { + raw_value: { kind: "raw", value: "'<raw>'" }, + string_value: { kind: "string", value: "<string>" }, + minimum: { kind: "number", value: "10" }, + }, + { enabledSyntaxes: ["mybatis"] }, + ), + ).toBe("select '&', '"', ''', '&lt;', '<', '<', '', '<raw>', '<string>' where score > 10 /* */"); + }); + + it("does not decode XML entities without an enabled valid MyBatis replacement", () => { + const mybatisSql = "select * from orders where total > #{minimum} and owner = ${owner}"; + const shellSql = "select * from orders where total < ${maximum}"; + const invalidMybatisSql = "select * from orders where total > #{1minimum} and owner = ${owner}"; + const otherSyntaxSql = "select ?, :named, @sql_server_name where total > 10"; + const plainSql = "select '<plain>'"; + + expect(substituteSqlParameters(mybatisSql, { owner: { kind: "string", value: "alice" } }, { enabledSyntaxes: ["shell"] })).toBe("select * from orders where total > #{minimum} and owner = 'alice'"); + expect(substituteSqlParameters(shellSql, { maximum: { kind: "number", value: "10" } }, { enabledSyntaxes: ["shell"] })).toBe("select * from orders where total < 10"); + expect(substituteSqlParameters(invalidMybatisSql, { owner: { kind: "string", value: "alice" } })).toBe("select * from orders where total > #{1minimum} and owner = 'alice'"); + expect( + substituteSqlParameters(otherSyntaxSql, { + "?1": { kind: "number", value: "1" }, + named: { kind: "number", value: "2" }, + sql_server_name: { kind: "number", value: "3" }, + }), + ).toBe("select 1, 2, 3 where total > 10"); + expect(substituteSqlParameters(plainSql, {})).toBe(plainSql); + }); + it("replaces placeholders with SQL literals", () => { const sql = "select * from t where dt >= ${start_date} and amount > ${amount} and enabled = ${enabled}"; expect( diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index b357abfcc2..bef06b1b30 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -166,21 +166,26 @@ export function extractSqlParameterDescriptors(sql: string, options?: SqlParamet export function substituteSqlParameters(sql: string, values: Record, 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; let result = ""; let cursor = 0; for (const occurrence of occurrences) { - result += sql.slice(cursor, occurrence.start); + result += decodeSourceFragment(sql.slice(cursor, occurrence.start)); const input = values[occurrence.key] ?? { kind: "string", value: "" }; // 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 += sql.slice(cursor); + result += decodeSourceFragment(sql.slice(cursor)); return result; } +function decodeMyBatisXmlComparisonEntities(fragment: string): string { + return fragment.replace(/&(?:lt|gt);/g, (entity) => (entity === "<" ? "<" : ">")); +} + export function sqlParameterLiteral(input: SqlParameterInput): string { if (input.kind === "null") return "NULL"; const raw = input.value;