From 34448cba9d859f6896c164dfc2292742198c9173 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 23 Jul 2026 00:17:20 +0800 Subject: [PATCH] fix(sql): preserve trigger pseudo-record references --- .../lib/__tests__/sql/sqlParameters.spec.ts | 91 +++++++++++ apps/desktop/src/lib/sql/sqlParameters.ts | 150 +++++++++++++++++- 2 files changed, 240 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index c4af24fa95..b1d1748722 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -285,6 +285,97 @@ describe("extractSqlParameters", () => { }); }); +describe("Oracle and Dameng trigger pseudo-records", () => { + it("ignores Oracle default pseudo-record fields while keeping ordinary parameters", () => { + const sql = ` + CREATE OR REPLACE TRIGGER audit_orders + BEFORE UPDATE ON orders + FOR EACH ROW + BEGIN + :NEW.updated_at := current_timestamp; + audit_change(:old.id, :PaReNt.order_id, :tenant_id); + END; + `; + + expect(extractSqlParameters(sql, { databaseType: "oracle" })).toEqual(["tenant_id"]); + }); + + it("ignores Dameng default pseudo-record fields case-insensitively", () => { + const sql = ` + create trigger audit_orders after update on orders + for each row + begin + insert into order_audit values (:new.id, :OLD.status, :EventInfo.event_type, :actor_id); + end; + `; + + expect(extractSqlParameters(sql, { databaseType: "dameng" })).toEqual(["actor_id"]); + }); + + it("parses REFERENCING aliases without disabling default pseudo-records", () => { + const sql = ` + create or replace trigger audit_orders + before update on orders + referencing old row as previous new as current + for each row + begin + audit_change(:previous.id, :CURRENT.status, :old.id, :new.status, :reason); + end; + `; + + expect(extractSqlParameters(sql, { databaseType: "oracle" })).toEqual(["reason"]); + }); + + it("replaces ordinary trigger parameters but preserves pseudo-record fields", () => { + const sql = `create trigger audit_orders before update on orders + referencing new as inserted old as deleted + for each row begin + :inserted.updated_by := :user_id; + audit_change(:deleted.id, :NEW.id, :note); + end;`; + + expect( + substituteSqlParameters( + sql, + { + user_id: { kind: "number", value: "42" }, + note: { kind: "string", value: "manual" }, + }, + { databaseType: "dameng" }, + ), + ).toBe(`create trigger audit_orders before update on orders + referencing new as inserted old as deleted + for each row begin + :inserted.updated_by := 42; + audit_change(:deleted.id, :NEW.id, 'manual'); + end;`); + }); + + it("does not apply trigger rules to other databases or statements outside triggers", () => { + const oracleScript = `create trigger audit_orders before update on orders + for each row begin :new.id := :value; end; + / + select :new, :outside_value from dual;`; + + expect(extractSqlParameters(oracleScript, { databaseType: "oracle" })).toEqual(["value", "new", "outside_value"]); + expect(extractSqlParameters("create trigger t before update on x begin :NEW.id := :value; end;", { databaseType: "postgres" })).toEqual(["NEW", "value"]); + expect(extractSqlParameters("create trigger t before update on x begin NEW.id := :value; end;", { databaseType: "postgres" })).toEqual(["value"]); + }); + + it("keeps assignment, casts, comments, strings, and non-field pseudo-record tokens unchanged", () => { + const sql = `create trigger audit_orders before update on orders + for each row begin + :new := :actual_value; + value := :NEW.id; + select value::int into :target_value from dual; + -- :OLD.comment_field + note := ':EVENTINFO.string_field'; + end;`; + + expect(extractSqlParameters(sql, { databaseType: "dameng" })).toEqual(["new", "actual_value", "target_value"]); + }); +}); + describe("substituteSqlParameters", () => { it("replaces placeholders with SQL literals", () => { const sql = "select * from t where dt >= ${start_date} and amount > ${amount} and enabled = ${enabled}"; diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index 9fa4be8c28..64ce7492b8 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -22,6 +22,7 @@ interface ParameterOccurrence extends SqlParameterDescriptor { } type ComplexTypeDeclarationKind = "struct" | "variant"; +type TriggerPseudoRecordName = "new" | "old" | "parent" | "eventinfo"; export interface SqlParameterOptions { databaseType?: DatabaseType; @@ -85,6 +86,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) const enabledSyntaxes = options?.enabledSyntaxes ? new Set(options.enabledSyntaxes) : null; const isSyntaxEnabled = (syntax: SqlParameterSyntax) => !enabledSyntaxes || enabledSyntaxes.has(syntax); const complexTypeFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") ? collectComplexTypeFieldSeparators(sql) : new Set(); + const triggerPseudoRecordFieldStarts = supportsNamedParameters && isSyntaxEnabled("named") ? collectTriggerPseudoRecordFieldStarts(sql, options?.databaseType) : new Set(); let i = 0; let dollarQuoteEnd = ""; let positionalIndex = 0; @@ -139,7 +141,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) } if (ch === ":" && supportsNamedParameters && isSyntaxEnabled("named")) { const name = readParameterName(sql, i + 1); - if (name && sql[i - 1] !== ":" && sql[i + 1] !== "=" && !complexTypeFieldSeparators.has(i)) { + if (name && sql[i - 1] !== ":" && sql[i + 1] !== "=" && !complexTypeFieldSeparators.has(i) && !triggerPseudoRecordFieldStarts.has(i)) { occurrences.push({ key: name, name, @@ -207,6 +209,152 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) return occurrences; } +// Oracle and Dameng expose trigger rows through colon-prefixed pseudo-records, +// unlike PostgreSQL's unprefixed NEW/OLD records. Keep ordinary :name binds enabled. +function collectTriggerPseudoRecordFieldStarts(sql: string, databaseType?: DatabaseType): Set { + const starts = new Set(); + const defaults = triggerPseudoRecordDefaults(databaseType); + if (!defaults) return starts; + + let aliases: Set | null = null; + let i = 0; + let dollarQuoteEnd = ""; + + while (i < sql.length) { + if (dollarQuoteEnd) { + const end = sql.indexOf(dollarQuoteEnd, i); + if (end === -1) break; + i = end + dollarQuoteEnd.length; + dollarQuoteEnd = ""; + continue; + } + + 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) { + dollarQuoteEnd = marker; + i += marker.length; + continue; + } + } + + if (aliases && ch === "/" && isStandaloneSlashDelimiter(sql, i)) { + aliases = null; + i += 1; + continue; + } + if (!aliases && matchesWord(sql, i, "create")) { + const triggerStart = readCreateTriggerEnd(sql, i); + if (triggerStart !== null) { + aliases = new Set(defaults); + i = triggerStart; + continue; + } + } + if (aliases && matchesWord(sql, i, "referencing")) { + i = collectTriggerReferencingAliases(sql, i + "referencing".length, aliases, defaults); + continue; + } + if (aliases && ch === ":") { + const name = readParameterName(sql, i + 1); + if (name && aliases.has(name.toLowerCase()) && isTriggerPseudoRecordFieldReference(sql, i + 1 + name.length)) { + starts.add(i); + i += 1 + name.length; + continue; + } + } + i += 1; + } + + return starts; +} + +function triggerPseudoRecordDefaults(databaseType?: DatabaseType): readonly TriggerPseudoRecordName[] | null { + if (databaseType === "oracle") return ["new", "old", "parent"]; + if (databaseType === "dameng") return ["new", "old", "eventinfo"]; + return null; +} + +function readCreateTriggerEnd(sql: string, start: number): number | null { + let keyword = readNextKeyword(sql, start + "create".length); + if (!keyword) return null; + if (keyword.word === "or") { + keyword = readNextKeyword(sql, keyword.end); + if (keyword?.word !== "replace") return null; + keyword = readNextKeyword(sql, keyword.end); + } + return keyword?.word === "trigger" ? keyword.end : null; +} + +function collectTriggerReferencingAliases(sql: string, start: number, aliases: Set, defaults: readonly TriggerPseudoRecordName[]): number { + const supported = new Set(defaults); + let i = start; + + while (i < sql.length) { + const source = readNextKeyword(sql, i); + if (!source || isTriggerReferencingBoundary(source.word)) return i; + if (!supported.has(source.word)) { + i = source.end; + continue; + } + + let alias = readNextKeyword(sql, source.end); + if (alias?.word === "row") alias = readNextKeyword(sql, alias.end); + if (alias?.word === "as") alias = readNextKeyword(sql, alias.end); + if (!alias || supported.has(alias.word) || isTriggerReferencingBoundary(alias.word)) { + i = source.end; + continue; + } + + aliases.add(alias.word); + i = alias.end; + } + + return i; +} + +function isTriggerReferencingBoundary(word: string): boolean { + return ["before", "after", "instead", "for", "when", "begin", "declare", "call", "enable", "disable"].includes(word); +} + +function isTriggerPseudoRecordFieldReference(sql: string, nameEnd: number): boolean { + if (sql[nameEnd] !== ".") return false; + const fieldStart = nameEnd + 1; + return PARAMETER_NAME_START_RE.test(sql[fieldStart] ?? "") || sql[fieldStart] === '"'; +} + +function isStandaloneSlashDelimiter(sql: string, start: number): boolean { + let before = start - 1; + while (before >= 0 && (sql[before] === " " || sql[before] === "\t" || sql[before] === "\r")) before -= 1; + if (before >= 0 && sql[before] !== "\n") return false; + + let after = start + 1; + while (after < sql.length && (sql[after] === " " || sql[after] === "\t" || sql[after] === "\r")) after += 1; + return after === sql.length || sql[after] === "\n"; +} + // JDBCX MCP commands accept npm scoped packages in their unquoted args value, // for example `args=-y @modelcontextprotocol/server-everything`. The `@scope` // prefix is command data, not a SQL Server-style template parameter.