Skip to content

Commit 492c4f6

Browse files
committed
feat: enhance assembly comparison handling to ignore legacy formatting differences
1 parent 30bb8b7 commit 492c4f6

5 files changed

Lines changed: 289 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
3636
- Treat legacy standalone table-level inline `PRIMARY KEY` and `UNIQUE` constraints as compatible during comparison with canonical post-create key-constraint statements.
3737
- Treat legacy CLR table-valued function return-column collation clauses as compatible during comparison when SQL Server ignores them in the effective return shape.
3838
- Treat legacy explicit `NULL` tokens on CLR table-valued function return columns as compatible during comparison and preserve them during compatibility reconciliation when the rest of the definition matches.
39+
- Treat equivalent legacy `Assembly` scripts as compatible during comparison when they differ only by banner comments, wrapped or case-varied hex payload formatting, `PERMISSION_SET` spacing, or quoted versus bracketed `ADD FILE` names.
3940
- Trailing semicolon differences on `INSERT` statement lines in data scripts are now suppressed during comparison normalization; scripts emitted with and without statement terminators compare as compatible (#47).
4041
- Legacy `TableData` scripts now compare as compatible when they differ from canonical output only by `SET IDENTITY_INSERT` semicolons or top-level `N'...'` string literal prefixes, including inside multi-line `INSERT ... VALUES (...)` statements.
4142
- Empty separator lines are now ignored during `status` and `diff`, and whitespace-only separator lines compare as compatible after normalization.

specs/01-cli.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ Behavior:
217217
- Equivalent `Role` membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` compare as compatible.
218218
- Equivalent `MessageType` validation synonyms/spacing and equivalent `Contract` and `Service` body formatting and item ordering compare as compatible.
219219
- Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Trigger`) compare as compatible.
220+
- For `Assembly`, comparison ignores leading legacy `--Assembly ...` banner comments and treats wrapped/case-varied `0x...` payloads, spacing-only `PERMISSION_SET` formatting, and `ADD FILE ... AS [name]` versus `AS 'name'` as compatible when the effective assembly definition is otherwise identical.
220221
- When `data.trackedTables` is configured, `status` also reports data-script differences for tracked tables.
221222
- Status output MUST report schema and data summaries separately.
222223
- Exit codes:
@@ -255,6 +256,7 @@ Behavior:
255256
- Equivalent `Role` membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` compare as compatible.
256257
- Equivalent `MessageType` validation synonyms/spacing and equivalent `Contract` and `Service` body formatting and item ordering compare as compatible.
257258
- Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Trigger`) compare as compatible.
259+
- For `Assembly`, comparison ignores leading legacy `--Assembly ...` banner comments and treats wrapped/case-varied `0x...` payloads, spacing-only `PERMISSION_SET` formatting, and `ADD FILE ... AS [name]` versus `AS 'name'` as compatible when the effective assembly definition is otherwise identical.
258260
- Diff output uses a chunked format: only changed lines and their surrounding context are shown, not the entire file.
259261
- `--context <N>` controls the number of unchanged context lines shown before and after each changed segment (default: 3). Negative values are treated as 0.
260262
- `--normalized-diff` switches diff rendering to the exact comparison-normalized text used for compatibility evaluation. It is intended for debugging and is off by default.

specs/04-scripting.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,7 @@ When compatibility reference files are available, `sqlct` MAY apply reconciliati
842842
- For `Service`, comparison normalization MAY treat equivalent single-line and multi-line contract-list formatting as compatible and MAY compare contract item ordering as compatible when the emitted contract set is otherwise identical.
843843
- For CLR table-valued `Function` scripts, comparison normalization MAY treat legacy explicit `NULL` tokens on return-column lines as compatible with canonical return-column lines that omit nullability, including legacy cases where the final return-column line also carries the closing `)` token.
844844
- For CLR table-valued `Function` scripts, comparison normalization MAY treat legacy `COLLATE <name>` clauses on return-column lines as compatible when SQL Server ignores that collation metadata for the effective return shape.
845+
- For `Assembly`, comparison normalization MAY ignore leading legacy `--Assembly ...` banner comments and MAY treat wrapped or case-varied `0x...` payload literals, spacing-only `WITH PERMISSION_SET = ...` differences, and `ALTER ASSEMBLY ... ADD FILE ... AS [name]` versus `AS 'name'` as compatible when the effective assembly definition is otherwise identical.
845846

