Skip to content

Commit 05b82f0

Browse files
committed
feat: locate module body diffs
1 parent 7721483 commit 05b82f0

6 files changed

Lines changed: 116 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
All notable changes to this project are documented here.
44

5+
## 1.14.0 - 2026-06-29
6+
7+
- Added `firstBodyDifference` to module/file comparison results, mapping the first SQL-normalized body difference back to database and file line numbers.
8+
- Included first body-difference location in compare summaries so callers can quickly jump past script wrapper noise to the real changed line.
9+
510
## 1.13.0 - 2026-06-29
611

712
- Added readable comparison summaries, `differenceKind`, `ignoredWrapperDifferences`, and `diff.mode` to module/file comparison results.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ Relative `logs`, `cache`, and `tmp` directories are created beside the config fi
120120

121121
Search, definition-slice, configuration-text, usage, and read-only query tools expose `resultInfo` for consistent returned-count, limit, truncation, reason, and hint metadata.
122122

123-
Module/file comparison returns a readable top-level summary, `differenceKind`, ignored wrapper-difference labels, and multi-hunk diffs with truncation metadata. Use `diffMode=summary` for ranges only, `compact` for the default focused diff, or `full` with larger `maxHunks` / `maxDiffLinesPerSide` caps. Repository comparison also accepts `excludePatterns` such as `backup/**` and returns `suggestedPatterns` for ambiguous candidates. It reports `sqlNormalizedMatch` and SQL-normalized hashes to ignore common deployment-script wrapper differences such as `CREATE OR ALTER`, leading `SET ANSI_NULLS` / `SET QUOTED_IDENTIFIER`, and trailing `GO`.
123+
Module/file comparison returns a readable top-level summary, `differenceKind`, `firstBodyDifference`, ignored wrapper-difference labels, and multi-hunk diffs with truncation metadata. Use `diffMode=summary` for ranges only, `compact` for the default focused diff, or `full` with larger `maxHunks` / `maxDiffLinesPerSide` caps. Repository comparison also accepts `excludePatterns` such as `backup/**` and returns `suggestedPatterns` for ambiguous candidates. It reports `sqlNormalizedMatch` and SQL-normalized hashes to ignore common deployment-script wrapper differences such as `CREATE OR ALTER`, leading `SET ANSI_NULLS` / `SET QUOTED_IDENTIFIER`, and trailing `GO`, while `firstBodyDifference` maps the first SQL-normalized body change back to database and file line numbers.
124124

