fix(sql): preserve trigger pseudo-record references

This commit is contained in:
t8y2 committed 2026-07-23 00:17:20 +08:00
1 parent 7053609adc
commit 34448cba9d
2 files changed
+240 -1

No files matched your search

+149 -1
View File
@@ -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<number>();
const triggerPseudoRecordFieldStarts = supportsNamedParameters && isSyntaxEnabled("named") ? collectTriggerPseudoRecordFieldStarts(sql, options?.databaseType) : new Set<number>();
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<number> {
const starts = new Set<number>();
const defaults = triggerPseudoRecordDefaults(databaseType);
if (!defaults) return starts;
let aliases: Set<string> | 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<string>, defaults: readonly TriggerPseudoRecordName[]): number {
const supported = new Set<string>(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.