846847
## 11. Error and Unsupported Behavior
847848
- Missing SQL object metadata for requested object MUST fail with an error.

src/SqlChangeTracker/Sync/SyncCommandService.cs

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ internal enum ComparisonTarget
3939
internal sealed class SyncCommandService : ISyncCommandService
4040
{
4141
internal const string TableDataObjectType = "TableData";
42+
private const string SqlIdentifierTokenPattern = @"(?:\[(?:[^\]]|\]\])+\]|""(?:""""|[^""])+""|[^\s;]+)";
43+
private const string SqlStringOrIdentifierTokenPattern = @"(?:\[(?:[^\]]|\]\])+\]|""(?:""""|[^""])+""|'(?:''|[^'])*'|[^\s;]+)";
4244
private static readonly Regex ScalarUserDefinedTypeScriptRegex = new(
4345
@"\bCREATE\s+TYPE\b.*\bFROM\b",
4446
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.Compiled);
@@ -75,9 +77,18 @@ internal sealed class SyncCommandService : ISyncCommandService
7577
private static readonly Regex SsmsObjectHeaderCommentRegex = new(
7678
@"^\s*/\*{5,}\s*Object:\s+(?:StoredProcedure|Procedure|View|Function|Trigger)\b.*Script Date:.*\*+/\s*$",
7779
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
80+
private static readonly Regex AssemblyHeaderCommentRegex = new(
81+
@"^\s*--\s*Assembly\b.*$",
82+
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
7883
private static readonly Regex CompatibleTextImageOnRegex = new(
7984
@"^(?<prefix>\)\s*(?:ON\s+(?:\[[^\]]+(?:\]\])*\]|""(?:""""|[^""])+""|[^\s]+)(?:\s*\([^)]+\))?)?)\s+TEXTIMAGE_ON\s+(?<dataSpace>\[[^\]]+(?:\]\])*\]|""(?:""""|[^""])+""|[^\s]+)(?<suffix>\s*)$",
8085
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
86+
private static readonly Regex CreateAssemblyBlockRegex = new(
87+
$@"^\s*CREATE\s+ASSEMBLY\s+(?<name>{SqlIdentifierTokenPattern})\s+(?:AUTHORIZATION\s+(?<owner>{SqlIdentifierTokenPattern})\s+)?FROM\s+(?<hex>0x(?:[0-9A-Fa-f\\\s])+?)\s+WITH\s+PERMISSION_SET\s*=\s*(?<permission>[A-Za-z_]+)\s*;?\s*$",
88+
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.Compiled);
89+
private static readonly Regex AlterAssemblyAddFileBlockRegex = new(
90+
$@"^\s*ALTER\s+ASSEMBLY\s+(?<name>{SqlIdentifierTokenPattern})\s+ADD\s+FILE\s+FROM\s+(?<hex>0x(?:[0-9A-Fa-f\\\s])+?)\s+AS\s+(?<file>{SqlStringOrIdentifierTokenPattern})\s*;?\s*$",
91+
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.Compiled);
8192
private static readonly IReadOnlyList<SupportedSqlObjectType> ActiveObjectTypes = SupportedSqlObjectTypes.ActiveSync;
8293

8394
private readonly SqlctConfigReader _configReader;
@@ -1710,6 +1721,12 @@ private static ComparableLine[] BuildComparableLinesForDiff(
17101721
return BuildUserDefinedTypeComparableLinesForDiff(normalized);
17111722
}
17121723

1724+
if (string.Equals(objectType, "Assembly", StringComparison.OrdinalIgnoreCase))
1725+
{
1726+
var normalized = PrepareScriptForReadableDiffDisplay(script, objectType);
1727+
return BuildAssemblyComparableLinesForDiff(normalized);
1728+
}
1729+
17131730
return null;
17141731
}
17151732

@@ -1732,6 +1749,11 @@ private static string PrepareScriptForReadableDiffDisplay(string script, string?
17321749
{
17331750
lines[i] = string.Empty;
17341751
}
1752+
else if (IsAssemblyObjectTypeForHeaderCommentCompatibility(objectType) &&
1753+
AssemblyHeaderCommentRegex.IsMatch(lines[i]))
1754+
{
1755+
lines[i] = string.Empty;
1756+
}
17351757
}
17361758

17371759
if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType))
@@ -1929,6 +1951,37 @@ private static string[] BuildUserDefinedTypeDisplayUnitsForDiff(string script)
19291951
return comparableLines.ToArray();
19301952
}
19311953

