fix(sql): preserve trigger pseudo-record references
This commit is contained in:
1 parent
7053609adc
commit
34448cba9d
2 files changed
+240
-1
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user