125125
`describe_query_result` accepts optional `templateValues` for UI SQL placeholders, for example `{ "0": "1=1" }` replaces `{0}` before describing columns. Replacements are raw SQL fragments, and the final SQL is still parsed by the read-only guard.
126126

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ SQL 文本可能包含敏感数据,仅在确有需要时启用 `logging.logSql
8383

8484
`compare_module_to_file` 适合已知道本地 SQL 文件路径时确认“仓库 SQL 是否已执行到数据库”;`compare_module_to_repo` 可按对象名在本地仓库/目录下自动发现 `.sql` 候选文件,唯一高分候选会直接比较,并列候选会返回列表让调用方收窄路径。
8585

86-
模块/文件对比会返回顶层可读摘要、`differenceKind`、被忽略的脚本包装差异标签,以及带截断信息的多 hunk diff。`diffMode=summary` 只返回差异范围,`compact` 是默认紧凑输出,`full` 可配合更大的 `maxHunks` / `maxDiffLinesPerSide` 查看更多行;仓库对比还支持 `excludePatterns` 排除 `backup/**` 等历史目录,并在候选并列时返回 `suggestedPatterns` 便于收窄。工具同时返回 `sqlNormalizedMatch` 和 SQL 归一化 hash,用于忽略常见部署脚本包装差异,例如 `CREATE OR ALTER`、开头 `SET ANSI_NULLS` / `SET QUOTED_IDENTIFIER`、结尾 `GO`
86+
模块/文件对比会返回顶层可读摘要、`differenceKind``firstBodyDifference`被忽略的脚本包装差异标签,以及带截断信息的多 hunk diff。`diffMode=summary` 只返回差异范围,`compact` 是默认紧凑输出,`full` 可配合更大的 `maxHunks` / `maxDiffLinesPerSide` 查看更多行;仓库对比还支持 `excludePatterns` 排除 `backup/**` 等历史目录,并在候选并列时返回 `suggestedPatterns` 便于收窄。工具同时返回 `sqlNormalizedMatch` 和 SQL 归一化 hash,用于忽略常见部署脚本包装差异,例如 `CREATE OR ALTER`、开头 `SET ANSI_NULLS` / `SET QUOTED_IDENTIFIER`、结尾 `GO``firstBodyDifference` 会把第一处 SQL 归一化后的正文差异映射回数据库和本地文件行号
8787

8888
搜索、模块定义切片、配置文本搜索、用法搜索和只读查询工具都会返回 `resultInfo`,用于统一判断结果是否被限制、为什么被截断以及下一步该如何缩小范围。
8989

src/SqlServerMcp/Sql/SqlMetadataService.cs

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Data;
22
using System.Diagnostics;
3+
using System.Globalization;
34
using System.Security.Cryptography;
45
using System.Text;
56
using System.Text.Json;
@@ -819,6 +820,7 @@ public async Task<object> CompareModuleToRepoAsync(
819820
normalizedMatch = comparison.NormalizedMatch,
820821
sqlNormalizedMatch = comparison.SqlNormalizedMatch,
821822
differenceKind = comparison.DifferenceKind,
823+
firstBodyDifference = comparison.FirstBodyDifference,
822824
nextActions = ModuleCompareNextActions
823825
};
824826
}
@@ -2461,7 +2463,15 @@ private async Task<ModuleFileComparisonResult> BuildModuleFileComparisonAsync(
24612463
var sqlNormalizedMatch = string.Equals(dbSqlNormalized, fileSqlNormalized, StringComparison.Ordinal);
24622464
var differenceKind = ClassifyModuleFileDifference(exactMatch, normalizedMatch, sqlNormalizedMatch);
24632465
var ignoredWrapperDifferences = DetectIgnoredWrapperDifferences(module.Definition, fileText);
2464-
var summary = BuildModuleFileComparisonSummary(file, diff, exactMatch, normalizedMatch, sqlNormalizedMatch, differenceKind);
2466+
var firstBodyDifference = FindFirstBodyDifference(module.Definition, fileText);
2467+
var summary = BuildModuleFileComparisonSummary(
2468+
file,
2469+
diff,
2470+
exactMatch,
2471+
normalizedMatch,
2472+
sqlNormalizedMatch,
2473+
differenceKind,
2474+
firstBodyDifference);
24652475

24662476
return new ModuleFileComparisonResult(
24672477
module.Schema,
@@ -2490,6 +2500,7 @@ private async Task<ModuleFileComparisonResult> BuildModuleFileComparisonAsync(
24902500
sqlNormalizedMatch,
24912501
differenceKind,
24922502
ignoredWrapperDifferences,
2503+
firstBodyDifference,
24932504
summary,
24942505
diff,
24952506
ModuleCompareNextActions);
@@ -2692,18 +2703,27 @@ private static string BuildModuleFileComparisonSummary(
26922703
bool exactMatch,
26932704
bool normalizedMatch,
26942705
bool sqlNormalizedMatch,
2695-
string differenceKind)
2706+
string differenceKind,
2707+
ModuleBodyDifference? firstBodyDifference)
26962708
{
26972709
var returnedDiffLineCount = diff.Hunks.Sum(hunk => hunk.DatabaseLines.Length + hunk.FileLines.Length);
26982710
var truncatedText = diff.Truncated ? "truncated" : "not truncated";
2699-
return $"{differenceKind}; selected {file.Name}; exactMatch={FormatBool(exactMatch)}; normalizedMatch={FormatBool(normalizedMatch)}; sqlNormalizedMatch={FormatBool(sqlNormalizedMatch)}; {diff.Hunks.Length} hunks; changedLines=db:{diff.DatabaseChangedLineCount},file:{diff.FileChangedLineCount}; returnedDiffLines={returnedDiffLineCount}; {truncatedText}";
2711+
var firstBodyText = firstBodyDifference is null
2712+
? "firstBodyDifference=none"
2713+
: $"firstBodyDifference=body:{firstBodyDifference.BodyLine},db:{FormatNullableInt(firstBodyDifference.DatabaseLine)},file:{FormatNullableInt(firstBodyDifference.FileLine)}";
2714+
return $"{differenceKind}; selected {file.Name}; exactMatch={FormatBool(exactMatch)}; normalizedMatch={FormatBool(normalizedMatch)}; sqlNormalizedMatch={FormatBool(sqlNormalizedMatch)}; {diff.Hunks.Length} hunks; changedLines=db:{diff.DatabaseChangedLineCount},file:{diff.FileChangedLineCount}; returnedDiffLines={returnedDiffLineCount}; {firstBodyText}; {truncatedText}";
27002715
}
27012716

27022717
private static string FormatBool(bool value)
27032718
{
27042719
return value ? "true" : "false";
27052720
}
27062721

2722+
private static string FormatNullableInt(int? value)
2723+
{
2724+
return value?.ToString(CultureInfo.InvariantCulture) ?? "none";
2725+
}
2726+
27072727
private static IEnumerable<FileInfo> EnumerateSqlFiles(DirectoryInfo root)
27082728
{
27092729
var pending = new Stack<DirectoryInfo>();
@@ -3387,52 +3407,83 @@ private static string NormalizeTextForComparison(string text)
33873407
}
33883408

33893409
internal static string NormalizeSqlModuleTextForComparison(string text)
3410+
{
3411+
return string.Join(
3412+
"\n",
3413+
BuildSqlModuleComparableLines(text).Select(line => line.Text))
3414+
.Trim();
3415+
}
3416+
3417+
internal static ModuleBodyDifference? FindFirstBodyDifference(string databaseDefinition, string fileText)
3418+
{
3419+
var databaseLines = BuildSqlModuleComparableLines(databaseDefinition);
3420+
var fileLines = BuildSqlModuleComparableLines(fileText);
3421+
var maxLineCount = Math.Max(databaseLines.Length, fileLines.Length);
3422+
for (var i = 0; i < maxLineCount; i++)
3423+
{
3424+
var databaseLine = i < databaseLines.Length ? databaseLines[i] : null;
3425+
var fileLine = i < fileLines.Length ? fileLines[i] : null;
3426+
if (string.Equals(databaseLine?.Text, fileLine?.Text, StringComparison.Ordinal))
3427+
{
3428+
continue;
3429+
}
3430+
3431+
return new ModuleBodyDifference(i + 1, databaseLine?.OriginalLineNumber, fileLine?.OriginalLineNumber);
3432+
}
3433+
3434+
return null;
3435+
}
3436+
3437+
private static SqlModuleComparableLine[] BuildSqlModuleComparableLines(string text)
33903438
{
33913439
var lines = SplitDefinitionLines(text.Trim('\uFEFF'))
3392-
.Select(line => line.TrimEnd())
3440+
.Select((line, index) => new SqlModuleComparableLine(index + 1, line.TrimEnd()))
33933441
.ToList();
33943442

3395-
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[0]))
3443+
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[0].Text))
33963444
{
33973445
lines.RemoveAt(0);
33983446
}
33993447

3400-
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1]))
3448+
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1].Text))
34013449
{
34023450
lines.RemoveAt(lines.Count - 1);
34033451
}
34043452

34053453
while (lines.Count > 0
3406-
&& (SqlBatchSeparatorRegex.IsMatch(lines[0])
3407-
|| SqlSessionSetRegex.IsMatch(lines[0])
3408-
|| string.IsNullOrWhiteSpace(lines[0])))
3454+
&& (SqlBatchSeparatorRegex.IsMatch(lines[0].Text)
3455+
|| SqlSessionSetRegex.IsMatch(lines[0].Text)
3456+
|| string.IsNullOrWhiteSpace(lines[0].Text)))
34093457
{
34103458
lines.RemoveAt(0);
34113459
}
34123460

34133461
while (lines.Count > 0
3414-
&& (SqlBatchSeparatorRegex.IsMatch(lines[^1])
3415-
|| string.IsNullOrWhiteSpace(lines[^1])))
3462+
&& (SqlBatchSeparatorRegex.IsMatch(lines[^1].Text)
3463+
|| string.IsNullOrWhiteSpace(lines[^1].Text)))
34163464
{
34173465
lines.RemoveAt(lines.Count - 1);
34183466
}
34193467

34203468
for (var i = 0; i < lines.Count; i++)
34213469
{
3422-
if (string.IsNullOrWhiteSpace(lines[i]))
3470+
if (string.IsNullOrWhiteSpace(lines[i].Text))
34233471
{
34243472
continue;
34253473
}
34263474

3427-
lines[i] = SqlModuleCreateRegex.Replace(lines[i], match =>
3475+
lines[i] = lines[i] with
34283476
{
3429-
var objectType = Regex.Replace(match.Groups[1].Value.ToUpperInvariant(), @"\s+", " ");
3430-
return $"CREATE {objectType}";
3431-
});
3477+
Text = SqlModuleCreateRegex.Replace(lines[i].Text, match =>
3478+
{
3479+
var objectType = Regex.Replace(match.Groups[1].Value.ToUpperInvariant(), @"\s+", " ");
3480+
return $"CREATE {objectType}";
3481+
})
3482+
};
34323483
break;
34333484
}
34343485

3435-
return string.Join("\n", lines).Trim();
3486+
return lines.ToArray();
34363487
}
34373488

34383489
internal static TempTableAnalysis AnalyzeTempTables(string definition)
@@ -5288,10 +5339,16 @@ private sealed record ModuleFileComparisonResult(
52885339
bool SqlNormalizedMatch,
52895340
string DifferenceKind,
52905341
string[] IgnoredWrapperDifferences,
5342+
ModuleBodyDifference? FirstBodyDifference,
52915343
string Summary,
52925344
ModuleFileDiff Diff,
52935345
string[] NextActions);
52945346

5347+
internal sealed record ModuleBodyDifference(
5348+
int BodyLine,
5349+
int? DatabaseLine,
5350+
int? FileLine);
5351+
52955352
internal sealed record ModuleSqlFileDiscovery(
52965353
string Root,
52975354
string[] Patterns,
@@ -5332,6 +5389,8 @@ private sealed record DiffOutputOptions(
53325389
int MaxDiffLinesPerSide,
53335390
bool IncludeLines);
53345391

5392+
private sealed record SqlModuleComparableLine(int OriginalLineNumber, string Text);
5393+
53355394
internal sealed record ModuleFileDiffHunk(
53365395
int DatabaseStartLine,
53375396
int DatabaseEndLine,

src/SqlServerMcp/SqlServerMcp.csproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
<RootNamespace>SqlServerMcp</RootNamespace>
88
<ImplicitUsings>enable</ImplicitUsings>
99
<Nullable>enable</Nullable>
10-
<Version>1.13.0</Version>
11-
<AssemblyVersion>1.13.0.0</AssemblyVersion>
12-
<FileVersion>1.13.0.0</FileVersion>
13-
<InformationalVersion>1.13.0</InformationalVersion>
10+
<Version>1.14.0</Version>
11+
<AssemblyVersion>1.14.0.0</AssemblyVersion>
12+
<FileVersion>1.14.0.0</FileVersion>
13+
<InformationalVersion>1.14.0</InformationalVersion>
1414
<Description>A read-only Model Context Protocol server for exploring and querying SQL Server.</Description>
1515
<RepositoryUrl>https://github.com/EdmondLu/sqlserver-mcp</RepositoryUrl>
1616
<RepositoryType>git</RepositoryType>

tests/SqlServerMcp.Tests/ModuleFileDiffTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,37 @@ SELECT 1
143143

144144
var databaseNormalized = SqlMetadataService.NormalizeSqlModuleTextForComparison(databaseDefinition);
145145
var fileNormalized = SqlMetadataService.NormalizeSqlModuleTextForComparison(fileText);
146+
var firstBodyDifference = SqlMetadataService.FindFirstBodyDifference(databaseDefinition, fileText);
146147

147148
Assert.Equal(databaseNormalized, fileNormalized);
149+
Assert.Null(firstBodyDifference);
150+
}
151+
152+
[Fact]
153+
public void FindFirstBodyDifference_ReturnsOriginalLineNumbersAfterWrapperNormalization()
154+
{
155+
const string databaseDefinition = """
156+
CREATE PROCEDURE dbo.Sample
157+
AS
158+
SELECT 1
159+
""";
160+
const string fileText = """
161+
SET ANSI_NULLS ON
162+
GO
163+
SET QUOTED_IDENTIFIER ON
164+
GO
165+
CREATE OR ALTER PROCEDURE dbo.Sample
166+
AS
167+
SELECT 2
168+
GO
169+
""";
170+
171+
var firstBodyDifference = SqlMetadataService.FindFirstBodyDifference(databaseDefinition, fileText);
172+
173+
Assert.NotNull(firstBodyDifference);
174+
Assert.Equal(3, firstBodyDifference.BodyLine);
175+
Assert.Equal(3, firstBodyDifference.DatabaseLine);
176+
Assert.Equal(7, firstBodyDifference.FileLine);
148177
}
149178

150179
[Fact]

0 commit comments

Comments
 (0)