From 45ef7e3f8ef1597f91b2acc73abd47d5da89e3dd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 18:11:11 +0900 Subject: [PATCH] Fix SQL MERGE column references (#2100) --- changelog.d/unreleased/2100.fixed.md | 16 + .../Languages/SqlReferenceExtractor.cs | 417 ++++++++++++++++++ .../ReferenceExtractorTests.cs | 33 ++ 3 files changed, 466 insertions(+) create mode 100644 changelog.d/unreleased/2100.fixed.md diff --git a/changelog.d/unreleased/2100.fixed.md b/changelog.d/unreleased/2100.fixed.md new file mode 100644 index 0000000000..668f68ed38 --- /dev/null +++ b/changelog.d/unreleased/2100.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2100 +affected: + - src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.cs + - tests/CodeIndex.Tests/ReferenceExtractorTests.cs +--- + +## English + +- **SQL MERGE actions now emit column-level reference edges (#2100)** — `MERGE` extraction now records `column_reference` edges from `UPDATE SET`, `INSERT (...)`, and `VALUES (...)` action bodies, plus `join_condition_reference` edges from the `ON` predicate, so lineage and impact queries can follow MERGE-driven transformations more completely. + +## 日本語 + +- **SQL MERGE action が列レベルの reference edge を出すようになりました (#2100)** — `MERGE` 抽出は `UPDATE SET`、`INSERT (...)`、`VALUES (...)` の action body から `column_reference` edge を、`ON` 条件から `join_condition_reference` edge を記録するようになり、MERGE による変換の lineage / impact query がより完全に追跡できます。 diff --git a/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.cs index 3a895868f3..c7cca8a6f2 100644 --- a/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.cs @@ -74,6 +74,18 @@ internal sealed class State private static readonly Regex MergeUsingSourceRegex = new( $@"(?[\s\S]*?)(?=(?\((?:[^()]|\([^()]*\))*\))?(?:\s+VALUES\s*(?\((?:[^()]|\([^()]*\))*\)))?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex MergeOnClauseRegex = new( + @"(?[\s\S]*?)(?=(?{QuotedIdentifierPattern}|{BareIdentifierPattern})", + RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex MergeUsingPrefixRegex = new( $@"(? references, + HashSet seen, + long fileId, + Func resolveContainerForCall, + Func shouldIgnoreName) + { + foreach (Match match in MergeOnClauseRegex.Matches(statement)) + { + if (IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + + var bodyGroup = match.Groups["body"]; + EmitQualifiedColumnReferences( + bodyGroup.Value, + bodyGroup.Index, + statement, + statementStart, + statementLineOffset, + lineOffset, + context, + lineNumber, + references, + seen, + fileId, + resolveContainerForCall, + shouldIgnoreName, + "join_condition_reference"); + } + + foreach (Match match in MergeUpdateSetActionRegex.Matches(statement)) + { + if (IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + + var bodyGroup = match.Groups["body"]; + foreach (var segment in SplitTopLevelCommaSegments(bodyGroup.Value, bodyGroup.Index)) + { + var equalsIndex = IndexOfTopLevelChar(segment.Text, '='); + if (equalsIndex <= 0) + continue; + + EmitMergeColumnReference( + segment.Text[..equalsIndex], + segment.StartIndex, + statement, + statementStart, + statementLineOffset, + lineOffset, + context, + lineNumber, + references, + seen, + fileId, + resolveContainerForCall, + shouldIgnoreName, + "column_reference"); + } + + EmitQualifiedColumnReferences( + bodyGroup.Value, + bodyGroup.Index, + statement, + statementStart, + statementLineOffset, + lineOffset, + context, + lineNumber, + references, + seen, + fileId, + resolveContainerForCall, + shouldIgnoreName, + "column_reference"); + } + + foreach (Match match in MergeInsertActionRegex.Matches(statement)) + { + if (IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + + var columnsGroup = match.Groups["columns"]; + if (columnsGroup.Success) + { + var innerStart = columnsGroup.Index + 1; + var inner = columnsGroup.Value.Length >= 2 + ? columnsGroup.Value[1..^1] + : string.Empty; + foreach (var segment in SplitTopLevelCommaSegments(inner, innerStart)) + { + EmitMergeColumnReference( + segment.Text, + segment.StartIndex, + statement, + statementStart, + statementLineOffset, + lineOffset, + context, + lineNumber, + references, + seen, + fileId, + resolveContainerForCall, + shouldIgnoreName, + "column_reference"); + } + } + + var valuesGroup = match.Groups["values"]; + if (valuesGroup.Success) + { + EmitQualifiedColumnReferences( + valuesGroup.Value, + valuesGroup.Index, + statement, + statementStart, + statementLineOffset, + lineOffset, + context, + lineNumber, + references, + seen, + fileId, + resolveContainerForCall, + shouldIgnoreName, + "column_reference"); + } + } + } + + private readonly record struct TextSegment(string Text, int StartIndex); + + private static void EmitQualifiedColumnReferences( + string text, + int textStart, + string statement, + int statementStart, + int statementLineOffset, + int lineOffset, + string context, + int lineNumber, + List references, + HashSet seen, + long fileId, + Func resolveContainerForCall, + Func shouldIgnoreName, + string referenceKind) + { + foreach (Match match in QualifiedColumnReferenceRegex.Matches(text)) + { + if (IsInsideDoubleQuotedRegion(text, match.Index)) + continue; + + var nameGroup = match.Groups["name"]; + EmitMergeColumnReference( + nameGroup.Value, + textStart + nameGroup.Index, + statement, + statementStart, + statementLineOffset, + lineOffset, + context, + lineNumber, + references, + seen, + fileId, + resolveContainerForCall, + shouldIgnoreName, + referenceKind); + } + } + + private static void EmitMergeColumnReference( + string rawName, + int rawIndex, + string statement, + int statementStart, + int statementLineOffset, + int lineOffset, + string context, + int lineNumber, + List references, + HashSet seen, + long fileId, + Func resolveContainerForCall, + Func shouldIgnoreName, + string referenceKind) + { + var trimmedStart = 0; + while (trimmedStart < rawName.Length && char.IsWhiteSpace(rawName[trimmedStart])) + trimmedStart++; + var trimmedEnd = rawName.Length; + while (trimmedEnd > trimmedStart && char.IsWhiteSpace(rawName[trimmedEnd - 1])) + trimmedEnd--; + if (trimmedStart >= trimmedEnd) + return; + + rawName = rawName[trimmedStart..trimmedEnd]; + rawIndex += trimmedStart; + var leafIndex = FindQualifiedIdentifierLeafIndex(rawName); + rawIndex += leafIndex; + rawName = rawName[leafIndex..].TrimStart(); + + var match = Regex.Match( + rawName, + $"^(?{QuotedIdentifierPattern}|{BareIdentifierPattern})", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success) + return; + + var nameGroup = match.Groups["name"]; + var absoluteNameIndex = rawIndex + nameGroup.Index; + if (absoluteNameIndex < statementLineOffset) + return; + + NormalizeIdentifier(nameGroup.Value, absoluteNameIndex, out var resolvedName, out var nameIndex, out var wasQuoted); + if (!wasQuoted && shouldIgnoreName(resolvedName)) + return; + + var nameColumn = nameIndex + statementStart - lineOffset; + var container = resolveContainerForCall(absoluteNameIndex); + ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, referenceKind, context, lineNumber, container); + } + + private static int FindQualifiedIdentifierLeafIndex(string rawName) + { + var leafStart = 0; + var quote = '\0'; + for (var i = 0; i < rawName.Length; i++) + { + var ch = rawName[i]; + if (quote != '\0') + { + if (quote == '[') + { + if (ch == ']') + { + if (i + 1 < rawName.Length && rawName[i + 1] == ']') + i++; + else + quote = '\0'; + } + continue; + } + + if (ch == quote) + { + if (i + 1 < rawName.Length && rawName[i + 1] == quote) + i++; + else + quote = '\0'; + } + continue; + } + + if (ch is '[' or '"' or '`') + { + quote = ch; + continue; + } + + if (ch != '.') + continue; + + leafStart = i + 1; + while (leafStart < rawName.Length && char.IsWhiteSpace(rawName[leafStart])) + leafStart++; + } + + return leafStart; + } + + private static List SplitTopLevelCommaSegments(string text, int textStart) + { + var segments = new List(); + var segmentStart = 0; + var depth = 0; + var quote = '\0'; + for (var i = 0; i < text.Length; i++) + { + var ch = text[i]; + if (quote != '\0') + { + if (quote == '[') + { + if (ch == ']') + { + if (i + 1 < text.Length && text[i + 1] == ']') + i++; + else + quote = '\0'; + } + continue; + } + + if (ch == quote) + { + if (i + 1 < text.Length && text[i + 1] == quote) + i++; + else + quote = '\0'; + } + continue; + } + + if (ch is '[' or '"' or '`' or '\'') + { + quote = ch; + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + if (ch == ')' && depth > 0) + { + depth--; + continue; + } + if (ch != ',' || depth != 0) + continue; + + segments.Add(new TextSegment(text[segmentStart..i], textStart + segmentStart)); + segmentStart = i + 1; + } + + segments.Add(new TextSegment(text[segmentStart..], textStart + segmentStart)); + return segments; + } + + private static int IndexOfTopLevelChar(string text, char value) + { + var depth = 0; + var quote = '\0'; + for (var i = 0; i < text.Length; i++) + { + var ch = text[i]; + if (quote != '\0') + { + if (quote == '[') + { + if (ch == ']') + { + if (i + 1 < text.Length && text[i + 1] == ']') + i++; + else + quote = '\0'; + } + continue; + } + + if (ch == quote) + { + if (i + 1 < text.Length && text[i + 1] == quote) + i++; + else + quote = '\0'; + } + continue; + } + + if (ch is '[' or '"' or '`' or '\'') + { + quote = ch; + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + if (ch == ')' && depth > 0) + { + depth--; + continue; + } + if (ch == value && depth == 0) + return i; + } + + return -1; } private static void EmitSourceReference( diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index bd96c02ca1..7c59a8c59b 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -16633,6 +16633,39 @@ WHEN MATCHED THEN Assert.Contains(references, r => r.SymbolName == "SET" && r.ReferenceKind == "reference" && r.Line == 8); } + [Fact] + public void Extract_SQL_MergeActionsEmitColumnReferences() + { + // issue #2100: MERGE action bodies carry target/source column lineage in UPDATE SET, + // INSERT column lists, VALUES expressions, and the ON join condition. + // issue #2100: MERGE action body の UPDATE SET、INSERT column list、VALUES 式、 + // ON join condition から target/source column lineage を落としてはいけない。 + const string content = """ + MERGE INTO audit_log AS t + USING staging_log AS s + ON t.id = s.id AND t.account_id = s.account_id + WHEN MATCHED THEN + UPDATE SET action = s.action, updated_at = COALESCE(s.updated_at, t.updated_at) + WHEN NOT MATCHED THEN + INSERT (id, action, updated_at) + VALUES (s.id, s.action, COALESCE(s.updated_at, CURRENT_TIMESTAMP)); + """; + + var symbols = SymbolExtractor.Extract(1, "sql", content); + var references = ReferenceExtractor.Extract(1, "sql", content, symbols); + + Assert.Contains(references, r => r.SymbolName == "audit_log" && r.ReferenceKind == "reference"); + Assert.Contains(references, r => r.SymbolName == "staging_log" && r.ReferenceKind == "reference"); + Assert.Equal(2, references.Count(r => r.SymbolName == "id" && r.ReferenceKind == "join_condition_reference")); + Assert.Equal(2, references.Count(r => r.SymbolName == "account_id" && r.ReferenceKind == "join_condition_reference")); + Assert.Contains(references, r => r.SymbolName == "action" && r.ReferenceKind == "column_reference"); + Assert.Contains(references, r => r.SymbolName == "updated_at" && r.ReferenceKind == "column_reference"); + Assert.True(references.Count(r => r.SymbolName == "id" && r.ReferenceKind == "column_reference") >= 2); + Assert.True(references.Count(r => r.SymbolName == "action" && r.ReferenceKind == "column_reference") >= 3); + Assert.True(references.Count(r => r.SymbolName == "updated_at" && r.ReferenceKind == "column_reference") >= 4); + Assert.DoesNotContain(references, r => r.SymbolName == "CURRENT_TIMESTAMP" && r.ReferenceKind == "column_reference"); + } + [Fact] public void Extract_SQL_HashCommentsDoNotLeakAsTempObjects() {