1954+
private static ComparableLine[] BuildAssemblyComparableLinesForDiff(string script)
1955+
{
1956+
var blocks = SplitGoDelimitedBlocks(script);
1957+
if (blocks.Count == 0)
1958+
{
1959+
return Array.Empty<ComparableLine>();
1960+
}
1961+
1962+
var comparableLines = new List<ComparableLine>(blocks.Count * 2);
1963+
foreach (var block in blocks)
1964+
{
1965+
var hasTerminalGo =
1966+
block.Length > 0 &&
1967+
string.Equals(block[^1].Trim(), "GO", StringComparison.OrdinalIgnoreCase);
1968+
var contentLines = hasTerminalGo ? block[..^1] : block;
1969+
if (contentLines.Any(line => line.Length > 0))
1970+
{
1971+
comparableLines.Add(BuildComparableLineForStructuredBlock(
1972+
NormalizeAssemblyBlockContentForComparison(contentLines),
1973+
string.Join("\n", contentLines)));
1974+
}
1975+
1976+
if (hasTerminalGo)
1977+
{
1978+
comparableLines.Add(new ComparableLine("GO", "GO"));
1979+
}
1980+
}
1981+
1982+
return comparableLines.ToArray();
1983+
}
1984+
19321985
private static IReadOnlyList<string> GetDisplayUnitsForTablePostCreatePackage(string package)
19331986
{
19341987
var blocks = SplitGoDelimitedBlocks(package);
@@ -2649,6 +2702,11 @@ internal static string NormalizeForComparison(
26492702
{
26502703
lines[i] = string.Empty;
26512704
}
2705+
else if (IsAssemblyObjectTypeForHeaderCommentCompatibility(objectType) &&
2706+
AssemblyHeaderCommentRegex.IsMatch(lines[i]))
2707+
{
2708+
lines[i] = string.Empty;
2709+
}
26522710
}
26532711

26542712
if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType))
@@ -2690,6 +2748,11 @@ internal static string NormalizeForComparison(
26902748
joined = NormalizeServiceBrokerScriptForComparison(joined, NormalizeServiceBaseBlockForComparison);
26912749
}
26922750

2751+
if (string.Equals(objectType, "Assembly", StringComparison.OrdinalIgnoreCase))
2752+
{
2753+
joined = NormalizeAssemblyScriptForComparison(joined);
2754+
}
2755+
26932756
if (string.Equals(objectType, "Function", StringComparison.OrdinalIgnoreCase))
26942757
{
26952758
joined = NormalizeClrTableValuedFunctionScriptForComparison(joined);
@@ -2864,6 +2927,11 @@ private static string NormalizeForDiffDisplay(
28642927
{
28652928
lines[i] = string.Empty;
28662929
}
2930+
else if (IsAssemblyObjectTypeForHeaderCommentCompatibility(objectType) &&
2931+
AssemblyHeaderCommentRegex.IsMatch(lines[i]))
2932+
{
2933+
lines[i] = string.Empty;
2934+
}
28672935
}
28682936

28692937
if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType))
@@ -3061,6 +3129,163 @@ private static string NormalizeServiceBaseBlockForComparison(string baseBlock)
30613129
: rebuilt + " " + suffix;
30623130
}
30633131

3132+
private static string NormalizeAssemblyScriptForComparison(string script)
3133+
{
3134+
var blocks = SplitGoDelimitedBlocks(script);
3135+
if (blocks.Count == 0)
3136+
{
3137+
return script;
3138+
}
3139+
3140+
var normalizedBlocks = new List<string>(blocks.Count);
3141+
foreach (var block in blocks)
3142+
{
3143+
var hasTerminalGo =
3144+
block.Length > 0 &&
3145+
string.Equals(block[^1].Trim(), "GO", StringComparison.OrdinalIgnoreCase);
3146+
var contentLines = hasTerminalGo ? block[..^1] : block;
3147+
if (contentLines.Any(line => line.Length > 0))
3148+
{
3149+
normalizedBlocks.Add(NormalizeAssemblyBlockContentForComparison(contentLines));
3150+
}
3151+
3152+
if (hasTerminalGo)
3153+
{
3154+
normalizedBlocks.Add("GO");
3155+
}
3156+
}
3157+
3158+
return string.Join("\n", normalizedBlocks);
3159+
}
3160+
3161+
private static string NormalizeAssemblyBlockContentForComparison(IEnumerable<string> blockLines)
3162+
{
3163+
var contentLines = blockLines
3164+
.Select(line => line.Trim())
3165+
.Where(line => line.Length > 0)
3166+
.ToArray();
3167+
if (contentLines.Length == 0)
3168+
{
3169+
return string.Empty;
3170+
}
3171+
3172+
if (TryNormalizeCreateAssemblyBlockForComparison(contentLines, out var createAssembly))
3173+
{
3174+
return createAssembly;
3175+
}
3176+
3177+
if (TryNormalizeAlterAssemblyAddFileBlockForComparison(contentLines, out var addFileAssembly))
3178+
{
3179+
return addFileAssembly;
3180+
}
3181+
3182+
if (contentLines.Length == 1 && IsPermissionStatementLine(contentLines[0]))
3183+
{
3184+
return NormalizePermissionStatementForComparison(contentLines[0]);
3185+
}
3186+
3187+
if (contentLines.Length == 1 && IsExtendedPropertyStatementLine(contentLines[0]))
3188+
{
3189+
return NormalizeExtendedPropertyStatementForComparison(contentLines[0]);
3190+
}
3191+
3192+
return NormalizeSqlStatementTokensForComparison(string.Join(" ", contentLines));
3193+
}
3194+
3195+
private static bool TryNormalizeCreateAssemblyBlockForComparison(
3196+
IReadOnlyList<string> contentLines,
3197+
out string normalized)
3198+
{
3199+
var match = CreateAssemblyBlockRegex.Match(string.Join("\n", contentLines));
3200+
if (!match.Success)
3201+
{
3202+
normalized = string.Empty;
3203+
return false;
3204+
}
3205+
3206+
var normalizedLines = new List<string>
3207+
{
3208+
NormalizeSqlStatementTokensForComparison(
3209+
$"CREATE ASSEMBLY {QuoteIdentifierForComparison(UnquoteSqlIdentifier(match.Groups["name"].Value))}")
3210+
};
3211+
3212+
if (match.Groups["owner"].Success)
3213+
{
3214+
normalizedLines.Add(NormalizeSqlStatementTokensForComparison(
3215+
$"AUTHORIZATION {QuoteIdentifierForComparison(UnquoteSqlIdentifier(match.Groups["owner"].Value))}"));
3216+
}
3217+
3218+
normalizedLines.Add(NormalizeSqlStatementTokensForComparison(
3219+
$"FROM 0x{NormalizeAssemblyHexLiteralForComparison(match.Groups["hex"].Value)}"));
3220+
normalizedLines.Add(NormalizeSqlStatementTokensForComparison(
3221+
$"WITH PERMISSION_SET = {NormalizeAssemblyPermissionSetForComparison(match.Groups["permission"].Value)}"));
3222+
normalized = string.Join("\n", normalizedLines);
3223+
return true;
3224+
}
3225+
3226+
private static bool TryNormalizeAlterAssemblyAddFileBlockForComparison(
3227+
IReadOnlyList<string> contentLines,
3228+
out string normalized)
3229+
{
3230+
var match = AlterAssemblyAddFileBlockRegex.Match(string.Join("\n", contentLines));
3231+
if (!match.Success)
3232+
{
3233+
normalized = string.Empty;
3234+
return false;
3235+
}
3236+
3237+
normalized = NormalizeSqlStatementTokensForComparison(
3238+
$"ALTER ASSEMBLY {QuoteIdentifierForComparison(UnquoteSqlIdentifier(match.Groups["name"].Value))} " +
3239+
$"ADD FILE FROM 0x{NormalizeAssemblyHexLiteralForComparison(match.Groups["hex"].Value)} " +
3240+
$"AS {NormalizeAssemblyFileTokenForComparison(match.Groups["file"].Value)}");
3241+
return true;
3242+
}
3243+
3244+
private static string NormalizeAssemblyHexLiteralForComparison(string value)
3245+
{
3246+
var hexPrefixIndex = value.IndexOf("0x", StringComparison.OrdinalIgnoreCase);
3247+
if (hexPrefixIndex < 0)
3248+
{
3249+
return value.Trim().ToLowerInvariant();
3250+
}
3251+
3252+
var builder = new StringBuilder(value.Length);
3253+
for (var i = hexPrefixIndex + 2; i < value.Length; i++)
3254+
{
3255+
var current = value[i];
3256+
if (char.IsWhiteSpace(current) || current == '\\')
3257+
{
3258+
continue;
3259+
}
3260+
3261+
if (IsHexDigit(current))
3262+
{
3263+
builder.Append(char.ToLowerInvariant(current));
3264+
}
3265+
}
3266+
3267+
return builder.ToString();
3268+
}
3269+
3270+
private static string NormalizeAssemblyPermissionSetForComparison(string value)
3271+
=> value.Trim().ToUpperInvariant() switch
3272+
{
3273+
"SAFE" or "SAFE_ACCESS" => "SAFE",
3274+
"EXTERNAL_ACCESS" => "EXTERNAL_ACCESS",
3275+
"UNSAFE" or "UNSAFE_ACCESS" => "UNSAFE",
3276+
_ => value.Trim().ToUpperInvariant()
3277+
};
3278+
3279+
private static string NormalizeAssemblyFileTokenForComparison(string value)
3280+
{
3281+
if (value.Length >= 2 && value[0] == '\'' && value[^1] == '\'')
3282+
{
3283+
return QuoteIdentifierForComparison(UnescapeSqlStringLiteral(value[1..^1]));
3284+
}
3285+
3286+
return QuoteIdentifierForComparison(UnquoteSqlIdentifier(value));
3287+
}
3288+
30643289
private static string CollapseServiceBrokerWhitespace(string text)
30653290
=> Regex.Replace(text, @"\s+", " ", RegexOptions.CultureInvariant).Trim();
30663291

@@ -3948,6 +4173,9 @@ private static bool IsProgrammableObjectTypeForHeaderCommentCompatibility(string
39484173
|| string.Equals(objectType, "Function", StringComparison.OrdinalIgnoreCase)
39494174
|| string.Equals(objectType, "Trigger", StringComparison.OrdinalIgnoreCase);
39504175

4176+
private static bool IsAssemblyObjectTypeForHeaderCommentCompatibility(string? objectType)
4177+
=> string.Equals(objectType, "Assembly", StringComparison.OrdinalIgnoreCase);
4178+
39514179
private static bool IsExtendedPropertyStatementLine(string line)
39524180
=> ExtendedPropertyStatementRegex.IsMatch(line);
39534181

0 commit comments

Comments
 (0)