From de82866f593dc72b808eae1df5024c0e6c08adae Mon Sep 17 00:00:00 2001 From: zacateras Date: Fri, 10 Apr 2026 12:53:45 +0200 Subject: [PATCH 1/7] feat: enhance compatibility handling for index options and legacy table formatting during comparison --- CHANGELOG.md | 5 +- specs/01-cli.md | 8 +- specs/04-scripting.md | 32 ++- src/SqlChangeTracker/Sql/SqlServerScripter.cs | 103 +++++++-- .../Sync/SyncCommandService.cs | 217 +++++++++++++++++- .../SqlServerScripterCompatibilityTests.cs | 18 ++ .../Sql/SqlServerScripterTests.cs | 69 ++++++ .../Sync/SyncCommandServiceTests.cs | 92 +++++++- 8 files changed, 514 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bec673c..d7b5e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Treat legacy Service Broker message-type validation synonyms and equivalent contract/service body formatting and item ordering as compatible during comparison. - Treat equivalent `TableData` scripts as compatible during comparison when the normalized `INSERT` statements differ only by row ordering within the same contiguous data block. - Treat equivalent `Table` scripts as compatible during comparison when post-create statement packages differ only by ordering after the base `CREATE TABLE` block. +- Treat equivalent legacy `Table` statement formatting as compatible during comparison when normalized table definitions, post-create table statements, and persisted option values are otherwise identical. - Treat omitted `TEXTIMAGE_ON` on `Table` scripts as compatible during comparison only when DB metadata shows the table LOB data space matches the current default data space. - Treat equivalent extended-property blocks as compatible during comparison when the normalized `sp_addextendedproperty` statements differ only by ordering, argument spacing, or named-vs-positional argument forms within the same contiguous block. +- Treat leading SSMS-generated banner comments on programmable objects as compatible during comparison. - Treat redundant empty or otherwise no-op `GO` batches as compatible during comparison. - 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. - 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). - 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. -- Whitespace-only separator lines now compare as compatible with empty blank lines during `status` and `diff`. +- Empty separator lines are now ignored during `status` and `diff`, and whitespace-only separator lines compare as compatible after normalization. - Preserve reference banner-comment formatting and module-declaration identifier quoting during programmable-object compatibility reconciliation. - Preserve compatible computed-column arithmetic grouping parentheses during table compatibility reconciliation. @@ -44,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Support active object type `Assembly`, with deterministic scripting to `Assemblies/*.sql` for user-defined SQL Server assemblies. - Support additional active object types: `TableType`, `XmlSchemaCollection`, `MessageType`, `Contract`, `Queue`, `Service`, `Route`, `EventNotification`, `ServiceBinding`, `FullTextCatalog`, `FullTextStoplist`, and `SearchPropertyList`. - Script standalone user-created table statistics as deterministic post-create table statements, including filtered, effective sampling, persisted-sample, incremental, and auto-drop metadata when available. +- Script persisted key and index storage options such as fill factor, pad index, duplicate-key handling, and row/page locking when those options differ from defaults. - Add `--object ` to `sqlct data track` and `sqlct data untrack` as a flag alias for the positional pattern argument. - Add `--filter ` to `sqlct data track` and `sqlct data untrack` for regex-based table matching; matched case-insensitively against the full `schema.table` display name. Exactly one of the positional pattern, `--object`, or `--filter` must be provided; combining any two returns exit code 2. - `sqlct diff` now uses a chunked diff format: only changed segments and configurable surrounding context lines are shown instead of the full file. Use `--context ` to control the number of context lines (default: 3) (#39). diff --git a/specs/01-cli.md b/specs/01-cli.md index 52ae3e8..4bc4ced 100644 --- a/specs/01-cli.md +++ b/specs/01-cli.md @@ -201,16 +201,18 @@ Behavior: - Changed: normalized script content differs. - Suppress changes when scripts are identical after normalization. - Normalization includes line-ending/trailing-newline stability plus explicitly listed compatibility rules for deterministic comparison. -- Whitespace-only lines are normalized to empty lines during comparison so blank separators with spaces or tabs compare as compatible. +- Empty lines are ignored during comparison, and whitespace-only lines are normalized to empty lines first so blank separators differing only by spaces or tabs compare as compatible. - Redundant empty or no-op `GO` batches compare as compatible. - Trailing semicolons on `INSERT` statement lines are stripped during normalization; scripts emitted with and without statement terminators compare as compatible. - Equivalent `TableData` `INSERT` statement ordering within the same contiguous data block compares as compatible when the inserted row set is otherwise identical. - Equivalent `Table` post-create statement package ordering compares as compatible when the normalized package set after the base `CREATE TABLE` block is otherwise identical. +- Equivalent legacy `Table` statement formatting for `CREATE TABLE`, `ALTER TABLE`, and `CREATE ... INDEX` statements compares as compatible when normalized identifiers, type tokens, default expressions, semicolons, and persisted option values are otherwise identical. - For `Table`, omitted `TEXTIMAGE_ON [name]` compares as compatible with an explicit clause only when DB metadata shows that the table LOB data space equals the current default data space represented by `[name]`. - Equivalent extended-property statement ordering within the same contiguous extended-property block compares as compatible when the normalized property statement set is otherwise identical. Equivalent named-vs-positional `sp_addextendedproperty` argument forms, including omitted trailing `NULL` levels, compare as compatible. - Equivalent `Queue` option spacing, line wrapping, explicit default `ON [PRIMARY]`, and disabled default activation compare as compatible. - Equivalent `Role` membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` compare as compatible. - Equivalent `MessageType` validation synonyms/spacing and equivalent `Contract` and `Service` body formatting and item ordering compare as compatible. +- Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Trigger`) compare as compatible. - When `data.trackedTables` is configured, `status` also reports data-script differences for tracked tables. - Status output MUST report schema and data summaries separately. - Exit codes: @@ -231,16 +233,18 @@ Behavior: - Changed objects use DB-vs-folder unified diff. - Added/deleted objects use empty-side vs script-side unified diff. - Normalization includes line-ending/trailing-newline stability plus explicitly listed compatibility rules for deterministic comparison. -- Whitespace-only lines are normalized to empty lines during comparison so blank separators with spaces or tabs compare as compatible. +- Empty lines are ignored during comparison, and whitespace-only lines are normalized to empty lines first so blank separators differing only by spaces or tabs compare as compatible. - Redundant empty or no-op `GO` batches compare as compatible. - Trailing semicolons on `INSERT` statement lines are stripped during normalization; scripts emitted with and without statement terminators compare as compatible. - Equivalent `TableData` `INSERT` statement ordering within the same contiguous data block compares as compatible when the inserted row set is otherwise identical. - Equivalent `Table` post-create statement package ordering compares as compatible when the normalized package set after the base `CREATE TABLE` block is otherwise identical. +- Equivalent legacy `Table` statement formatting for `CREATE TABLE`, `ALTER TABLE`, and `CREATE ... INDEX` statements compares as compatible when normalized identifiers, type tokens, default expressions, semicolons, and persisted option values are otherwise identical. - For `Table`, omitted `TEXTIMAGE_ON [name]` compares as compatible with an explicit clause only when DB metadata shows that the table LOB data space equals the current default data space represented by `[name]`. - Equivalent extended-property statement ordering within the same contiguous extended-property block compares as compatible when the normalized property statement set is otherwise identical. Equivalent named-vs-positional `sp_addextendedproperty` argument forms, including omitted trailing `NULL` levels, compare as compatible. - Equivalent `Queue` option spacing, line wrapping, explicit default `ON [PRIMARY]`, and disabled default activation compare as compatible. - Equivalent `Role` membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` compare as compatible. - Equivalent `MessageType` validation synonyms/spacing and equivalent `Contract` and `Service` body formatting and item ordering compare as compatible. +- Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Trigger`) compare as compatible. - Diff output uses a chunked format: only changed lines and their surrounding context are shown, not the entire file. - `--context ` controls the number of unchanged context lines shown before and after each changed segment (default: 3). Negative values are treated as 0. - When two change segments are close enough that their context regions overlap, they are merged into a single hunk. diff --git a/specs/04-scripting.md b/specs/04-scripting.md index 957fd17..36c2510 100644 --- a/specs/04-scripting.md +++ b/specs/04-scripting.md @@ -253,10 +253,16 @@ Each emitted statement MUST be followed by `GO`. - index type contains `CLUSTERED` -> `CLUSTERED` - otherwise -> `NONCLUSTERED` - Key columns MUST be ordered by `key_ordinal` and include `DESC` on descending keys. -- Constraint-level `WITH` options MUST include `STATISTICS_INCREMENTAL=ON` when the backing index statistics are incremental. -- Constraint-level `WITH` options MUST include `DATA_COMPRESSION = ` when compression is not `NONE`. -- When both constraint-level options are present, they MUST be emitted in this order: - - `WITH (STATISTICS_INCREMENTAL=ON, DATA_COMPRESSION = )` +- Constraint-level `WITH` options MUST include: + - `PAD_INDEX = ON` when the backing index is padded, + - `FILLFACTOR = ` when the backing index fill factor is non-zero, + - `IGNORE_DUP_KEY = ON` when the backing index ignores duplicate keys, + - `STATISTICS_INCREMENTAL=ON` when the backing index statistics are incremental, + - `DATA_COMPRESSION = ` when compression is not `NONE`, + - `ALLOW_ROW_LOCKS = OFF` when the backing index disables row locks, + - `ALLOW_PAGE_LOCKS = OFF` when the backing index disables page locks. +- When multiple constraint-level options are present, they MUST be emitted in this order: + - `WITH (PAD_INDEX = ON, FILLFACTOR = , IGNORE_DUP_KEY = ON, STATISTICS_INCREMENTAL=ON, DATA_COMPRESSION = , ALLOW_ROW_LOCKS = OFF, ALLOW_PAGE_LOCKS = OFF)` - `ON [data_space]` MUST be emitted when available. - `ON [data_space] ([partition_column])` MUST be emitted when the backing index is partitioned and the partitioning column is available from catalog metadata. @@ -270,10 +276,16 @@ Each emitted statement MUST be followed by `GO`. - Key columns MUST be ordered by `key_ordinal`, then `index_column_id`. - Included columns MUST be emitted in `INCLUDE (...)` when present. - Filtered index predicate MUST be emitted as `WHERE ` when present. -- Index `WITH` options MUST include `STATISTICS_INCREMENTAL=ON` when the backing index statistics are incremental. -- Index compression MUST emit `DATA_COMPRESSION = ` when not `NONE`. -- When both index-level options are present, they MUST be emitted in this order: - - `WITH (STATISTICS_INCREMENTAL=ON, DATA_COMPRESSION = )` +- Index `WITH` options MUST include: + - `PAD_INDEX = ON` when the index is padded, + - `FILLFACTOR = ` when the index fill factor is non-zero, + - `IGNORE_DUP_KEY = ON` when the index ignores duplicate keys, + - `STATISTICS_INCREMENTAL=ON` when the backing index statistics are incremental, + - `DATA_COMPRESSION = ` when compression is not `NONE`, + - `ALLOW_ROW_LOCKS = OFF` when the index disables row locks, + - `ALLOW_PAGE_LOCKS = OFF` when the index disables page locks. +- When multiple index-level options are present, they MUST be emitted in this order: + - `WITH (PAD_INDEX = ON, FILLFACTOR = , IGNORE_DUP_KEY = ON, STATISTICS_INCREMENTAL=ON, DATA_COMPRESSION = , ALLOW_ROW_LOCKS = OFF, ALLOW_PAGE_LOCKS = OFF)` - `ON [data_space]` MUST be emitted when available. - `ON [data_space] ([partition_column])` MUST be emitted when the index is partitioned and the partitioning column is available from catalog metadata. @@ -800,15 +812,17 @@ When compatibility reference files are available, `sqlct` MAY apply reconciliati - Script generation MUST emit canonical scripting output per this document and MUST NOT include diff/status-specific normalization. - `status` and `diff` normalization behaviors are external contracts defined in `specs/01-cli.md` and `specs/05-output-formats.md`. - Scripting and comparison normalization responsibilities MUST remain decoupled. -- Whitespace-only lines MUST be normalized to empty lines during comparison so that blank separators differing only by spaces or tabs compare as compatible. +- Empty lines MUST be ignored during comparison, and whitespace-only lines MUST be normalized to empty lines first so that blank separators differing only by spaces or tabs compare as compatible. - Comparison normalization MAY ignore redundant empty or otherwise no-op `GO` batches, including batches that contain only standalone semicolon lines. - Trailing semicolons on `INSERT` statement lines MUST be stripped by comparison normalization so that scripts emitted with and without statement terminators compare as compatible. - For `TableData`, trailing semicolons on `SET IDENTITY_INSERT` lines MUST also be stripped by comparison normalization. - For `TableData`, comparison normalization MUST treat legacy top-level `N'...'` string literals inside single-line or multi-line `INSERT ... VALUES (...)` statements as compatible with canonical `'...'` literals; canonical script generation remains governed by Section 8.26. - For `TableData`, comparison normalization MAY treat reordered `INSERT ... VALUES (...)` statements within the same contiguous data block as compatible when the normalized inserted-row set is otherwise identical. - For `Table`, comparison normalization MAY treat reordered post-create statement packages as compatible when the normalized package set after the base table `CREATE` block is otherwise identical. Table-scoped trigger packages MUST include the trigger body together with any immediately preceding programmable-object `SET` blocks. +- For `Table`, comparison normalization MAY treat equivalent legacy formatting for `CREATE TABLE`, `ALTER TABLE`, and `CREATE ... INDEX` statement blocks as compatible when normalized identifiers, type tokens, default expressions, semicolons, and persisted option values are otherwise identical. - For `Table`, comparison normalization MAY treat omitted `TEXTIMAGE_ON [name]` as compatible with an explicit clause only when DB metadata shows that the table `lob_data_space_id` resolves to the current default data space named `[name]`; otherwise omission remains a semantic difference. - For extended-property blocks, comparison normalization MAY treat reordered `EXEC sp_addextendedproperty ...` statements as compatible within the same contiguous extended-property block when the normalized property statement set is otherwise identical, MAY ignore equivalent spacing around commas and arguments in those statements, and MAY treat equivalent named-vs-positional argument forms with omitted trailing `NULL` levels as compatible. +- For programmable `StoredProcedure`, `View`, `Function`, and `Trigger` scripts, comparison normalization MAY ignore leading SSMS-generated `/*** Object: ... Script Date: ... ***/` banner comments. - For `Queue`, comparison normalization MUST treat equivalent single-line and multi-line queue option formatting as compatible, MAY treat explicit `ON [PRIMARY]` as equivalent to an omitted default primary filegroup, and MAY treat disabled activation containing only default owner execution context as equivalent to omitted activation. - For `Role`, comparison normalization MAY treat legacy `EXEC sp_addrolemember N'', N''` statements as compatible with `ALTER ROLE [role] ADD MEMBER [member]` when the effective role-membership change is otherwise identical. - For `MessageType`, comparison normalization MAY treat legacy `VALIDATION = XML` as compatible with canonical `VALIDATION = WELL_FORMED_XML`, and MAY ignore equivalent spacing around the validation assignment. diff --git a/src/SqlChangeTracker/Sql/SqlServerScripter.cs b/src/SqlChangeTracker/Sql/SqlServerScripter.cs index d92e4df..304af6d 100644 --- a/src/SqlChangeTracker/Sql/SqlServerScripter.cs +++ b/src/SqlChangeTracker/Sql/SqlServerScripter.cs @@ -88,10 +88,15 @@ private readonly record struct TableStorageInfo( string? PartitionColumn, string? LobDataSpace); - private readonly record struct IndexScriptingOptions( + internal readonly record struct IndexScriptingOptions( string Compression, string? PartitionColumn, - bool StatisticsIncremental); + bool StatisticsIncremental, + bool PadIndex, + byte FillFactor, + bool IgnoreDupKey, + bool AllowRowLocks, + bool AllowPageLocks); private readonly record struct StatisticsScriptingOptions( string? SamplingClause, @@ -2792,22 +2797,58 @@ internal static string BuildIndexOnClause(string? dataSpace, string? partitionCo } internal static string BuildIndexWithClause(string compression, bool statisticsIncremental) + => BuildIndexWithClause(new IndexScriptingOptions( + compression, + null, + statisticsIncremental, + PadIndex: false, + FillFactor: 0, + IgnoreDupKey: false, + AllowRowLocks: true, + AllowPageLocks: true)); + + internal static string BuildIndexWithClause(IndexScriptingOptions options) { - var options = new List(); - if (statisticsIncremental) + var clauseOptions = new List(); + if (options.PadIndex) { - options.Add("STATISTICS_INCREMENTAL=ON"); + clauseOptions.Add("PAD_INDEX = ON"); } - if (!string.IsNullOrWhiteSpace(compression) && - !string.Equals(compression, "NONE", StringComparison.OrdinalIgnoreCase)) + if (options.FillFactor > 0) { - options.Add($"DATA_COMPRESSION = {compression}"); + clauseOptions.Add($"FILLFACTOR = {options.FillFactor}"); } - return options.Count == 0 + if (options.IgnoreDupKey) + { + clauseOptions.Add("IGNORE_DUP_KEY = ON"); + } + + if (options.StatisticsIncremental) + { + clauseOptions.Add("STATISTICS_INCREMENTAL=ON"); + } + + if (!string.IsNullOrWhiteSpace(options.Compression) && + !string.Equals(options.Compression, "NONE", StringComparison.OrdinalIgnoreCase)) + { + clauseOptions.Add($"DATA_COMPRESSION = {options.Compression}"); + } + + if (!options.AllowRowLocks) + { + clauseOptions.Add("ALLOW_ROW_LOCKS = OFF"); + } + + if (!options.AllowPageLocks) + { + clauseOptions.Add("ALLOW_PAGE_LOCKS = OFF"); + } + + return clauseOptions.Count == 0 ? string.Empty - : $" WITH ({string.Join(", ", options)})"; + : $" WITH ({string.Join(", ", clauseOptions)})"; } internal static string BuildStatisticsWithClause( @@ -3951,7 +3992,7 @@ FROM sys.key_constraints kc ? "CLUSTERED" : "NONCLUSTERED"; var indexOptions = ReadIndexScriptingOptions(connection, objectId, indexId); - var withClause = BuildIndexWithClause(indexOptions.Compression, indexOptions.StatisticsIncremental); + var withClause = BuildIndexWithClause(indexOptions); var onClause = BuildIndexOnClause(dataSpace, indexOptions.PartitionColumn); if (keyLineMap != null && keyLineMap.TryGetValue(name, out var line)) @@ -4069,7 +4110,7 @@ FROM sys.index_columns ic var columnstoreType = index.TypeDesc.StartsWith("CLUSTERED", StringComparison.OrdinalIgnoreCase) ? "CLUSTERED COLUMNSTORE" : "NONCLUSTERED COLUMNSTORE"; - var columnstoreWithClause = BuildIndexWithClause("NONE", indexOptions.StatisticsIncremental); + var columnstoreWithClause = BuildIndexWithClause(indexOptions with { Compression = "NONE" }); var columnstoreOnClause = BuildIndexOnClause(index.DataSpace, indexOptions.PartitionColumn); lines.Add($"CREATE {columnstoreType} INDEX [{index.Name}] ON {fullName}{columnstoreWithClause}{columnstoreOnClause}"); @@ -4086,7 +4127,7 @@ FROM sys.index_columns ic var type = string.Equals(index.TypeDesc, "CLUSTERED", StringComparison.OrdinalIgnoreCase) ? "CLUSTERED" : "NONCLUSTERED"; var includeClause = includes.Count > 0 ? $" INCLUDE ({string.Join(", ", includes)})" : string.Empty; var filterClause = string.IsNullOrWhiteSpace(index.Filter) ? string.Empty : $" WHERE {index.Filter}"; - var withClause = BuildIndexWithClause(indexOptions.Compression, indexOptions.StatisticsIncremental); + var withClause = BuildIndexWithClause(indexOptions); var onClause = BuildIndexOnClause(index.DataSpace, indexOptions.PartitionColumn); if (indexLineMap != null && indexLineMap.TryGetValue(index.Name, out var lineNonColumnstore)) @@ -4257,10 +4298,44 @@ OUTER APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) dsp private static IndexScriptingOptions ReadIndexScriptingOptions(SqlConnection connection, int objectId, int indexId) { + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT i.is_padded, + i.fill_factor, + i.ignore_dup_key, + i.allow_row_locks, + i.allow_page_locks +FROM sys.indexes i +WHERE i.object_id = @obj AND i.index_id = @idx;"; + command.Parameters.AddWithValue("@obj", objectId); + command.Parameters.AddWithValue("@idx", indexId); + + var padIndex = false; + byte fillFactor = 0; + var ignoreDupKey = false; + var allowRowLocks = true; + var allowPageLocks = true; + using (var reader = command.ExecuteReader()) + { + if (reader.Read()) + { + padIndex = !reader.IsDBNull(0) && reader.GetBoolean(0); + fillFactor = reader.IsDBNull(1) ? (byte)0 : reader.GetByte(1); + ignoreDupKey = !reader.IsDBNull(2) && reader.GetBoolean(2); + allowRowLocks = reader.IsDBNull(3) || reader.GetBoolean(3); + allowPageLocks = reader.IsDBNull(4) || reader.GetBoolean(4); + } + } + return new IndexScriptingOptions( ReadIndexCompression(connection, objectId, indexId), ReadIndexPartitionColumn(connection, objectId, indexId), - ReadIndexStatisticsIncremental(connection, objectId, indexId)); + ReadIndexStatisticsIncremental(connection, objectId, indexId), + padIndex, + fillFactor, + ignoreDupKey, + allowRowLocks, + allowPageLocks); } private static string ReadIndexCompression(SqlConnection connection, int objectId, int indexId) diff --git a/src/SqlChangeTracker/Sync/SyncCommandService.cs b/src/SqlChangeTracker/Sync/SyncCommandService.cs index 27dcd29..1c3bcc7 100644 --- a/src/SqlChangeTracker/Sync/SyncCommandService.cs +++ b/src/SqlChangeTracker/Sync/SyncCommandService.cs @@ -63,6 +63,9 @@ internal sealed class SyncCommandService : ISyncCommandService private static readonly Regex ExtendedPropertyStatementRegex = new( @"^\s*EXEC(?:UTE)?\s+(?:sys\.)?sp_addextendedproperty\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex SsmsObjectHeaderCommentRegex = new( + @"^\s*/\*{5,}\s*Object:\s+(?:StoredProcedure|Procedure|View|Function|Trigger)\b.*Script Date:.*\*+/\s*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex CompatibleTextImageOnRegex = new( @"^(?\)\s*(?:ON\s+(?:\[[^\]]+(?:\]\])*\]|""(?:""""|[^""])+""|[^\s]+)(?:\s*\([^)]+\))?)?)\s+TEXTIMAGE_ON\s+(?\[[^\]]+(?:\]\])*\]|""(?:""""|[^""])+""|[^\s]+)(?\s*)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); @@ -2040,8 +2043,20 @@ internal static string NormalizeForComparison( { lines[i] = string.Empty; } + else if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType) && + SsmsObjectHeaderCommentRegex.IsMatch(lines[i])) + { + lines[i] = string.Empty; + } } + if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType)) + { + lines = TrimLeadingEmptyLinesForComparison(lines); + } + + lines = RemoveEmptyLinesForComparison(lines); + var isTableData = string.Equals(objectType, TableDataObjectType, StringComparison.OrdinalIgnoreCase); var joined = string.Join("\n", lines); joined = NormalizeEmptyGoBatchesForComparison(joined); @@ -2193,6 +2208,20 @@ private static bool IsIgnorableNoOpBatchLine(string line) return trimmed.All(ch => ch == ';'); } + private static string[] TrimLeadingEmptyLinesForComparison(string[] lines) + { + var startIndex = 0; + while (startIndex < lines.Length && lines[startIndex].Length == 0) + { + startIndex++; + } + + return startIndex == 0 ? lines : lines[startIndex..]; + } + + private static string[] RemoveEmptyLinesForComparison(string[] lines) + => lines.Where(line => line.Length > 0).ToArray(); + private static string NormalizeQueueScriptForComparison(string script) { var normalized = Regex.Replace( @@ -2534,10 +2563,13 @@ private static string NormalizeTableScriptForComparison(string script) } var normalizedBlocks = new List(blocks.Count); - normalizedBlocks.AddRange(blocks.Take(createTableIndex + 1).Select(JoinBlockLines)); + normalizedBlocks.AddRange(blocks.Take(createTableIndex).Select(NormalizeTableBlockForComparison)); + normalizedBlocks.Add(NormalizeTableBlockForComparison(blocks[createTableIndex])); var postCreatePackages = BuildTablePostCreatePackages(blocks, createTableIndex + 1); - foreach (var package in postCreatePackages.OrderBy(NormalizeTablePostCreatePackageKey, StringComparer.Ordinal)) + foreach (var package in postCreatePackages + .Select(NormalizeTablePostCreatePackageForComparison) + .OrderBy(NormalizeTablePostCreatePackageKey, StringComparer.Ordinal)) { normalizedBlocks.Add(package); } @@ -2631,9 +2663,190 @@ private static List BuildTablePostCreatePackages(IReadOnlyList private static string NormalizeTablePostCreatePackageKey(string package) => Regex.Replace(package, @"\s+", " ", RegexOptions.CultureInvariant).Trim(); + private static string NormalizeTableBlockForComparison(string[] block) + { + var firstLine = GetFirstMeaningfulLine(block); + if (string.IsNullOrEmpty(firstLine)) + { + return JoinBlockLines(block); + } + + if (firstLine.StartsWith("CREATE TABLE", StringComparison.OrdinalIgnoreCase) || + firstLine.StartsWith("ALTER TABLE", StringComparison.OrdinalIgnoreCase) || + (firstLine.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase) && + firstLine.IndexOf(" INDEX ", StringComparison.OrdinalIgnoreCase) >= 0)) + { + return NormalizeLegacyTableStatementBlockForComparison(block); + } + + return JoinBlockLines(block); + } + + private static string NormalizeTablePostCreatePackageForComparison(string package) + { + var blocks = SplitGoDelimitedBlocks(package); + if (blocks.Count != 1) + { + return package; + } + + return NormalizeTableBlockForComparison(blocks[0]); + } + + private static string NormalizeLegacyTableStatementBlockForComparison(IEnumerable block) + { + var statement = string.Join( + " ", + block.Where(line => !string.Equals(line.Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + .Select(line => line.Trim()) + .Where(line => line.Length > 0)); + + if (statement.Length == 0) + { + return "GO"; + } + + return NormalizeLegacyTableStatementTextForComparison(statement) + "\nGO"; + } + + private static string NormalizeLegacyTableStatementTextForComparison(string statement) + { + var normalized = NormalizeSqlStatementTokensForComparison(statement); + string previous; + do + { + previous = normalized; + normalized = Regex.Replace( + normalized, + @"\bdefault\(\((?[-+]?\d+(?:\.\d+)?)\)\)", + "default(${expr})", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + while (!string.Equals(previous, normalized, StringComparison.Ordinal)); + + return normalized; + } + + private static string NormalizeSqlStatementTokensForComparison(string statement) + { + var builder = new StringBuilder(statement.Length); + var pendingSpace = false; + + for (var i = 0; i < statement.Length; i++) + { + var ch = statement[i]; + if (char.IsWhiteSpace(ch)) + { + pendingSpace = builder.Length > 0; + continue; + } + + if (ch == '\'') + { + if (pendingSpace && + builder.Length > 0 && + builder[^1] is not '(' and not '.' and not ',' and not '=') + { + builder.Append(' '); + } + + pendingSpace = false; + builder.Append(ch); + while (++i < statement.Length) + { + builder.Append(statement[i]); + if (statement[i] == '\'') + { + if (i + 1 < statement.Length && statement[i + 1] == '\'') + { + builder.Append(statement[++i]); + continue; + } + + break; + } + } + + continue; + } + + if (ch == '[' || ch == '"') + { + var quote = ch; + var start = i; + var tokenBuilder = new StringBuilder(); + while (++i < statement.Length) + { + var current = statement[i]; + if (current == (quote == '[' ? ']' : '"')) + { + if (i + 1 < statement.Length && statement[i + 1] == current) + { + tokenBuilder.Append(current); + i++; + continue; + } + + break; + } + + tokenBuilder.Append(current); + } + + if (pendingSpace && + builder.Length > 0 && + builder[^1] is not '(' and not '.' and not ',' and not '=') + { + builder.Append(' '); + } + + pendingSpace = false; + builder.Append(tokenBuilder.ToString().ToLowerInvariant()); + continue; + } + + if (ch == ';') + { + pendingSpace = false; + continue; + } + + if (ch is '(' or ')' or ',' or '=' or '.') + { + TrimTrailingSpaces(builder); + builder.Append(ch); + pendingSpace = false; + continue; + } + + if (pendingSpace && + builder.Length > 0 && + builder[^1] is not '(' and not '.' and not ',' and not '=') + { + builder.Append(' '); + } + + pendingSpace = false; + builder.Append(char.ToLowerInvariant(ch)); + } + + return builder.ToString().Trim(); + } + + private static string? GetFirstMeaningfulLine(IEnumerable lines) + => lines + .Select(line => line.TrimStart()) + .FirstOrDefault(line => line.Length > 0 && !string.Equals(line, "GO", StringComparison.OrdinalIgnoreCase)); + private static string JoinBlockLines(IEnumerable block) => string.Join("\n", block); + private static bool IsProgrammableObjectTypeForHeaderCommentCompatibility(string? objectType) + => string.Equals(objectType, "StoredProcedure", StringComparison.OrdinalIgnoreCase) + || string.Equals(objectType, "View", StringComparison.OrdinalIgnoreCase) + || string.Equals(objectType, "Function", StringComparison.OrdinalIgnoreCase) + || string.Equals(objectType, "Trigger", StringComparison.OrdinalIgnoreCase); + private static bool IsExtendedPropertyStatementLine(string line) => ExtendedPropertyStatementRegex.IsMatch(line); diff --git a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs index c176b0e..713461e 100644 --- a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs +++ b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs @@ -551,6 +551,24 @@ public void BuildIndexWithClause_EmitsStatisticsIncrementalBeforeCompression_Whe Assert.Equal(" WITH (STATISTICS_INCREMENTAL=ON, DATA_COMPRESSION = PAGE)", clause); } + [Fact] + public void BuildIndexWithClause_EmitsStablePersistedIndexOptionsInDeterministicOrder() + { + var clause = SqlServerScripter.BuildIndexWithClause(new SqlServerScripter.IndexScriptingOptions( + Compression: "PAGE", + PartitionColumn: null, + StatisticsIncremental: true, + PadIndex: true, + FillFactor: 80, + IgnoreDupKey: true, + AllowRowLocks: false, + AllowPageLocks: false)); + + Assert.Equal( + " WITH (PAD_INDEX = ON, FILLFACTOR = 80, IGNORE_DUP_KEY = ON, STATISTICS_INCREMENTAL=ON, DATA_COMPRESSION = PAGE, ALLOW_ROW_LOCKS = OFF, ALLOW_PAGE_LOCKS = OFF)", + clause); + } + [Fact] public void BuildIndexOnClause_EmitsPartitionColumn_WhenPresent() { diff --git a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs index a198fd9..794e638 100644 --- a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs +++ b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs @@ -245,6 +245,36 @@ public void ScriptTable_EmitsUserCreatedStatistics_WhenPresent() } } + [Fact] + public void ScriptTable_EmitsPersistedIndexOptions_WhenConfigured() + { + var server = Environment.GetEnvironmentVariable("SQLCT_TEST_SERVER"); + if (string.IsNullOrWhiteSpace(server)) + { + return; + } + + var databaseName = $"SqlctIndexOptions_{Guid.NewGuid():N}"; + try + { + var expectedLines = CreateIndexOptionsFixtureDatabase(server, databaseName); + var options = new SqlConnectionOptions(server, databaseName, "integrated", null, null, true); + + var scripter = new SqlServerScripter(); + var script = scripter.ScriptObject(options, new DbObjectInfo("dbo", "SampleStorage", "Table")); + + var primaryKeyLine = FindScriptLineContainingName(script, "PK_SampleStorage"); + var indexLine = FindScriptLineContainingName(script, "IX_SampleStorage_ItemCode"); + + Assert.Equal(expectedLines.PrimaryKeyLine, primaryKeyLine); + Assert.Equal(expectedLines.IndexLine, indexLine); + } + finally + { + DropDatabase(server, databaseName); + } + } + [Fact] public void ScriptView_EmitsIndexedViewIndex_ForAdventureWorksView() { @@ -1098,6 +1128,45 @@ INSERT INTO [dbo].[SampleTable] ([KeyAlpha], [KeyBeta], [KeyGamma], [StatusFlag] return expectedStatisticsLine; } + private static (string PrimaryKeyLine, string IndexLine) CreateIndexOptionsFixtureDatabase(string server, string databaseName) + { + using var connection = SqlConnectionFactory.Create(new SqlConnectionOptions(server, "master", "integrated", null, null, true)); + connection.Open(); + + using (var createDatabase = connection.CreateCommand()) + { + createDatabase.CommandText = $"CREATE DATABASE [{databaseName}];"; + createDatabase.ExecuteNonQuery(); + } + + using var fixtureConnection = SqlConnectionFactory.Create(new SqlConnectionOptions(server, databaseName, "integrated", null, null, true)); + fixtureConnection.Open(); + + var setupStatements = new[] + { + """ +CREATE TABLE [dbo].[SampleStorage] ( + [SampleStorageId] [int] NOT NULL, + [ItemCode] [int] NOT NULL, + [ItemName] [nvarchar](50) NULL +); +""", + "ALTER TABLE [dbo].[SampleStorage] ADD CONSTRAINT [PK_SampleStorage] PRIMARY KEY CLUSTERED ([SampleStorageId]) WITH (FILLFACTOR = 90);", + "CREATE UNIQUE NONCLUSTERED INDEX [IX_SampleStorage_ItemCode] ON [dbo].[SampleStorage] ([ItemCode]) WITH (PAD_INDEX = ON, FILLFACTOR = 80, IGNORE_DUP_KEY = ON, ALLOW_ROW_LOCKS = OFF, ALLOW_PAGE_LOCKS = OFF);" + }; + + foreach (var statement in setupStatements) + { + using var command = fixtureConnection.CreateCommand(); + command.CommandText = statement; + command.ExecuteNonQuery(); + } + + return ( + "ALTER TABLE [dbo].[SampleStorage] ADD CONSTRAINT [PK_SampleStorage] PRIMARY KEY CLUSTERED ([SampleStorageId]) WITH (FILLFACTOR = 90) ON [PRIMARY]", + "CREATE UNIQUE NONCLUSTERED INDEX [IX_SampleStorage_ItemCode] ON [dbo].[SampleStorage] ([ItemCode]) WITH (PAD_INDEX = ON, FILLFACTOR = 80, IGNORE_DUP_KEY = ON, ALLOW_ROW_LOCKS = OFF, ALLOW_PAGE_LOCKS = OFF) ON [PRIMARY]"); + } + private static void DropDatabase(string? server, string databaseName) { if (string.IsNullOrWhiteSpace(server)) diff --git a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs index cdb595f..198edb3 100644 --- a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs +++ b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs @@ -1485,6 +1485,33 @@ public void BuildUnifiedDiff_Table_SuppressesRedundantSemicolonOnlyGoBatchDiffer Assert.Empty(diff); } + [Fact] + public void BuildUnifiedDiff_Table_SuppressesPureBlankLineDifferences() + { + var source = + "CREATE TABLE [Accounting].[RateCache]\n" + + "(\n" + + "[RateId] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "EXEC sp_addextendedproperty 'MS_Description', N'Rate identifier', 'SCHEMA', 'Accounting', 'TABLE', 'RateCache', 'COLUMN', 'RateId'\n" + + "GO"; + var target = + "CREATE TABLE [Accounting].[RateCache]\n" + + "(\n" + + "[RateId] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "\n" + + "EXEC sp_addextendedproperty 'MS_Description', N'Rate identifier', 'SCHEMA', 'Accounting', 'TABLE', 'RateCache', 'COLUMN', 'RateId'\n" + + "GO\n" + + "\n"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Empty(diff); + } + [Fact] public void BuildUnifiedDiff_View_SuppressesEquivalentExtendedPropertyNamedArgumentDifferences() { @@ -1555,6 +1582,39 @@ public void BuildUnifiedDiff_Table_SuppressesEquivalentPostCreatePackageOrderDif Assert.Empty(diff); } + [Fact] + public void BuildUnifiedDiff_Table_SuppressesEquivalentLegacyTableFormattingDifferences() + { + var source = + "CREATE TABLE [stg].[SampleImport]\n" + + "(\n" + + "[RowId] [int] NOT NULL,\n" + + "[LoadFlag] [bit] NOT NULL CONSTRAINT [DF_SampleImport_LoadFlag] DEFAULT ((1)),\n" + + "[BatchCode] [varchar] (100) NULL\n" + + ") ON [PRIMARY]\n" + + "GO\n" + + "ALTER TABLE [stg].[SampleImport] ADD CONSTRAINT [PK_SampleImport] PRIMARY KEY CLUSTERED ([RowId]) WITH (FILLFACTOR = 90) ON [PRIMARY]\n" + + "GO\n" + + "ALTER TABLE [stg].[SampleImport] SET ( LOCK_ESCALATION = AUTO )\n" + + "GO"; + var target = + "CREATE TABLE stg.SampleImport(\n" + + " RowId INT NOT NULL,\n" + + " LoadFlag BIT NOT NULL CONSTRAINT DF_SampleImport_LoadFlag DEFAULT(1),\n" + + " BatchCode VARCHAR(100) NULL\n" + + ") ON PRIMARY;\n" + + "GO\n" + + "ALTER TABLE stg.SampleImport ADD CONSTRAINT PK_SampleImport PRIMARY KEY CLUSTERED (RowId) WITH (FILLFACTOR=90) ON PRIMARY;\n" + + "GO\n" + + "\n" + + "ALTER TABLE stg.SampleImport SET ( LOCK_ESCALATION = AUTO )\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Empty(diff); + } + [Fact] public void BuildUnifiedDiff_Table_PreservesPostCreatePackageContentDifferencesWhenOrderAlsoDiffers() { @@ -1595,8 +1655,36 @@ public void BuildUnifiedDiff_Table_PreservesPostCreatePackageContentDifferencesW var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); - Assert.Contains("ADD CONSTRAINT [PK_ExternalDef] PRIMARY KEY CLUSTERED ([ExternalId]) ON [PRIMARY]", diff); - Assert.Contains("ADD CONSTRAINT [PK_ExternalDef] PRIMARY KEY CLUSTERED ([ExternalId]) WITH (DATA_COMPRESSION = PAGE) ON [PRIMARY]", diff); + Assert.Contains("add constraint pk_externaldef primary key clustered(externalid) on primary", diff, StringComparison.OrdinalIgnoreCase); + Assert.Contains("add constraint pk_externaldef primary key clustered(externalid) with(data_compression=page) on primary", diff, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildUnifiedDiff_StoredProcedure_SuppressesLeadingSsmsHeaderComment() + { + var source = + "SET ANSI_NULLS ON\n" + + "GO\n" + + "SET QUOTED_IDENTIFIER ON\n" + + "GO\n" + + "CREATE PROCEDURE [stg].[LoadLookup]\n" + + "AS\n" + + "SELECT 1\n" + + "GO"; + var target = + "/****** Object: StoredProcedure [stg].[LoadLookup] Script Date: 2017-08-10 00:32:47 ******/\n" + + "SET ANSI_NULLS ON\n" + + "GO\n" + + "SET QUOTED_IDENTIFIER ON\n" + + "GO\n" + + "CREATE PROCEDURE [stg].[LoadLookup]\n" + + "AS\n" + + "SELECT 1\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("StoredProcedure", "db", "folder", source, target); + + Assert.Empty(diff); } [Fact] From b64bea0f8cc499732e44b6e3bf1315123fd32aa3 Mon Sep 17 00:00:00 2001 From: zacateras Date: Fri, 10 Apr 2026 13:01:22 +0200 Subject: [PATCH 2/7] feat: enhance compatibility handling for legacy UserDefinedType formatting during comparison --- CHANGELOG.md | 1 + specs/01-cli.md | 2 ++ specs/04-scripting.md | 1 + .../Sync/SyncCommandService.cs | 22 ++++++++++++++++ .../Sync/SyncCommandServiceTests.cs | 25 +++++++++++++++++++ 5 files changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b5e06..cccbefa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Treat equivalent `TableData` scripts as compatible during comparison when the normalized `INSERT` statements differ only by row ordering within the same contiguous data block. - Treat equivalent `Table` scripts as compatible during comparison when post-create statement packages differ only by ordering after the base `CREATE TABLE` block. - Treat equivalent legacy `Table` statement formatting as compatible during comparison when normalized table definitions, post-create table statements, and persisted option values are otherwise identical. +- Treat equivalent legacy `UserDefinedType` `CREATE TYPE` formatting as compatible during comparison when the normalized type definition is otherwise identical. - Treat omitted `TEXTIMAGE_ON` on `Table` scripts as compatible during comparison only when DB metadata shows the table LOB data space matches the current default data space. - Treat equivalent extended-property blocks as compatible during comparison when the normalized `sp_addextendedproperty` statements differ only by ordering, argument spacing, or named-vs-positional argument forms within the same contiguous block. - Treat leading SSMS-generated banner comments on programmable objects as compatible during comparison. diff --git a/specs/01-cli.md b/specs/01-cli.md index 4bc4ced..b786135 100644 --- a/specs/01-cli.md +++ b/specs/01-cli.md @@ -207,6 +207,7 @@ Behavior: - Equivalent `TableData` `INSERT` statement ordering within the same contiguous data block compares as compatible when the inserted row set is otherwise identical. - Equivalent `Table` post-create statement package ordering compares as compatible when the normalized package set after the base `CREATE TABLE` block is otherwise identical. - Equivalent legacy `Table` statement formatting for `CREATE TABLE`, `ALTER TABLE`, and `CREATE ... INDEX` statements compares as compatible when normalized identifiers, type tokens, default expressions, semicolons, and persisted option values are otherwise identical. +- Equivalent legacy `UserDefinedType` `CREATE TYPE` statement formatting compares as compatible when normalized identifiers, type tokens, default expressions, semicolons, and inline table-valued type bodies are otherwise identical. - For `Table`, omitted `TEXTIMAGE_ON [name]` compares as compatible with an explicit clause only when DB metadata shows that the table LOB data space equals the current default data space represented by `[name]`. - Equivalent extended-property statement ordering within the same contiguous extended-property block compares as compatible when the normalized property statement set is otherwise identical. Equivalent named-vs-positional `sp_addextendedproperty` argument forms, including omitted trailing `NULL` levels, compare as compatible. - Equivalent `Queue` option spacing, line wrapping, explicit default `ON [PRIMARY]`, and disabled default activation compare as compatible. @@ -239,6 +240,7 @@ Behavior: - Equivalent `TableData` `INSERT` statement ordering within the same contiguous data block compares as compatible when the inserted row set is otherwise identical. - Equivalent `Table` post-create statement package ordering compares as compatible when the normalized package set after the base `CREATE TABLE` block is otherwise identical. - Equivalent legacy `Table` statement formatting for `CREATE TABLE`, `ALTER TABLE`, and `CREATE ... INDEX` statements compares as compatible when normalized identifiers, type tokens, default expressions, semicolons, and persisted option values are otherwise identical. +- Equivalent legacy `UserDefinedType` `CREATE TYPE` statement formatting compares as compatible when normalized identifiers, type tokens, default expressions, semicolons, and inline table-valued type bodies are otherwise identical. - For `Table`, omitted `TEXTIMAGE_ON [name]` compares as compatible with an explicit clause only when DB metadata shows that the table LOB data space equals the current default data space represented by `[name]`. - Equivalent extended-property statement ordering within the same contiguous extended-property block compares as compatible when the normalized property statement set is otherwise identical. Equivalent named-vs-positional `sp_addextendedproperty` argument forms, including omitted trailing `NULL` levels, compare as compatible. - Equivalent `Queue` option spacing, line wrapping, explicit default `ON [PRIMARY]`, and disabled default activation compare as compatible. diff --git a/specs/04-scripting.md b/specs/04-scripting.md index 36c2510..5509b01 100644 --- a/specs/04-scripting.md +++ b/specs/04-scripting.md @@ -820,6 +820,7 @@ When compatibility reference files are available, `sqlct` MAY apply reconciliati - For `TableData`, comparison normalization MAY treat reordered `INSERT ... VALUES (...)` statements within the same contiguous data block as compatible when the normalized inserted-row set is otherwise identical. - For `Table`, comparison normalization MAY treat reordered post-create statement packages as compatible when the normalized package set after the base table `CREATE` block is otherwise identical. Table-scoped trigger packages MUST include the trigger body together with any immediately preceding programmable-object `SET` blocks. - For `Table`, comparison normalization MAY treat equivalent legacy formatting for `CREATE TABLE`, `ALTER TABLE`, and `CREATE ... INDEX` statement blocks as compatible when normalized identifiers, type tokens, default expressions, semicolons, and persisted option values are otherwise identical. +- For `UserDefinedType`, comparison normalization MAY treat equivalent legacy `CREATE TYPE` statement formatting as compatible when normalized identifiers, type tokens, default expressions, semicolons, and inline table-valued type bodies are otherwise identical. - For `Table`, comparison normalization MAY treat omitted `TEXTIMAGE_ON [name]` as compatible with an explicit clause only when DB metadata shows that the table `lob_data_space_id` resolves to the current default data space named `[name]`; otherwise omission remains a semantic difference. - For extended-property blocks, comparison normalization MAY treat reordered `EXEC sp_addextendedproperty ...` statements as compatible within the same contiguous extended-property block when the normalized property statement set is otherwise identical, MAY ignore equivalent spacing around commas and arguments in those statements, and MAY treat equivalent named-vs-positional argument forms with omitted trailing `NULL` levels as compatible. - For programmable `StoredProcedure`, `View`, `Function`, and `Trigger` scripts, comparison normalization MAY ignore leading SSMS-generated `/*** Object: ... Script Date: ... ***/` banner comments. diff --git a/src/SqlChangeTracker/Sync/SyncCommandService.cs b/src/SqlChangeTracker/Sync/SyncCommandService.cs index 1c3bcc7..04ae847 100644 --- a/src/SqlChangeTracker/Sync/SyncCommandService.cs +++ b/src/SqlChangeTracker/Sync/SyncCommandService.cs @@ -2110,6 +2110,10 @@ internal static string NormalizeForComparison( compatibleOmittedTextImageOnDataSpaceName); joined = NormalizeTableScriptForComparison(joined); } + else if (string.Equals(objectType, "UserDefinedType", StringComparison.OrdinalIgnoreCase)) + { + joined = NormalizeUserDefinedTypeScriptForComparison(joined); + } if (!joined.Contains("INSERT ", StringComparison.OrdinalIgnoreCase)) { @@ -2693,6 +2697,24 @@ private static string NormalizeTablePostCreatePackageForComparison(string packag return NormalizeTableBlockForComparison(blocks[0]); } + private static string NormalizeUserDefinedTypeScriptForComparison(string script) + { + var blocks = SplitGoDelimitedBlocks(script); + for (var i = 0; i < blocks.Count; i++) + { + var firstLine = GetFirstMeaningfulLine(blocks[i]); + if (firstLine is null || + !firstLine.StartsWith("CREATE TYPE", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + blocks[i] = [NormalizeLegacyTableStatementBlockForComparison(blocks[i])]; + } + + return string.Join("\n", blocks.SelectMany(block => block)); + } + private static string NormalizeLegacyTableStatementBlockForComparison(IEnumerable block) { var statement = string.Join( diff --git a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs index 198edb3..8b35fdb 100644 --- a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs +++ b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs @@ -1615,6 +1615,31 @@ public void BuildUnifiedDiff_Table_SuppressesEquivalentLegacyTableFormattingDiff Assert.Empty(diff); } + [Fact] + public void BuildUnifiedDiff_UserDefinedType_SuppressesEquivalentLegacyTableValuedTypeFormattingDifferences() + { + var source = + "CREATE TYPE [Accounting].[RateWindow] AS TABLE\n" + + "(\n" + + "[EffectiveDate] [date] NOT NULL,\n" + + "[RateValue] [decimal] (15, 8) NOT NULL,\n" + + "[YearFraction] [decimal] (15, 12) NOT NULL\n" + + ")\n" + + "GO"; + var target = + "CREATE TYPE Accounting.RateWindow AS TABLE\n" + + "(\n" + + " [EffectiveDate] DATE NOT NULL,\n" + + " RateValue DECIMAL(15,8) NOT NULL,\n" + + " YearFraction Decimal(15,12) NOT NULL\n" + + ");\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("UserDefinedType", "db", "folder", source, target); + + Assert.Empty(diff); + } + [Fact] public void BuildUnifiedDiff_Table_PreservesPostCreatePackageContentDifferencesWhenOrderAlsoDiffers() { From adb30a75cee47c80354a266dc5751600457a9bde Mon Sep 17 00:00:00 2001 From: zacateras Date: Fri, 10 Apr 2026 14:02:18 +0200 Subject: [PATCH 3/7] feat(diff): add --normalized-diff option to DiffCommand - Introduced a new command line option `--normalized-diff` in `DiffCommandSettings` to control the normalization of diff output. - Updated `RunDiff` method in `ISyncCommandService` and its implementation to accept a new parameter for normalized diff. - Modified the `SyncCommandService` to handle normalized diff logic in the diff generation process. - Enhanced the `BuildUnifiedDiff` method to support normalized diff rendering. - Updated tests to verify the behavior of the new normalized diff feature. --- CHANGELOG.md | 4 + README.md | 2 +- specs/01-cli.md | 5 +- specs/04-scripting.md | 2 + src/SqlChangeTracker/Commands/DiffCommand.cs | 2 +- .../Commands/GlobalSettings.cs | 3 + src/SqlChangeTracker/PACKAGE_README.md | 2 +- .../Sync/SyncCommandService.cs | 910 +++++++++++++++++- .../Commands/StatusDiffPullCommandTests.cs | 38 +- .../Sync/SyncCommandServiceTests.cs | 82 +- 10 files changed, 989 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cccbefa..210acaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Treat equivalent extended-property blocks as compatible during comparison when the normalized `sp_addextendedproperty` statements differ only by ordering, argument spacing, or named-vs-positional argument forms within the same contiguous block. - Treat leading SSMS-generated banner comments on programmable objects as compatible during comparison. - Treat redundant empty or otherwise no-op `GO` batches as compatible during comparison. +- Keep `diff` output readable by rendering compatible `Table` and `UserDefinedType` changes from readable script text instead of opaque comparison-normalized text. +- Keep readable `diff` output for `Table` and table-valued `UserDefinedType` bodies at per-entry granularity instead of collapsing the entire body into one changed line. +- Align readable `Table` and table-valued `UserDefinedType` diffs by individual body entries so a single changed column or inline constraint does not mark the entire body as changed. - 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. - 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). - 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. @@ -42,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Next-steps suggestions are printed after `sqlct init` to guide users toward `pull`, `status`, and `diff` (#36). - Add `--object ` to `sqlct pull` for exact-match filtering using the same selector forms as `diff --object` (#35). - Add `--filter ` to `sqlct pull` for regex-based filtering; multiple patterns may be provided and matching is case-insensitive (#35). +- Add `--normalized-diff` to `sqlct diff` to render comparison-normalized diff text for debugging while preserving readable diff output by default. - Add `--filter ` to `sqlct diff` for regex-based filtering; without `--object` filters the output to matching objects, with `--object` additionally constrains the single-object result (#35). - SQL Authentication support: set `database.auth` to `"sql"` and supply `database.user` (and optionally `database.password`) in `sqlct.config.json` to connect using SQL Server Authentication (#30). - Support active object type `Assembly`, with deterministic scripting to `Assemblies/*.sql` for user-defined SQL Server assemblies. diff --git a/README.md b/README.md index b823b6e..1370f7e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ validate configuration, and generate reproducible scripts for Git and CI/CD. - `sqlct data untrack [] [--object ] [--filter ] [--project-dir ]` - `sqlct data list [--project-dir ]` - `sqlct status [--project-dir ] [--target ]` -- `sqlct diff [--project-dir ] [--target ] [--object ] [--filter ...] [--context ]` +- `sqlct diff [--project-dir ] [--target ] [--object ] [--filter ...] [--context ] [--normalized-diff]` - `sqlct pull [--project-dir ] [--object ] [--filter ...]` Current runtime scope for `status`, `diff`, and `pull` covers: diff --git a/specs/01-cli.md b/specs/01-cli.md index b786135..345beec 100644 --- a/specs/01-cli.md +++ b/specs/01-cli.md @@ -224,7 +224,7 @@ Behavior: ### diff Show textual diffs. ` -sqlct diff [--project-dir ] [--target ] [--object ] [--filter ...] [--context ] +sqlct diff [--project-dir ] [--target ] [--object ] [--filter ...] [--context ] [--normalized-diff] ` Behavior: - Compare object script from DB vs folder. @@ -234,6 +234,8 @@ Behavior: - Changed objects use DB-vs-folder unified diff. - Added/deleted objects use empty-side vs script-side unified diff. - Normalization includes line-ending/trailing-newline stability plus explicitly listed compatibility rules for deterministic comparison. +- Displayed diff hunks SHOULD preserve readable script text where possible and MUST NOT fall back to opaque comparison-normalized formatting when a readable compatibility-preserving representation is available. +- For `Table` and table-valued `UserDefinedType` scripts, readable diff rendering SHOULD preserve structural body boundaries so column and inline-constraint changes remain pinpointed within the body instead of collapsing the entire statement into one changed line. - Empty lines are ignored during comparison, and whitespace-only lines are normalized to empty lines first so blank separators differing only by spaces or tabs compare as compatible. - Redundant empty or no-op `GO` batches compare as compatible. - Trailing semicolons on `INSERT` statement lines are stripped during normalization; scripts emitted with and without statement terminators compare as compatible. @@ -249,6 +251,7 @@ Behavior: - Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Trigger`) compare as compatible. - Diff output uses a chunked format: only changed lines and their surrounding context are shown, not the entire file. - `--context ` controls the number of unchanged context lines shown before and after each changed segment (default: 3). Negative values are treated as 0. +- `--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. - When two change segments are close enough that their context regions overlap, they are merged into a single hunk. - Each hunk is prefixed with a `@@ -l,s +l,s @@` header indicating the source and target line ranges. - When `data.trackedTables` is configured, `diff` also supports data-script diffs for tracked tables. diff --git a/specs/04-scripting.md b/specs/04-scripting.md index 5509b01..af257e2 100644 --- a/specs/04-scripting.md +++ b/specs/04-scripting.md @@ -812,6 +812,8 @@ When compatibility reference files are available, `sqlct` MAY apply reconciliati - Script generation MUST emit canonical scripting output per this document and MUST NOT include diff/status-specific normalization. - `status` and `diff` normalization behaviors are external contracts defined in `specs/01-cli.md` and `specs/05-output-formats.md`. - Scripting and comparison normalization responsibilities MUST remain decoupled. +- Diff rendering MAY use comparison-normalized keys to identify compatible changes, but emitted diff text SHOULD remain human-readable and SHOULD prefer readable compatibility-preserving representations over opaque comparison-normalized forms. +- For `Table` and table-valued `UserDefinedType` statements, readable diff rendering SHOULD retain structural body boundaries so body entries can diff at per-entry granularity when the compatibility-preserving representation exposes them. - Empty lines MUST be ignored during comparison, and whitespace-only lines MUST be normalized to empty lines first so that blank separators differing only by spaces or tabs compare as compatible. - Comparison normalization MAY ignore redundant empty or otherwise no-op `GO` batches, including batches that contain only standalone semicolon lines. - Trailing semicolons on `INSERT` statement lines MUST be stripped by comparison normalization so that scripts emitted with and without statement terminators compare as compatible. diff --git a/src/SqlChangeTracker/Commands/DiffCommand.cs b/src/SqlChangeTracker/Commands/DiffCommand.cs index ea443e9..a9c987d 100644 --- a/src/SqlChangeTracker/Commands/DiffCommand.cs +++ b/src/SqlChangeTracker/Commands/DiffCommand.cs @@ -16,7 +16,7 @@ public override int Execute(CommandContext context, DiffCommandSettings settings var output = new OutputFormatter(settings.Json); var showProgress = !settings.Json && !settings.NoProgress; var result = ProgressRunner.Run("Running diff...", showProgress, - progress => SyncService.RunDiff(settings.ProjectDir, settings.Target, settings.ObjectSelector, settings.FilterPatterns, settings.ContextLines ?? 3, progress)); + progress => SyncService.RunDiff(settings.ProjectDir, settings.Target, settings.ObjectSelector, settings.FilterPatterns, settings.ContextLines ?? 3, settings.ShowNormalizedDiff, progress)); if (!result.Success) { output.WriteError(new ErrorResult("diff", result.Error!)); diff --git a/src/SqlChangeTracker/Commands/GlobalSettings.cs b/src/SqlChangeTracker/Commands/GlobalSettings.cs index 7b7b536..de825b4 100644 --- a/src/SqlChangeTracker/Commands/GlobalSettings.cs +++ b/src/SqlChangeTracker/Commands/GlobalSettings.cs @@ -44,6 +44,9 @@ internal sealed class DiffCommandSettings : StatusCommandSettings [CommandOption("--context ")] public int? ContextLines { get; set; } + + [CommandOption("--normalized-diff")] + public bool ShowNormalizedDiff { get; set; } } internal sealed class PullCommandSettings : ProjectCommandSettings diff --git a/src/SqlChangeTracker/PACKAGE_README.md b/src/SqlChangeTracker/PACKAGE_README.md index f339373..f754584 100644 --- a/src/SqlChangeTracker/PACKAGE_README.md +++ b/src/SqlChangeTracker/PACKAGE_README.md @@ -23,7 +23,7 @@ sqlct data track [] [--object ] [--filter ] [--project- sqlct data untrack [] [--object ] [--filter ] [--project-dir ] sqlct data list [--project-dir ] sqlct status [--project-dir ] [--target ] [--no-progress] -sqlct diff [--project-dir ] [--target ] [--object ] [--filter ...] [--context ] [--no-progress] +sqlct diff [--project-dir ] [--target ] [--object ] [--filter ...] [--context ] [--normalized-diff] [--no-progress] sqlct pull [--project-dir ] [--object ] [--filter ...] [--no-progress] ``` diff --git a/src/SqlChangeTracker/Sync/SyncCommandService.cs b/src/SqlChangeTracker/Sync/SyncCommandService.cs index 04ae847..0c26fa0 100644 --- a/src/SqlChangeTracker/Sync/SyncCommandService.cs +++ b/src/SqlChangeTracker/Sync/SyncCommandService.cs @@ -12,7 +12,7 @@ internal interface ISyncCommandService { CommandExecutionResult RunStatus(string? projectDir, string? target, Action? progress = null); - CommandExecutionResult RunDiff(string? projectDir, string? target, string? objectSelector, string[]? filterPatterns = null, int contextLines = 3, Action? progress = null); + CommandExecutionResult RunDiff(string? projectDir, string? target, string? objectSelector, string[]? filterPatterns = null, int contextLines = 3, bool normalizedDiff = false, Action? progress = null); CommandExecutionResult RunPull(string? projectDir, string? objectSelector = null, string[]? filterPatterns = null, Action? progress = null); } @@ -140,7 +140,7 @@ public CommandExecutionResult RunStatus(string? projectDir, string return CommandExecutionResult.Ok(status, exitCode); } - public CommandExecutionResult RunDiff(string? projectDir, string? target, string? objectSelector, string[]? filterPatterns = null, int contextLines = 3, Action? progress = null) + public CommandExecutionResult RunDiff(string? projectDir, string? target, string? objectSelector, string[]? filterPatterns = null, int contextLines = 3, bool normalizedDiff = false, Action? progress = null) { if (!TryParseTarget(target, out var comparisonTarget)) { @@ -216,7 +216,7 @@ public CommandExecutionResult RunDiff(string? projectDir, string? ta } var entry = BuildChangeEntry(selectedSnapshotResult.Payload!, comparisonTarget, selected.Payload!); - var diff = BuildDiffText(entry, sourceLabel, targetLabel, contextLines); + var diff = BuildDiffText(entry, sourceLabel, targetLabel, contextLines, normalizedDiff); var result = new DiffResult( "diff", @@ -242,7 +242,7 @@ public CommandExecutionResult RunDiff(string? projectDir, string? ta : changes.Where(change => MatchesObjectPatterns(change.Object.DisplayName, compiledPatterns)).ToArray(); var diffSections = filteredChanges - .Select(change => BuildDiffSection(change, sourceLabel, targetLabel, contextLines)) + .Select(change => BuildDiffSection(change, sourceLabel, targetLabel, contextLines, normalizedDiff)) .Where(section => !string.IsNullOrWhiteSpace(section)) .ToArray(); @@ -1431,9 +1431,9 @@ private static ChangeEntry BuildChangeEntry(ComparisonSnapshot snapshot, Compari return new ChangeEntry(sourceObject, sourceObject, targetObject, "changed"); } - private static string BuildDiffSection(ChangeEntry entry, string sourceLabel, string targetLabel, int contextLines) + private static string BuildDiffSection(ChangeEntry entry, string sourceLabel, string targetLabel, int contextLines, bool normalizedDiff) { - var diff = BuildDiffText(entry, sourceLabel, targetLabel, contextLines); + var diff = BuildDiffText(entry, sourceLabel, targetLabel, contextLines, normalizedDiff); if (string.IsNullOrWhiteSpace(diff)) { return string.Empty; @@ -1442,7 +1442,7 @@ private static string BuildDiffSection(ChangeEntry entry, string sourceLabel, st return $"Object: {entry.Object.SelectorDisplayName} ({entry.Object.ObjectType}){Environment.NewLine}{diff}"; } - private static string BuildDiffText(ChangeEntry entry, string sourceLabel, string targetLabel, int contextLines) + private static string BuildDiffText(ChangeEntry entry, string sourceLabel, string targetLabel, int contextLines, bool normalizedDiff) { var sourceScript = entry.SourceObject?.Script ?? string.Empty; var targetScript = entry.TargetObject?.Script ?? string.Empty; @@ -1452,21 +1452,22 @@ private static string BuildDiffText(ChangeEntry entry, string sourceLabel, strin return string.Empty; } - return BuildUnifiedDiff(entry.SourceObject, entry.TargetObject, sourceLabel, targetLabel, contextLines); + return BuildUnifiedDiff(entry.SourceObject, entry.TargetObject, sourceLabel, targetLabel, contextLines, normalizedDiff); } - internal static string BuildUnifiedDiff(string sourceLabel, string targetLabel, string sourceScript, string targetScript, int contextLines = 3) - => BuildUnifiedDiff(null, sourceLabel, targetLabel, sourceScript, targetScript, contextLines); + internal static string BuildUnifiedDiff(string sourceLabel, string targetLabel, string sourceScript, string targetScript, int contextLines = 3, bool normalizedDiff = false) + => BuildUnifiedDiff(null, sourceLabel, targetLabel, sourceScript, targetScript, contextLines, normalizedDiff); - internal static string BuildUnifiedDiff(string? objectType, string sourceLabel, string targetLabel, string sourceScript, string targetScript, int contextLines = 3) - => BuildUnifiedDiffCore(objectType, null, sourceLabel, targetLabel, sourceScript, targetScript, contextLines); + internal static string BuildUnifiedDiff(string? objectType, string sourceLabel, string targetLabel, string sourceScript, string targetScript, int contextLines = 3, bool normalizedDiff = false) + => BuildUnifiedDiffCore(objectType, null, sourceLabel, targetLabel, sourceScript, targetScript, contextLines, normalizedDiff); private static string BuildUnifiedDiff( InternalObject? sourceObject, InternalObject? targetObject, string sourceLabel, string targetLabel, - int contextLines = 3) + int contextLines = 3, + bool normalizedDiff = false) { var objectType = sourceObject?.ObjectType ?? targetObject?.ObjectType; var sourceScript = sourceObject?.Script ?? string.Empty; @@ -1481,7 +1482,8 @@ private static string BuildUnifiedDiff( targetLabel, sourceScript, targetScript, - contextLines); + contextLines, + normalizedDiff); } private static string BuildUnifiedDiffCore( @@ -1491,23 +1493,26 @@ private static string BuildUnifiedDiffCore( string targetLabel, string sourceScript, string targetScript, - int contextLines = 3) + int contextLines = 3, + bool normalizedDiff = false) { - var normalizedSource = NormalizeForComparison(sourceScript, objectType, compatibleOmittedTextImageOnDataSpaceName); - var normalizedTarget = NormalizeForComparison(targetScript, objectType, compatibleOmittedTextImageOnDataSpaceName); - if (string.Equals(normalizedSource, normalizedTarget, StringComparison.Ordinal)) + var comparableSourceLines = BuildComparableLinesForDiff( + sourceScript, + objectType, + compatibleOmittedTextImageOnDataSpaceName, + normalizedDiff); + var comparableTargetLines = BuildComparableLinesForDiff( + targetScript, + objectType, + compatibleOmittedTextImageOnDataSpaceName, + normalizedDiff); + + if (ComparableLinesEqual(comparableSourceLines, comparableTargetLines)) { return string.Empty; } - var sourceLines = normalizedSource.Length == 0 - ? Array.Empty() - : normalizedSource.Split('\n'); - var targetLines = normalizedTarget.Length == 0 - ? Array.Empty() - : normalizedTarget.Split('\n'); - - var diffLines = ComputeDiffLines(sourceLines, targetLines); + var diffLines = ComputeDiffLines(comparableSourceLines, comparableTargetLines); var hunks = BuildDiffHunks(diffLines, Math.Max(0, contextLines)); if (hunks.Count == 0) @@ -1532,6 +1537,15 @@ private static string BuildUnifiedDiffCore( private enum DiffLineKind { Equal, Removed, Added } + private readonly record struct ComparableLine(string Key, string Display); + + private readonly record struct TableLikeBodyItem(string Text, bool HasTrailingComma); + + private sealed record TableLikeStatementParts( + string Prefix, + IReadOnlyList Items, + string Suffix); + private readonly record struct DiffEntry(DiffLineKind Kind, int SrcLine, int TgtLine, string Content); private sealed record DiffHunk(IReadOnlyList Lines, int SrcStart, int SrcCount, int TgtStart, int TgtCount); @@ -1576,6 +1590,443 @@ private static IReadOnlyList ComputeDiffLines(string[] source, string return result; } + private static IReadOnlyList ComputeDiffLines(ComparableLine[] source, ComparableLine[] target) + { + int m = source.Length, n = target.Length; + + var dp = new int[m + 1, n + 1]; + for (int i = m - 1; i >= 0; i--) + { + for (int j = n - 1; j >= 0; j--) + { + dp[i, j] = string.Equals(source[i].Key, target[j].Key, StringComparison.Ordinal) + ? 1 + dp[i + 1, j + 1] + : Math.Max(dp[i + 1, j], dp[i, j + 1]); + } + } + + var result = new List(m + n); + int si = 0, ti = 0, srcLine = 1, tgtLine = 1; + while (si < m || ti < n) + { + if (si < m && ti < n && string.Equals(source[si].Key, target[ti].Key, StringComparison.Ordinal)) + { + result.Add(new DiffEntry(DiffLineKind.Equal, srcLine++, tgtLine++, source[si].Display)); + si++; + ti++; + } + else if (si < m && (ti >= n || dp[si + 1, ti] >= dp[si, ti + 1])) + { + result.Add(new DiffEntry(DiffLineKind.Removed, srcLine++, 0, source[si].Display)); + si++; + } + else + { + result.Add(new DiffEntry(DiffLineKind.Added, 0, tgtLine++, target[ti].Display)); + ti++; + } + } + + return result; + } + + private static ComparableLine[] BuildComparableLinesForDiff( + string script, + string? objectType, + string? compatibleOmittedTextImageOnDataSpaceName, + bool normalizedDiff) + { + var keyScript = NormalizeForComparison(script, objectType, compatibleOmittedTextImageOnDataSpaceName); + var keyLines = keyScript.Length == 0 + ? Array.Empty() + : keyScript.Split('\n'); + + if (normalizedDiff || !NeedsReadableDiffDisplayNormalization(objectType)) + { + return keyLines.Select(line => new ComparableLine(line, line)).ToArray(); + } + + var structuredComparableLines = BuildStructuredComparableLinesForDiff( + script, + objectType, + compatibleOmittedTextImageOnDataSpaceName); + if (structuredComparableLines is not null) + { + return structuredComparableLines; + } + + var displayScript = NormalizeForDiffDisplay(script, objectType, compatibleOmittedTextImageOnDataSpaceName); + var displayLines = displayScript.Length == 0 + ? Array.Empty() + : displayScript.Split('\n'); + + if (keyLines.Length != displayLines.Length) + { + if (TryBuildGroupedComparableLines(keyLines, displayLines, out var groupedComparableLines)) + { + return groupedComparableLines; + } + + return keyLines.Select(line => new ComparableLine(line, line)).ToArray(); + } + + var comparableLines = new ComparableLine[keyLines.Length]; + for (var i = 0; i < keyLines.Length; i++) + { + comparableLines[i] = new ComparableLine(keyLines[i], displayLines[i]); + } + + return comparableLines; + } + + private static ComparableLine[]? BuildStructuredComparableLinesForDiff( + string script, + string? objectType, + string? compatibleOmittedTextImageOnDataSpaceName) + { + if (string.Equals(objectType, "Table", StringComparison.OrdinalIgnoreCase)) + { + var normalized = PrepareScriptForReadableDiffDisplay(script, objectType); + normalized = NormalizeCompatibleOmittedTextImageOnForComparison( + normalized, + compatibleOmittedTextImageOnDataSpaceName); + return BuildTableComparableLinesForDiff(normalized); + } + + if (string.Equals(objectType, "UserDefinedType", StringComparison.OrdinalIgnoreCase)) + { + var normalized = PrepareScriptForReadableDiffDisplay(script, objectType); + return BuildUserDefinedTypeComparableLinesForDiff(normalized); + } + + return null; + } + + private static string PrepareScriptForReadableDiffDisplay(string script, string? objectType) + { + var normalized = script + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\r", "\n", StringComparison.Ordinal) + .TrimEnd('\n'); + + var lines = normalized.Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + if (string.IsNullOrWhiteSpace(lines[i])) + { + lines[i] = string.Empty; + } + else if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType) && + SsmsObjectHeaderCommentRegex.IsMatch(lines[i])) + { + lines[i] = string.Empty; + } + } + + if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType)) + { + lines = TrimLeadingEmptyLinesForComparison(lines); + } + + lines = RemoveEmptyLinesForComparison(lines); + + var joined = string.Join("\n", lines); + joined = NormalizeEmptyGoBatchesForComparison(joined); + + if (joined.Contains("sp_addextendedproperty", StringComparison.OrdinalIgnoreCase)) + { + joined = NormalizeExtendedPropertyBlocksForComparison(joined); + } + + return joined; + } + + private static string[] BuildTableDisplayUnitsForDiff(string script) + { + var blocks = SplitGoDelimitedBlocks(script); + if (blocks.Count == 0) + { + return script.Length == 0 ? Array.Empty() : script.Split('\n'); + } + + var createTableIndex = blocks.FindIndex(BlockContainsCreateTable); + if (createTableIndex < 0) + { + return script.Split('\n'); + } + + var units = new List(); + foreach (var block in blocks.Take(createTableIndex)) + { + units.AddRange(GetDisplayUnitsForBlock(block)); + } + + units.AddRange(GetDisplayUnitsForTableLikeBlock(blocks[createTableIndex])); + + var postCreatePackages = BuildTablePostCreatePackages(blocks, createTableIndex + 1); + foreach (var package in postCreatePackages.OrderBy(NormalizeTablePostCreatePackageForComparison, StringComparer.Ordinal)) + { + units.AddRange(GetDisplayUnitsForTablePostCreatePackage(package)); + } + + return units.ToArray(); + } + + private static ComparableLine[]? BuildTableComparableLinesForDiff(string script) + { + var blocks = SplitGoDelimitedBlocks(script); + if (blocks.Count == 0) + { + return Array.Empty(); + } + + var createTableIndex = blocks.FindIndex(BlockContainsCreateTable); + if (createTableIndex < 0) + { + return null; + } + + var comparableLines = new List(); + foreach (var block in blocks.Take(createTableIndex)) + { + comparableLines.Add(BuildComparableLineForStructuredBlock( + NormalizeTableBlockForComparison(block), + NormalizeTableBlockForDiffDisplay(block))); + } + + if (!TryBuildComparableLinesForTableLikeBlock(blocks[createTableIndex], out var createTableComparableLines)) + { + return null; + } + + comparableLines.AddRange(createTableComparableLines); + + var postCreatePackages = BuildTablePostCreatePackages(blocks, createTableIndex + 1); + foreach (var package in postCreatePackages.OrderBy(NormalizeTablePostCreatePackageForComparison, StringComparer.Ordinal)) + { + comparableLines.Add(BuildComparableLineForStructuredBlock( + NormalizeTablePostCreatePackageForComparison(package), + NormalizeTablePostCreatePackageForDiffDisplay(package))); + } + + return comparableLines.ToArray(); + } + + private static string[] BuildUserDefinedTypeDisplayUnitsForDiff(string script) + { + var blocks = SplitGoDelimitedBlocks(script); + if (blocks.Count == 0) + { + return script.Length == 0 ? Array.Empty() : script.Split('\n'); + } + + var units = new List(); + foreach (var block in blocks) + { + var firstLine = GetFirstMeaningfulLine(block); + if (firstLine is not null && + firstLine.StartsWith("CREATE TYPE", StringComparison.OrdinalIgnoreCase)) + { + units.AddRange(GetDisplayUnitsForTableLikeBlock(block)); + } + else + { + units.AddRange(GetDisplayUnitsForBlock(block)); + } + } + + return units.ToArray(); + } + + private static ComparableLine[]? BuildUserDefinedTypeComparableLinesForDiff(string script) + { + var blocks = SplitGoDelimitedBlocks(script); + if (blocks.Count == 0) + { + return Array.Empty(); + } + + var comparableLines = new List(); + foreach (var block in blocks) + { + var firstLine = GetFirstMeaningfulLine(block); + if (!string.IsNullOrEmpty(firstLine) && + firstLine.StartsWith("CREATE TYPE", StringComparison.OrdinalIgnoreCase)) + { + if (!TryBuildComparableLinesForTableLikeBlock(block, out var createTypeComparableLines)) + { + return null; + } + + comparableLines.AddRange(createTypeComparableLines); + continue; + } + + comparableLines.Add(BuildComparableLineForStructuredBlock( + JoinBlockLines(block), + JoinBlockLines(block))); + } + + return comparableLines.ToArray(); + } + + private static IReadOnlyList GetDisplayUnitsForTablePostCreatePackage(string package) + { + var blocks = SplitGoDelimitedBlocks(package); + if (blocks.Count != 1) + { + return package.Split('\n'); + } + + var firstLine = GetFirstMeaningfulLine(blocks[0]); + if (!string.IsNullOrEmpty(firstLine) && + (firstLine.StartsWith("ALTER TABLE", StringComparison.OrdinalIgnoreCase) || + (firstLine.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase) && + firstLine.IndexOf(" INDEX ", StringComparison.OrdinalIgnoreCase) >= 0))) + { + return GetDisplayUnitsForTableLikeBlock(blocks[0]); + } + + return GetDisplayUnitsForBlock(blocks[0]); + } + + private static IReadOnlyList GetDisplayUnitsForTableLikeBlock(IEnumerable block) + => NormalizeLegacyTableStatementBlockForDiffDisplay(block).Split('\n'); + + private static IReadOnlyList GetDisplayUnitsForBlock(IEnumerable block) + => JoinBlockLines(block).Split('\n'); + + private static ComparableLine BuildComparableLineForStructuredBlock(string key, string display) + => new(key, display); + + private static bool TryBuildComparableLinesForTableLikeBlock( + IEnumerable block, + out IReadOnlyList comparableLines) + { + var statement = string.Join( + " ", + block.Where(line => !string.Equals(line.Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + .Select(line => line.Trim()) + .Where(line => line.Length > 0)); + + if (statement.Length == 0) + { + comparableLines = [new ComparableLine("GO", "GO")]; + return true; + } + + if (!TryParseTableLikeStatementForDiffDisplay(statement, out var parts)) + { + comparableLines = Array.Empty(); + return false; + } + + var lines = new List(parts.Items.Count + 4) + { + new ComparableLine( + NormalizeLegacyTableStatementTextForComparison(parts.Prefix), + parts.Prefix), + new ComparableLine("(", "(") + }; + + foreach (var item in parts.Items) + { + var display = item.HasTrailingComma + ? $" {item.Text}," + : $" {item.Text}"; + var key = NormalizeLegacyTableStatementTextForComparison( + item.HasTrailingComma ? item.Text + "," : item.Text); + lines.Add(new ComparableLine(key, display)); + } + + var closeDisplay = string.IsNullOrWhiteSpace(parts.Suffix) + ? ")" + : $") {parts.Suffix}"; + lines.Add(new ComparableLine( + NormalizeLegacyTableStatementTextForComparison(closeDisplay), + closeDisplay)); + lines.Add(new ComparableLine("GO", "GO")); + + comparableLines = lines; + return true; + } + + private static bool TryBuildGroupedComparableLines( + IReadOnlyList keyLines, + IReadOnlyList displayLines, + out ComparableLine[] comparableLines) + { + var grouped = new List(keyLines.Count); + var displayIndex = 0; + + for (var keyIndex = 0; keyIndex < keyLines.Count; keyIndex++) + { + var keyLine = keyLines[keyIndex]; + if (string.Equals(keyLine, "GO", StringComparison.OrdinalIgnoreCase)) + { + if (displayIndex >= displayLines.Count || + !string.Equals(displayLines[displayIndex].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + { + comparableLines = Array.Empty(); + return false; + } + + grouped.Add(new ComparableLine(keyLine, displayLines[displayIndex])); + displayIndex++; + continue; + } + + if (displayIndex >= displayLines.Count) + { + comparableLines = Array.Empty(); + return false; + } + + var statementLines = new List(); + while (displayIndex < displayLines.Count && + !string.Equals(displayLines[displayIndex].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + { + statementLines.Add(displayLines[displayIndex]); + displayIndex++; + } + + if (statementLines.Count == 0) + { + comparableLines = Array.Empty(); + return false; + } + + grouped.Add(new ComparableLine(keyLine, string.Join("\n", statementLines))); + } + + if (displayIndex != displayLines.Count) + { + comparableLines = Array.Empty(); + return false; + } + + comparableLines = grouped.ToArray(); + return true; + } + + private static bool ComparableLinesEqual(ComparableLine[] source, ComparableLine[] target) + { + if (source.Length != target.Length) + { + return false; + } + + for (var i = 0; i < source.Length; i++) + { + if (!string.Equals(source[i].Key, target[i].Key, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + private static IReadOnlyList BuildDiffHunks(IReadOnlyList diffLines, int contextLines) { var changeIndices = new List(); @@ -1623,19 +2074,19 @@ private static IReadOnlyList BuildDiffHunks(IReadOnlyList d switch (entry.Kind) { case DiffLineKind.Equal: - hunkLines.Add($" {entry.Content}"); + AppendDiffDisplayLines(hunkLines, " ", entry.Content); if (srcStart == 0) srcStart = entry.SrcLine; if (tgtStart == 0) tgtStart = entry.TgtLine; srcCount++; tgtCount++; break; case DiffLineKind.Removed: - hunkLines.Add($"-{entry.Content}"); + AppendDiffDisplayLines(hunkLines, "-", entry.Content); if (srcStart == 0) srcStart = entry.SrcLine; srcCount++; break; case DiffLineKind.Added: - hunkLines.Add($"+{entry.Content}"); + AppendDiffDisplayLines(hunkLines, "+", entry.Content); if (tgtStart == 0) tgtStart = entry.TgtLine; tgtCount++; break; @@ -1648,6 +2099,15 @@ private static IReadOnlyList BuildDiffHunks(IReadOnlyList d return hunks; } + private static void AppendDiffDisplayLines(List lines, string prefix, string content) + { + var displayLines = content.Split('\n'); + foreach (var line in displayLines) + { + lines.Add(prefix + line); + } + } + private static string BuildObjectKey(string objectType, string schema, string name) => $"{objectType}|{schema}|{name}"; @@ -2195,37 +2655,96 @@ private static string NormalizeCompatibleOmittedTextImageOnForComparison( continue; } - lines[i] = match.Groups["prefix"].Value + match.Groups["suffix"].Value; + lines[i] = match.Groups["prefix"].Value + match.Groups["suffix"].Value; + } + + return string.Join("\n", lines); + } + + private static bool IsIgnorableNoOpBatchLine(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return true; + } + + var trimmed = line.Trim(); + return trimmed.All(ch => ch == ';'); + } + + private static string[] TrimLeadingEmptyLinesForComparison(string[] lines) + { + var startIndex = 0; + while (startIndex < lines.Length && lines[startIndex].Length == 0) + { + startIndex++; + } + + return startIndex == 0 ? lines : lines[startIndex..]; + } + + private static string[] RemoveEmptyLinesForComparison(string[] lines) + => lines.Where(line => line.Length > 0).ToArray(); + + private static bool NeedsReadableDiffDisplayNormalization(string? objectType) + => string.Equals(objectType, "Table", StringComparison.OrdinalIgnoreCase) + || string.Equals(objectType, "UserDefinedType", StringComparison.OrdinalIgnoreCase); + + private static string NormalizeForDiffDisplay( + string script, + string? objectType, + string? compatibleOmittedTextImageOnDataSpaceName) + { + var normalized = script + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\r", "\n", StringComparison.Ordinal) + .TrimEnd('\n'); + + var lines = normalized.Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + if (string.IsNullOrWhiteSpace(lines[i])) + { + lines[i] = string.Empty; + } + else if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType) && + SsmsObjectHeaderCommentRegex.IsMatch(lines[i])) + { + lines[i] = string.Empty; + } + } + + if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType)) + { + lines = TrimLeadingEmptyLinesForComparison(lines); } - return string.Join("\n", lines); - } + lines = RemoveEmptyLinesForComparison(lines); - private static bool IsIgnorableNoOpBatchLine(string line) - { - if (string.IsNullOrWhiteSpace(line)) + var joined = string.Join("\n", lines); + joined = NormalizeEmptyGoBatchesForComparison(joined); + + if (joined.Contains("sp_addextendedproperty", StringComparison.OrdinalIgnoreCase)) { - return true; + joined = NormalizeExtendedPropertyBlocksForComparison(joined); } - var trimmed = line.Trim(); - return trimmed.All(ch => ch == ';'); - } + if (string.Equals(objectType, "Table", StringComparison.OrdinalIgnoreCase)) + { + joined = NormalizeCompatibleOmittedTextImageOnForComparison( + joined, + compatibleOmittedTextImageOnDataSpaceName); + return NormalizeTableScriptForDiffDisplay(joined); + } - private static string[] TrimLeadingEmptyLinesForComparison(string[] lines) - { - var startIndex = 0; - while (startIndex < lines.Length && lines[startIndex].Length == 0) + if (string.Equals(objectType, "UserDefinedType", StringComparison.OrdinalIgnoreCase)) { - startIndex++; + return NormalizeUserDefinedTypeScriptForDiffDisplay(joined); } - return startIndex == 0 ? lines : lines[startIndex..]; + return joined; } - private static string[] RemoveEmptyLinesForComparison(string[] lines) - => lines.Where(line => line.Length > 0).ToArray(); - private static string NormalizeQueueScriptForComparison(string script) { var normalized = Regex.Replace( @@ -2561,7 +3080,7 @@ private static string NormalizeTableScriptForComparison(string script) } var createTableIndex = blocks.FindIndex(BlockContainsCreateTable); - if (createTableIndex < 0 || createTableIndex >= blocks.Count - 1) + if (createTableIndex < 0) { return script; } @@ -2697,6 +3216,64 @@ private static string NormalizeTablePostCreatePackageForComparison(string packag return NormalizeTableBlockForComparison(blocks[0]); } + private static string NormalizeTableScriptForDiffDisplay(string script) + { + var blocks = SplitGoDelimitedBlocks(script); + if (blocks.Count == 0) + { + return script; + } + + var createTableIndex = blocks.FindIndex(BlockContainsCreateTable); + if (createTableIndex < 0) + { + return script; + } + + var normalizedBlocks = new List(blocks.Count); + normalizedBlocks.AddRange(blocks.Take(createTableIndex).Select(NormalizeTableBlockForDiffDisplay)); + normalizedBlocks.Add(NormalizeTableBlockForDiffDisplay(blocks[createTableIndex])); + + var postCreatePackages = BuildTablePostCreatePackages(blocks, createTableIndex + 1); + foreach (var package in postCreatePackages + .OrderBy(NormalizeTablePostCreatePackageForComparison, StringComparer.Ordinal)) + { + normalizedBlocks.Add(NormalizeTablePostCreatePackageForDiffDisplay(package)); + } + + return string.Join("\n", normalizedBlocks); + } + + private static string NormalizeTableBlockForDiffDisplay(string[] block) + { + var firstLine = GetFirstMeaningfulLine(block); + if (string.IsNullOrEmpty(firstLine)) + { + return JoinBlockLines(block); + } + + if (firstLine.StartsWith("CREATE TABLE", StringComparison.OrdinalIgnoreCase) || + firstLine.StartsWith("ALTER TABLE", StringComparison.OrdinalIgnoreCase) || + (firstLine.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase) && + firstLine.IndexOf(" INDEX ", StringComparison.OrdinalIgnoreCase) >= 0)) + { + return NormalizeLegacyTableStatementBlockForDiffDisplay(block); + } + + return JoinBlockLines(block); + } + + private static string NormalizeTablePostCreatePackageForDiffDisplay(string package) + { + var blocks = SplitGoDelimitedBlocks(package); + if (blocks.Count != 1) + { + return package; + } + + return NormalizeTableBlockForDiffDisplay(blocks[0]); + } + private static string NormalizeUserDefinedTypeScriptForComparison(string script) { var blocks = SplitGoDelimitedBlocks(script); @@ -2715,6 +3292,24 @@ private static string NormalizeUserDefinedTypeScriptForComparison(string script) return string.Join("\n", blocks.SelectMany(block => block)); } + private static string NormalizeUserDefinedTypeScriptForDiffDisplay(string script) + { + var blocks = SplitGoDelimitedBlocks(script); + for (var i = 0; i < blocks.Count; i++) + { + var firstLine = GetFirstMeaningfulLine(blocks[i]); + if (firstLine is null || + !firstLine.StartsWith("CREATE TYPE", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + blocks[i] = [NormalizeLegacyTableStatementBlockForDiffDisplay(blocks[i])]; + } + + return string.Join("\n", blocks.SelectMany(block => block)); + } + private static string NormalizeLegacyTableStatementBlockForComparison(IEnumerable block) { var statement = string.Join( @@ -2731,6 +3326,223 @@ private static string NormalizeLegacyTableStatementBlockForComparison(IEnumerabl return NormalizeLegacyTableStatementTextForComparison(statement) + "\nGO"; } + private static string NormalizeLegacyTableStatementBlockForDiffDisplay(IEnumerable block) + { + var statement = string.Join( + " ", + block.Where(line => !string.Equals(line.Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + .Select(line => line.Trim()) + .Where(line => line.Length > 0)); + + if (statement.Length == 0) + { + return "GO"; + } + + return NormalizeLegacyTableStatementTextForDiffDisplay(statement) + "\nGO"; + } + + private static string NormalizeLegacyTableStatementTextForDiffDisplay(string statement) + { + var normalized = Regex.Replace(statement, @"\s+", " ", RegexOptions.CultureInvariant).Trim(); + while (normalized.EndsWith(";", StringComparison.Ordinal)) + { + normalized = normalized[..^1].TrimEnd(); + } + + if (TryFormatCreateTableLikeStatementForDiffDisplay(normalized, out var formatted)) + { + return formatted; + } + + return normalized; + } + + private static bool TryFormatCreateTableLikeStatementForDiffDisplay(string statement, out string formatted) + { + formatted = string.Empty; + + if (!TryParseTableLikeStatementForDiffDisplay(statement, out var parts)) + { + return false; + } + + var lines = new List(parts.Items.Count + 3) + { + parts.Prefix, + "(" + }; + + foreach (var item in parts.Items) + { + lines.Add(item.HasTrailingComma ? $" {item.Text}," : $" {item.Text}"); + } + + lines.Add(string.IsNullOrWhiteSpace(parts.Suffix) ? ")" : $") {parts.Suffix}"); + formatted = string.Join("\n", lines); + return true; + } + + private static bool TryParseTableLikeStatementForDiffDisplay(string statement, out TableLikeStatementParts parts) + { + parts = null!; + + if (!statement.StartsWith("CREATE TABLE", StringComparison.OrdinalIgnoreCase) && + !statement.StartsWith("CREATE TYPE", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var openParenIndex = statement.IndexOf('('); + if (openParenIndex < 0) + { + return false; + } + + var closeParenIndex = FindMatchingCloseParenthesis(statement, openParenIndex); + if (closeParenIndex < 0) + { + return false; + } + + var prefix = statement[..openParenIndex].TrimEnd(); + var body = statement[(openParenIndex + 1)..closeParenIndex]; + var suffix = statement[(closeParenIndex + 1)..].Trim(); + var rawItems = SplitTopLevelSqlList(body); + var items = new List(rawItems.Count); + for (var i = 0; i < rawItems.Count; i++) + { + var item = rawItems[i].Trim(); + if (item.Length == 0) + { + continue; + } + + items.Add(new TableLikeBodyItem(item, i < rawItems.Count - 1)); + } + + if (items.Count == 0) + { + return false; + } + + parts = new TableLikeStatementParts(prefix, items, suffix); + return true; + } + + private static int FindMatchingCloseParenthesis(string text, int openParenIndex) + { + var depth = 0; + var inSingleQuotedString = false; + + for (var i = openParenIndex; i < text.Length; i++) + { + var ch = text[i]; + if (inSingleQuotedString) + { + if (ch == '\'') + { + if (i + 1 < text.Length && text[i + 1] == '\'') + { + i++; + } + else + { + inSingleQuotedString = false; + } + } + + continue; + } + + if (ch == '\'') + { + inSingleQuotedString = true; + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + + if (ch == ')') + { + depth--; + if (depth == 0) + { + return i; + } + } + } + + return -1; + } + + private static IReadOnlyList SplitTopLevelSqlList(string body) + { + var items = new List(); + var current = new StringBuilder(); + var depth = 0; + var inSingleQuotedString = false; + + for (var i = 0; i < body.Length; i++) + { + var ch = body[i]; + if (inSingleQuotedString) + { + current.Append(ch); + if (ch == '\'') + { + if (i + 1 < body.Length && body[i + 1] == '\'') + { + current.Append(body[++i]); + } + else + { + inSingleQuotedString = false; + } + } + + continue; + } + + if (ch == '\'') + { + inSingleQuotedString = true; + current.Append(ch); + continue; + } + + if (ch == '(') + { + depth++; + current.Append(ch); + continue; + } + + if (ch == ')') + { + depth--; + current.Append(ch); + continue; + } + + if (ch == ',' && depth == 0) + { + items.Add(current.ToString()); + current.Clear(); + continue; + } + + current.Append(ch); + } + + items.Add(current.ToString()); + return items; + } + private static string NormalizeLegacyTableStatementTextForComparison(string statement) { var normalized = NormalizeSqlStatementTokensForComparison(statement); diff --git a/tests/SqlChangeTracker.Tests/Commands/StatusDiffPullCommandTests.cs b/tests/SqlChangeTracker.Tests/Commands/StatusDiffPullCommandTests.cs index 82abf37..5351461 100644 --- a/tests/SqlChangeTracker.Tests/Commands/StatusDiffPullCommandTests.cs +++ b/tests/SqlChangeTracker.Tests/Commands/StatusDiffPullCommandTests.cs @@ -295,7 +295,7 @@ public void DiffCommand_WithFilterPatterns_PassesPatternsToService() string.Empty, []), ExitCodes.Success), - OnRunDiff = (_, _, _, patterns, _) => capturedPatterns = patterns + OnRunDiff = (_, _, _, patterns, _, _) => capturedPatterns = patterns }; var command = new DiffCommand { SyncService = stub }; @@ -324,7 +324,7 @@ public void DiffCommand_WithContextLines_PassesValueToService() string.Empty, []), ExitCodes.Success), - OnRunDiff = (_, _, _, _, ctx) => capturedContextLines = ctx + OnRunDiff = (_, _, _, _, ctx, _) => capturedContextLines = ctx }; var command = new DiffCommand { SyncService = stub }; @@ -350,7 +350,7 @@ public void DiffCommand_WithoutContextLines_DefaultsToThree() string.Empty, []), ExitCodes.Success), - OnRunDiff = (_, _, _, _, ctx) => capturedContextLines = ctx + OnRunDiff = (_, _, _, _, ctx, _) => capturedContextLines = ctx }; var command = new DiffCommand { SyncService = stub }; @@ -361,6 +361,32 @@ public void DiffCommand_WithoutContextLines_DefaultsToThree() Assert.Equal(3, capturedContextLines); } + [Fact] + public void DiffCommand_WithNormalizedDiffFlag_PassesValueToService() + { + var capturedNormalizedDiff = false; + var stub = new StubSyncCommandService + { + DiffResult = CommandExecutionResult.Ok( + new DiffResult( + "diff", + ".\\schema", + "db", + null, + string.Empty, + []), + ExitCodes.Success), + OnRunDiff = (_, _, _, _, _, normalized) => capturedNormalizedDiff = normalized + }; + + var command = new DiffCommand { SyncService = stub }; + var settings = new DiffCommandSettings { ShowNormalizedDiff = true }; + var exitCode = command.Execute(CreateContext("diff"), settings, default); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.True(capturedNormalizedDiff); + } + [Fact] public void DiffCommand_WithInvalidFilterPattern_ReturnsInvalidConfigError() { @@ -540,16 +566,16 @@ private sealed class StubSyncCommandService : ISyncCommandService public CommandExecutionResult PullResult { get; set; } = CommandExecutionResult.Failure(new ErrorInfo(ErrorCodes.ExecutionFailed, "pull not configured"), ExitCodes.ExecutionFailure); - public Action? OnRunDiff { get; set; } + public Action? OnRunDiff { get; set; } public Action?>? OnRunPull { get; set; } public CommandExecutionResult RunStatus(string? projectDir, string? target, Action? progress = null) => StatusResult; - public CommandExecutionResult RunDiff(string? projectDir, string? target, string? objectSelector, string[]? filterPatterns = null, int contextLines = 3, Action? progress = null) + public CommandExecutionResult RunDiff(string? projectDir, string? target, string? objectSelector, string[]? filterPatterns = null, int contextLines = 3, bool normalizedDiff = false, Action? progress = null) { - OnRunDiff?.Invoke(projectDir, target, objectSelector, filterPatterns, contextLines); + OnRunDiff?.Invoke(projectDir, target, objectSelector, filterPatterns, contextLines, normalizedDiff); return DiffResult; } diff --git a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs index 8b35fdb..f8d5174 100644 --- a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs +++ b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs @@ -1680,8 +1680,86 @@ public void BuildUnifiedDiff_Table_PreservesPostCreatePackageContentDifferencesW var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); - Assert.Contains("add constraint pk_externaldef primary key clustered(externalid) on primary", diff, StringComparison.OrdinalIgnoreCase); - Assert.Contains("add constraint pk_externaldef primary key clustered(externalid) with(data_compression=page) on primary", diff, StringComparison.OrdinalIgnoreCase); + Assert.NotEmpty(diff); + Assert.Contains("data_compression", diff, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildUnifiedDiff_Table_PreservesReadableStatementText_WhenLegacyFormattingAlsoNormalizes() + { + var source = + "CREATE TABLE [lab].[SampleMeasure]\n" + + "(\n" + + "[BatchId] [int] NOT NULL,\n" + + "[MeasureValue] [decimal] (15, 8) NULL\n" + + ") ON [PRIMARY]\n" + + "GO"; + var target = + "create table lab.samplemeasure(batchid int not null,measurevalue decimal(15,8) null,) on primary;\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Contains(" CREATE TABLE [lab].[SampleMeasure]", diff); + Assert.Contains(" [BatchId] [int] NOT NULL,", diff); + Assert.Contains("- [MeasureValue] [decimal] (15, 8) NULL", diff); + Assert.Contains("+ measurevalue decimal(15,8) null", diff, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("create table lab.samplemeasure(batchid int not null,measurevalue decimal(15,8) null) on primary", diff, StringComparison.Ordinal); + } + + [Fact] + public void BuildUnifiedDiff_Table_PinpointsChangedBodyEntryAndCloseLine_WhenLegacyFormattingAlsoDiffers() + { + var source = + "CREATE TABLE [lab].[SampleAmount]\n" + + "(\n" + + "[BatchId] [int] NOT NULL,\n" + + "[ItemId] [int] NOT NULL,\n" + + "[SourceAmount] [decimal] (15, 2) NOT NULL,\n" + + "[TargetAmount] [decimal] (15, 3) NOT NULL\n" + + ") ON [DATAFG]\n" + + "GO\n" + + "ALTER TABLE [lab].[SampleAmount] ADD CONSTRAINT [PK_SampleAmount] PRIMARY KEY CLUSTERED ([BatchId], [ItemId]) WITH (FILLFACTOR = 90) ON [DATAFG]\n" + + "GO"; + var target = + "create table lab.sampleamount(batchid int not null,itemid int not null,sourceamount decimal(15,2) not null,targetamount decimal(15,2) not null) on datafg with(data_compression=page)\n" + + "GO\n" + + "alter table lab.sampleamount add constraint pk_sampleamount primary key clustered(batchid,itemid) with(fillfactor=90,data_compression=page) on datafg\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Contains(" [BatchId] [int] NOT NULL,", diff); + Assert.DoesNotContain("- [BatchId] [int] NOT NULL,", diff); + Assert.DoesNotContain("+ batchid int not null,", diff, StringComparison.OrdinalIgnoreCase); + Assert.Contains("- [TargetAmount] [decimal] (15, 3) NOT NULL", diff); + Assert.Contains("+ targetamount decimal(15,2) not null", diff, StringComparison.OrdinalIgnoreCase); + Assert.Contains("-) ON [DATAFG]", diff); + Assert.Contains("+) ON datafg with(data_compression=page)", diff, StringComparison.OrdinalIgnoreCase); + Assert.Contains("-ALTER TABLE [lab].[SampleAmount] ADD CONSTRAINT [PK_SampleAmount] PRIMARY KEY CLUSTERED ([BatchId], [ItemId]) WITH (FILLFACTOR = 90) ON [DATAFG]", diff); + Assert.Contains("+alter table lab.sampleamount add constraint pk_sampleamount primary key clustered(batchid,itemid) with(fillfactor=90,data_compression=page) on datafg", diff, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildUnifiedDiff_Table_CanRenderNormalizedDiffForDebuggingWhenRequested() + { + var source = + "CREATE TABLE [lab].[SampleMeasure]\n" + + "(\n" + + "[BatchId] [int] NOT NULL,\n" + + "[MeasureValue] [decimal] (15, 8) NULL\n" + + ")\n" + + "GO"; + var target = + "create table lab.samplemeasure(batchid int not null,measurevalue decimal(15,8) null,) on primary;\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target, normalizedDiff: true); + + Assert.Contains("create table lab.samplemeasure", diff, StringComparison.Ordinal); + Assert.Contains("measurevalue decimal(15,8) null", diff, StringComparison.Ordinal); + Assert.Contains("measurevalue decimal(15,8) null,)", diff, StringComparison.Ordinal); + Assert.DoesNotContain("[lab].[SampleMeasure]", diff, StringComparison.Ordinal); } [Fact] From 30bb8b7092dd671b166611e9f5b9a62e257f2a87 Mon Sep 17 00:00:00 2001 From: zacateras Date: Fri, 10 Apr 2026 15:33:17 +0200 Subject: [PATCH 4/7] Add tests for database diagram stored procedures and user-defined type permissions - Implement tests to recognize and filter excluded stored procedures related to database diagrams in SqlServerIntrospectorTests. - Enhance SqlServerScripterTests with functionality to script roles and users, including permissions for user-defined types. - Introduce unified diff tests in SyncCommandServiceTests to handle permission order differences and suppress irrelevant changes for user-defined types and tables. - Ensure consistent handling of user and role permissions in unified diffs, including suppression of terminal GO statements. --- CHANGELOG.md | 10 + README.md | 2 + specs/01-cli.md | 14 +- specs/04-scripting.md | 22 +- src/SqlChangeTracker/PACKAGE_README.md | 2 + .../Sql/SqlServerIntrospector.cs | 39 +- src/SqlChangeTracker/Sql/SqlServerScripter.cs | 78 ++- .../Sync/SyncCommandService.cs | 485 ++++++++++++++++-- .../Sql/SqlServerIntrospectorTests.cs | 27 + .../Sql/SqlServerScripterTests.cs | 132 +++++ .../Sync/SyncCommandServiceTests.cs | 278 +++++++++- 11 files changed, 1003 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 210acaa..f02db73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,16 +15,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Treat equivalent role-membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` as compatible during comparison. - Treat legacy Service Broker message-type validation synonyms and equivalent contract/service body formatting and item ordering as compatible during comparison. - Treat equivalent `TableData` scripts as compatible during comparison when the normalized `INSERT` statements differ only by row ordering within the same contiguous data block. +- Treat equivalent `TableData` scripts as compatible during comparison when single-row `INSERT` column lists and corresponding value tuples are reordered consistently. - Treat equivalent `Table` scripts as compatible during comparison when post-create statement packages differ only by ordering after the base `CREATE TABLE` block. - Treat equivalent legacy `Table` statement formatting as compatible during comparison when normalized table definitions, post-create table statements, and persisted option values are otherwise identical. - Treat equivalent legacy `UserDefinedType` `CREATE TYPE` formatting as compatible during comparison when the normalized type definition is otherwise identical. - Treat omitted `TEXTIMAGE_ON` on `Table` scripts as compatible during comparison only when DB metadata shows the table LOB data space matches the current default data space. - Treat equivalent extended-property blocks as compatible during comparison when the normalized `sp_addextendedproperty` statements differ only by ordering, argument spacing, or named-vs-positional argument forms within the same contiguous block. +- Treat equivalent extended-property blocks as compatible during comparison when the normalized `sp_addextendedproperty` statements differ only by ordering, argument spacing, named-vs-positional forms, or top-level `N'...'` string literal prefixes. - Treat leading SSMS-generated banner comments on programmable objects as compatible during comparison. - Treat redundant empty or otherwise no-op `GO` batches as compatible during comparison. +- Treat an omitted terminal `GO` after the final batch as compatible during comparison with an explicit final `GO`. - Keep `diff` output readable by rendering compatible `Table` and `UserDefinedType` changes from readable script text instead of opaque comparison-normalized text. +- Keep normal `diff` output readable for non-table objects such as users and roles by rendering permission changes from readable script text instead of lowercase comparison-normalized tokens. - Keep readable `diff` output for `Table` and table-valued `UserDefinedType` bodies at per-entry granularity instead of collapsing the entire body into one changed line. - Align readable `Table` and table-valued `UserDefinedType` diffs by individual body entries so a single changed column or inline constraint does not mark the entire body as changed. +- Exclude SSMS database-diagram support stored procedures from discovery and scripting even when SQL Server does not mark them as system-shipped. +- Script `TYPE::` permissions for scalar and table-valued `UserDefinedType` objects. +- Script database-level permissions granted directly to `Role` and `User` principals, and emit `CREATE USER ... WITHOUT LOGIN` when no server-login metadata is available. +- Treat equivalent contiguous permission statement ordering as compatible during comparison. +- Treat legacy standalone table-level inline `PRIMARY KEY` and `UNIQUE` constraints as compatible during comparison with canonical post-create key-constraint statements. +- Treat legacy CLR table-valued function return-column collation clauses as compatible during comparison when SQL Server ignores them in the effective return shape. - 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. - 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). - 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. diff --git a/README.md b/README.md index 1370f7e..5f70f6a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ Schema scripting covers user-defined schemas and also emits built-in `dbo` when Stored procedure scripting covers T-SQL procedures and SQL CLR stored procedures (`sys.objects.type = 'P'` and `PC`). +Role and user scripting includes database-level permissions granted directly to those principals, and loginless users are scripted as `CREATE USER ... WITHOUT LOGIN`. + Table scripting also includes standalone user-created table statistics (`CREATE STATISTICS`) as post-create table statements. Current statistics option coverage includes effective sampling (`FULLSCAN` or `SAMPLE PERCENT`), `PERSIST_SAMPLE_PERCENT = ON`, `NORECOMPUTE`, `INCREMENTAL=ON`, and `AUTO_DROP = ON|OFF` when the source server exposes the required metadata. `MAXDOP`, `STATS_STREAM`, `ROWCOUNT`, and `PAGECOUNT` remain deferred. Function scripting covers T-SQL scalar/table functions and SQL CLR scalar/table-valued functions (`sys.objects.type = 'FS'` and `FT`), including `EXTERNAL NAME` assembly bindings. diff --git a/specs/01-cli.md b/specs/01-cli.md index 345beec..480fdb6 100644 --- a/specs/01-cli.md +++ b/specs/01-cli.md @@ -203,13 +203,16 @@ Behavior: - Normalization includes line-ending/trailing-newline stability plus explicitly listed compatibility rules for deterministic comparison. - Empty lines are ignored during comparison, and whitespace-only lines are normalized to empty lines first so blank separators differing only by spaces or tabs compare as compatible. - Redundant empty or no-op `GO` batches compare as compatible. +- An omitted terminal `GO` after the final batch compares as compatible with an explicit final `GO`. - Trailing semicolons on `INSERT` statement lines are stripped during normalization; scripts emitted with and without statement terminators compare as compatible. -- Equivalent `TableData` `INSERT` statement ordering within the same contiguous data block compares as compatible when the inserted row set is otherwise identical. +- Equivalent `TableData` single-row `INSERT` statements compare as compatible when column lists and corresponding value tuples are reordered consistently, and contiguous `INSERT` statement ordering within the same data block compares as compatible when the normalized inserted-row set is otherwise identical. - Equivalent `Table` post-create statement package ordering compares as compatible when the normalized package set after the base `CREATE TABLE` block is otherwise identical. - Equivalent legacy `Table` statement formatting for `CREATE TABLE`, `ALTER TABLE`, and `CREATE ... INDEX` statements compares as compatible when normalized identifiers, type tokens, default expressions, semicolons, and persisted option values are otherwise identical. +- Equivalent legacy standalone table-level `PRIMARY KEY` and `UNIQUE` constraints written inline inside `CREATE TABLE (...)` compare as compatible with canonical post-create key-constraint statements when the normalized constraint semantics are otherwise identical. - Equivalent legacy `UserDefinedType` `CREATE TYPE` statement formatting compares as compatible when normalized identifiers, type tokens, default expressions, semicolons, and inline table-valued type bodies are otherwise identical. +- Equivalent contiguous permission statement ordering compares as compatible when the normalized permission statement set is otherwise identical. - For `Table`, omitted `TEXTIMAGE_ON [name]` compares as compatible with an explicit clause only when DB metadata shows that the table LOB data space equals the current default data space represented by `[name]`. -- Equivalent extended-property statement ordering within the same contiguous extended-property block compares as compatible when the normalized property statement set is otherwise identical. Equivalent named-vs-positional `sp_addextendedproperty` argument forms, including omitted trailing `NULL` levels, compare as compatible. +- Equivalent extended-property statement ordering within the same contiguous extended-property block compares as compatible when the normalized property statement set is otherwise identical. Equivalent named-vs-positional `sp_addextendedproperty` argument forms, including omitted trailing `NULL` levels and top-level Unicode-literal prefixes on string arguments, compare as compatible. - Equivalent `Queue` option spacing, line wrapping, explicit default `ON [PRIMARY]`, and disabled default activation compare as compatible. - Equivalent `Role` membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` compare as compatible. - Equivalent `MessageType` validation synonyms/spacing and equivalent `Contract` and `Service` body formatting and item ordering compare as compatible. @@ -238,13 +241,16 @@ Behavior: - For `Table` and table-valued `UserDefinedType` scripts, readable diff rendering SHOULD preserve structural body boundaries so column and inline-constraint changes remain pinpointed within the body instead of collapsing the entire statement into one changed line. - Empty lines are ignored during comparison, and whitespace-only lines are normalized to empty lines first so blank separators differing only by spaces or tabs compare as compatible. - Redundant empty or no-op `GO` batches compare as compatible. +- An omitted terminal `GO` after the final batch compares as compatible with an explicit final `GO`. - Trailing semicolons on `INSERT` statement lines are stripped during normalization; scripts emitted with and without statement terminators compare as compatible. -- Equivalent `TableData` `INSERT` statement ordering within the same contiguous data block compares as compatible when the inserted row set is otherwise identical. +- Equivalent `TableData` single-row `INSERT` statements compare as compatible when column lists and corresponding value tuples are reordered consistently, and contiguous `INSERT` statement ordering within the same data block compares as compatible when the normalized inserted-row set is otherwise identical. - Equivalent `Table` post-create statement package ordering compares as compatible when the normalized package set after the base `CREATE TABLE` block is otherwise identical. - Equivalent legacy `Table` statement formatting for `CREATE TABLE`, `ALTER TABLE`, and `CREATE ... INDEX` statements compares as compatible when normalized identifiers, type tokens, default expressions, semicolons, and persisted option values are otherwise identical. +- Equivalent legacy standalone table-level `PRIMARY KEY` and `UNIQUE` constraints written inline inside `CREATE TABLE (...)` compare as compatible with canonical post-create key-constraint statements when the normalized constraint semantics are otherwise identical. - Equivalent legacy `UserDefinedType` `CREATE TYPE` statement formatting compares as compatible when normalized identifiers, type tokens, default expressions, semicolons, and inline table-valued type bodies are otherwise identical. +- Equivalent contiguous permission statement ordering compares as compatible when the normalized permission statement set is otherwise identical. - For `Table`, omitted `TEXTIMAGE_ON [name]` compares as compatible with an explicit clause only when DB metadata shows that the table LOB data space equals the current default data space represented by `[name]`. -- Equivalent extended-property statement ordering within the same contiguous extended-property block compares as compatible when the normalized property statement set is otherwise identical. Equivalent named-vs-positional `sp_addextendedproperty` argument forms, including omitted trailing `NULL` levels, compare as compatible. +- Equivalent extended-property statement ordering within the same contiguous extended-property block compares as compatible when the normalized property statement set is otherwise identical. Equivalent named-vs-positional `sp_addextendedproperty` argument forms, including omitted trailing `NULL` levels and top-level Unicode-literal prefixes on string arguments, compare as compatible. - Equivalent `Queue` option spacing, line wrapping, explicit default `ON [PRIMARY]`, and disabled default activation compare as compatible. - Equivalent `Role` membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` compare as compatible. - Equivalent `MessageType` validation synonyms/spacing and equivalent `Contract` and `Service` body formatting and item ordering compare as compatible. diff --git a/specs/04-scripting.md b/specs/04-scripting.md index af257e2..d6e5f59 100644 --- a/specs/04-scripting.md +++ b/specs/04-scripting.md @@ -42,6 +42,7 @@ This specification defines normative scripting rules for `sqlct`. - Discovery SHOULD preserve compatibility with projects that include security and storage object groups. - Database-scoped objects with no explicit schema MUST be mapped consistently with schema-folder rules. - `Schema` discovery covers user-defined schemas, excludes `sys` and `INFORMATION_SCHEMA`, and includes built-in `dbo` only when it has explicit schema permissions or schema-level extended properties that are in scope for scripting. +- `StoredProcedure` discovery excludes SSMS database-diagram support procedures: `sp_alterdiagram`, `sp_creatediagram`, `sp_dropdiagram`, `sp_helpdiagramdefinition`, `sp_helpdiagrams`, `sp_renamediagram`, and `sp_upgraddiagrams`. - `Role` discovery covers user-defined roles and fixed roles that have non-system members tracked in role membership metadata. - `Assembly` discovery covers user-defined assemblies from `sys.assemblies` and excludes SQL Server system assemblies (`is_user_defined = 0`). - `UserDefinedType` discovery covers both scalar alias types and table-valued types. @@ -477,9 +478,10 @@ Each emitted statement MUST be followed by `GO`. - Role membership statements MUST use: - `EXEC sp_addrolemember N'', N''` - System-principal memberships for `dbo`, `guest`, `INFORMATION_SCHEMA`, and `sys` MUST NOT be emitted. +- Role database-level permissions MUST be emitted after any role-membership statements and MUST use database-permission syntax without an `ON` clause (for example `GRANT VIEW DATABASE STATE TO [role]`). - Role-level extended properties MUST use: - `EXEC sp_addextendedproperty ..., 'USER', N'', NULL, NULL, NULL, NULL` -- Role extended properties MUST be emitted after the base role DDL and any role-membership statements, ordered by property name. +- Role extended properties MUST be emitted after the base role DDL, any role-membership statements, and any role database-level permissions, ordered by property name. ### 8.8 Users - User scripts MUST emit one `CREATE USER` statement and end with `GO`. @@ -490,10 +492,12 @@ Each emitted statement MUST be followed by `GO`. - `FOR CERTIFICATE [certificate]` - `FOR ASYMMETRIC KEY [key]` - `WITH DEFAULT_SCHEMA=[schema]` MUST be emitted only when a non-empty, non-`dbo` default schema applies to the emitted user shape. +- When server-login metadata is unavailable for an otherwise login-mapped user shape, the scripting engine MUST emit `WITHOUT LOGIN` rather than inventing a fallback `FOR LOGIN [user_name]` reference. +- User database-level permissions MUST be emitted after the base user `GO` and MUST use database-permission syntax without an `ON` clause (for example `GRANT VIEW DEFINITION TO [user]`). - Contained database users that require `WITH PASSWORD` remain unsupported in the current scripting engine and MUST fail explicitly rather than emit lossy output. - User-level extended properties MUST use: - `EXEC sp_addextendedproperty ..., 'USER', N'', NULL, NULL, NULL, NULL` -- User extended properties MUST be emitted after the base user `GO`, ordered by property name. +- User extended properties MUST be emitted after the base user `GO` and any user database-level permissions, ordered by property name. ### 8.9 Synonyms - Synonym scripts MUST emit: @@ -509,9 +513,10 @@ Each emitted statement MUST be followed by `GO`. - `CREATE TYPE [schema].[name] FROM ` - `GO` - Base-type formatting MUST reuse the general type-formatting rules from Section 6.5. +- Scalar alias user-defined type permissions MUST use `ON TYPE::[schema].[name]`. - User-defined-type extended properties MUST use: - `EXEC sp_addextendedproperty ..., 'SCHEMA', N'', 'TYPE', N'', NULL, NULL` -- User-defined-type extended properties MUST be emitted after the type `GO`, ordered by property name. +- Scalar alias user-defined type extended properties MUST be emitted after grants, ordered by property name. ### 8.11 UserDefinedType (Table-Valued) - Table-valued user-defined type metadata MUST be sourced from `sys.table_types` together with the table-like metadata attached to `type_table_object_id`. @@ -529,7 +534,8 @@ Each emitted statement MUST be followed by `GO`. - Inline entries inside the table-valued user-defined type body MUST be comma-separated. - Table-valued user-defined type key-constraint formatting MUST reuse the applicable constraint rules from Section 8.1.6, except the constraints remain inside the `AS TABLE (...)` body rather than being emitted after `GO`. - Table-valued user-defined types are still exposed as `UserDefinedType` in CLI output and selectors. -- Table-valued user-defined type scripting MUST NOT emit storage clauses, non-constraint indexes, XML indexes, foreign keys, triggers, full-text indexes, permissions, or extended properties. +- Table-valued user-defined type permissions MUST use `ON TYPE::[schema].[name]`. +- Table-valued user-defined type scripting MUST NOT emit storage clauses, non-constraint indexes, XML indexes, foreign keys, triggers, full-text indexes, or extended properties. ### 8.12 XmlSchemaCollection - XML schema collection metadata MUST be sourced from `sys.xml_schema_collections`, schema metadata, and `XML_SCHEMA_NAMESPACE`. @@ -816,15 +822,18 @@ When compatibility reference files are available, `sqlct` MAY apply reconciliati - For `Table` and table-valued `UserDefinedType` statements, readable diff rendering SHOULD retain structural body boundaries so body entries can diff at per-entry granularity when the compatibility-preserving representation exposes them. - Empty lines MUST be ignored during comparison, and whitespace-only lines MUST be normalized to empty lines first so that blank separators differing only by spaces or tabs compare as compatible. - Comparison normalization MAY ignore redundant empty or otherwise no-op `GO` batches, including batches that contain only standalone semicolon lines. +- Comparison normalization MAY treat an omitted terminal `GO` after the final batch as compatible with an explicit final `GO`. - Trailing semicolons on `INSERT` statement lines MUST be stripped by comparison normalization so that scripts emitted with and without statement terminators compare as compatible. - For `TableData`, trailing semicolons on `SET IDENTITY_INSERT` lines MUST also be stripped by comparison normalization. - For `TableData`, comparison normalization MUST treat legacy top-level `N'...'` string literals inside single-line or multi-line `INSERT ... VALUES (...)` statements as compatible with canonical `'...'` literals; canonical script generation remains governed by Section 8.26. -- For `TableData`, comparison normalization MAY treat reordered `INSERT ... VALUES (...)` statements within the same contiguous data block as compatible when the normalized inserted-row set is otherwise identical. +- For `TableData`, comparison normalization MAY treat single-row `INSERT ... VALUES (...)` statements as compatible when the target column list and corresponding value tuple are reordered consistently, and MAY treat reordered `INSERT ... VALUES (...)` statements within the same contiguous data block as compatible when the normalized inserted-row set is otherwise identical. - For `Table`, comparison normalization MAY treat reordered post-create statement packages as compatible when the normalized package set after the base table `CREATE` block is otherwise identical. Table-scoped trigger packages MUST include the trigger body together with any immediately preceding programmable-object `SET` blocks. - For `Table`, comparison normalization MAY treat equivalent legacy formatting for `CREATE TABLE`, `ALTER TABLE`, and `CREATE ... INDEX` statement blocks as compatible when normalized identifiers, type tokens, default expressions, semicolons, and persisted option values are otherwise identical. +- For `Table`, comparison normalization MAY treat legacy standalone table-level `PRIMARY KEY` and `UNIQUE` constraints written inline as top-level `CREATE TABLE (...)` body entries as compatible with canonical post-create key-constraint statements when the normalized constraint semantics are otherwise identical. - For `UserDefinedType`, comparison normalization MAY treat equivalent legacy `CREATE TYPE` statement formatting as compatible when normalized identifiers, type tokens, default expressions, semicolons, and inline table-valued type bodies are otherwise identical. +- Comparison normalization MAY treat reordered contiguous `GRANT` and `DENY` statement blocks as compatible when the normalized permission statement set is otherwise identical. - For `Table`, comparison normalization MAY treat omitted `TEXTIMAGE_ON [name]` as compatible with an explicit clause only when DB metadata shows that the table `lob_data_space_id` resolves to the current default data space named `[name]`; otherwise omission remains a semantic difference. -- For extended-property blocks, comparison normalization MAY treat reordered `EXEC sp_addextendedproperty ...` statements as compatible within the same contiguous extended-property block when the normalized property statement set is otherwise identical, MAY ignore equivalent spacing around commas and arguments in those statements, and MAY treat equivalent named-vs-positional argument forms with omitted trailing `NULL` levels as compatible. +- For extended-property blocks, comparison normalization MAY treat reordered `EXEC sp_addextendedproperty ...` statements as compatible within the same contiguous extended-property block when the normalized property statement set is otherwise identical, MAY ignore equivalent spacing around commas and arguments in those statements, and MAY treat equivalent named-vs-positional argument forms with omitted trailing `NULL` levels and top-level Unicode-literal prefixes on string arguments as compatible. - For programmable `StoredProcedure`, `View`, `Function`, and `Trigger` scripts, comparison normalization MAY ignore leading SSMS-generated `/*** Object: ... Script Date: ... ***/` banner comments. - For `Queue`, comparison normalization MUST treat equivalent single-line and multi-line queue option formatting as compatible, MAY treat explicit `ON [PRIMARY]` as equivalent to an omitted default primary filegroup, and MAY treat disabled activation containing only default owner execution context as equivalent to omitted activation. - For `Role`, comparison normalization MAY treat legacy `EXEC sp_addrolemember N'', N''` statements as compatible with `ALTER ROLE [role] ADD MEMBER [member]` when the effective role-membership change is otherwise identical. @@ -832,6 +841,7 @@ When compatibility reference files are available, `sqlct` MAY apply reconciliati - For `Contract`, comparison normalization MAY treat equivalent single-line and multi-line body formatting as compatible and MAY compare message-usage item ordering as compatible when the emitted usage set is otherwise identical. - 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. - 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. +- For CLR table-valued `Function` scripts, comparison normalization MAY treat legacy `COLLATE ` clauses on return-column lines as compatible when SQL Server ignores that collation metadata for the effective return shape. ## 11. Error and Unsupported Behavior - Missing SQL object metadata for requested object MUST fail with an error. diff --git a/src/SqlChangeTracker/PACKAGE_README.md b/src/SqlChangeTracker/PACKAGE_README.md index f754584..33f19cc 100644 --- a/src/SqlChangeTracker/PACKAGE_README.md +++ b/src/SqlChangeTracker/PACKAGE_README.md @@ -57,6 +57,8 @@ Schema scripting covers user-defined schemas and also emits built-in `dbo` when Stored procedure scripting covers T-SQL procedures and SQL CLR stored procedures (`sys.objects.type = 'P'` and `PC`). +Role and user scripting includes database-level permissions granted directly to those principals, and loginless users are scripted as `CREATE USER ... WITHOUT LOGIN`. + Table scripting also includes standalone user-created table statistics (`CREATE STATISTICS`) as post-create table statements. Current statistics option coverage includes effective sampling (`FULLSCAN` or `SAMPLE PERCENT`), `PERSIST_SAMPLE_PERCENT = ON`, `NORECOMPUTE`, `INCREMENTAL=ON`, and `AUTO_DROP = ON|OFF` when the source server exposes the required metadata. `MAXDOP`, `STATS_STREAM`, `ROWCOUNT`, and `PAGECOUNT` remain deferred. Function scripting covers T-SQL scalar/table functions and SQL CLR scalar/table-valued functions (`sys.objects.type = 'FS'` and `FT`), including `EXTERNAL NAME` assembly bindings. diff --git a/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs b/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs index f81b196..bfb0dc6 100644 --- a/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs +++ b/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs @@ -6,6 +6,17 @@ namespace SqlChangeTracker.Sql; internal class SqlServerIntrospector { + private static readonly HashSet ExcludedStoredProcedureNames = new(StringComparer.OrdinalIgnoreCase) + { + "sp_alterdiagram", + "sp_creatediagram", + "sp_dropdiagram", + "sp_helpdiagramdefinition", + "sp_helpdiagrams", + "sp_renamediagram", + "sp_upgraddiagrams" + }; + public virtual IReadOnlyList ListObjects(SqlConnectionOptions options, int maxParallelism = 0) { var dop = ResolveParallelism(maxParallelism); @@ -251,7 +262,9 @@ FROM sys.partition_schemes ps } }); - return bag.ToList(); + return bag + .Where(ShouldIncludeObject) + .ToList(); } public virtual IReadOnlyList ListMatchingObjects( @@ -283,6 +296,7 @@ public virtual IReadOnlyList ListMatchingObjects( }); return bag + .Where(ShouldIncludeObject) .OrderBy(item => item.Schema, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.ObjectType, StringComparer.OrdinalIgnoreCase) @@ -392,7 +406,13 @@ private static IEnumerable QueryObjects( schema = "dbo"; } - yield return new DbObjectInfo(schema, name, objectType, userDefinedTypeKind); + var item = new DbObjectInfo(schema, name, objectType, userDefinedTypeKind); + if (!ShouldIncludeObject(item)) + { + continue; + } + + yield return item; } } @@ -761,10 +781,23 @@ FROM sys.registered_search_property_lists sp && reader.FieldCount > 2 ? MapUserDefinedTypeKind(reader.GetString(2)) : null; - yield return new DbObjectInfo(matchedSchema, matchedName, objectType, userDefinedTypeKind); + var item = new DbObjectInfo(matchedSchema, matchedName, objectType, userDefinedTypeKind); + if (!ShouldIncludeObject(item)) + { + continue; + } + + yield return item; } } + internal static bool ShouldIncludeObject(DbObjectInfo item) + => !string.Equals(item.ObjectType, "StoredProcedure", StringComparison.OrdinalIgnoreCase) + || !IsExcludedStoredProcedureName(item.Name); + + internal static bool IsExcludedStoredProcedureName(string name) + => ExcludedStoredProcedureNames.Contains(name); + private static bool ObjectExists(SqlConnection connection, string objectName) { using var command = connection.CreateCommand(); diff --git a/src/SqlChangeTracker/Sql/SqlServerScripter.cs b/src/SqlChangeTracker/Sql/SqlServerScripter.cs index 304af6d..a65d390 100644 --- a/src/SqlChangeTracker/Sql/SqlServerScripter.cs +++ b/src/SqlChangeTracker/Sql/SqlServerScripter.cs @@ -1270,6 +1270,7 @@ FROM sys.database_principals dp } lines.AddRange(ReadRoleMembershipStatements(connection, roleName)); + lines.AddRange(ReadPrincipalDatabasePermissions(connection, roleName, referenceLines)); if (lines.Count == 0) { throw new InvalidOperationException($"Role has no scriptable definition: [{roleName}]."); @@ -1325,6 +1326,7 @@ FROM sys.database_principals dp lines.Add($"CREATE USER [{userName}] FOR CERTIFICATE [{certificateName}]"); lines.Add("GO"); + lines.AddRange(ReadPrincipalDatabasePermissions(connection, userName, referenceLines)); AppendExtendedPropertyLines(lines, ReadPrincipalExtendedProperties(connection, userName, referenceLines), referenceLines); AppendTrailingBlankLines(lines, referenceLines); return string.Join(Environment.NewLine, lines); @@ -1339,6 +1341,7 @@ FROM sys.database_principals dp lines.Add($"CREATE USER [{userName}] FOR ASYMMETRIC KEY [{asymmetricKeyName}]"); lines.Add("GO"); + lines.AddRange(ReadPrincipalDatabasePermissions(connection, userName, referenceLines)); AppendExtendedPropertyLines(lines, ReadPrincipalExtendedProperties(connection, userName, referenceLines), referenceLines); AppendTrailingBlankLines(lines, referenceLines); return string.Join(Environment.NewLine, lines); @@ -1348,6 +1351,7 @@ FROM sys.database_principals dp { lines.Add($"CREATE USER [{userName}] WITHOUT LOGIN{defaultSchemaClause}"); lines.Add("GO"); + lines.AddRange(ReadPrincipalDatabasePermissions(connection, userName, referenceLines)); AppendExtendedPropertyLines(lines, ReadPrincipalExtendedProperties(connection, userName, referenceLines), referenceLines); AppendTrailingBlankLines(lines, referenceLines); return string.Join(Environment.NewLine, lines); @@ -1358,6 +1362,7 @@ FROM sys.database_principals dp { lines.Add($"CREATE USER [{userName}] FROM EXTERNAL PROVIDER{defaultSchemaClause}"); lines.Add("GO"); + lines.AddRange(ReadPrincipalDatabasePermissions(connection, userName, referenceLines)); AppendExtendedPropertyLines(lines, ReadPrincipalExtendedProperties(connection, userName, referenceLines), referenceLines); AppendTrailingBlankLines(lines, referenceLines); return string.Join(Environment.NewLine, lines); @@ -1368,9 +1373,19 @@ FROM sys.database_principals dp throw new InvalidOperationException($"Contained database users with password metadata are not supported: [{userName}]."); } - var effectiveLoginName = string.IsNullOrWhiteSpace(loginName) ? userName : loginName; - lines.Add($"CREATE USER [{userName}] FOR LOGIN [{effectiveLoginName}]{defaultSchemaClause}"); + if (string.IsNullOrWhiteSpace(loginName)) + { + lines.Add($"CREATE USER [{userName}] WITHOUT LOGIN{defaultSchemaClause}"); + lines.Add("GO"); + lines.AddRange(ReadPrincipalDatabasePermissions(connection, userName, referenceLines)); + AppendExtendedPropertyLines(lines, ReadPrincipalExtendedProperties(connection, userName, referenceLines), referenceLines); + AppendTrailingBlankLines(lines, referenceLines); + return string.Join(Environment.NewLine, lines); + } + + lines.Add($"CREATE USER [{userName}] FOR LOGIN [{loginName}]{defaultSchemaClause}"); lines.Add("GO"); + lines.AddRange(ReadPrincipalDatabasePermissions(connection, userName, referenceLines)); AppendExtendedPropertyLines(lines, ReadPrincipalExtendedProperties(connection, userName, referenceLines), referenceLines); AppendTrailingBlankLines(lines, referenceLines); return string.Join(Environment.NewLine, lines); @@ -1588,7 +1603,7 @@ private static string ScriptUserDefinedType(SqlConnection connection, DbObjectIn var kind = obj.UserDefinedTypeKind ?? ResolveUserDefinedTypeKind(connection, obj); if (kind == UserDefinedTypeKind.Table) { - return ScriptTableType(connection, obj); + return ScriptTableType(connection, obj, referenceLines); } using var command = connection.CreateCommand(); @@ -1628,6 +1643,7 @@ FROM sys.types t "GO" }; + lines.AddRange(ReadUserDefinedTypePermissions(connection, schemaName, typeName, referenceLines)); AppendExtendedPropertyLines(lines, ReadUserDefinedTypeExtendedProperties(connection, schemaName, typeName, referenceLines), referenceLines); AppendTrailingBlankLines(lines, referenceLines); return string.Join(Environment.NewLine, lines); @@ -1657,7 +1673,7 @@ FROM sys.types t : UserDefinedTypeKind.Scalar; } - private static string ScriptTableType(SqlConnection connection, DbObjectInfo obj) + private static string ScriptTableType(SqlConnection connection, DbObjectInfo obj, string[]? referenceLines) { using var command = connection.CreateCommand(); command.CommandText = @" @@ -1699,6 +1715,7 @@ FROM sys.table_types tt lines.Add(")"); lines.Add("GO"); + lines.AddRange(ReadUserDefinedTypePermissions(connection, obj.Schema, obj.Name, referenceLines)); return string.Join(Environment.NewLine, lines); } @@ -4540,6 +4557,23 @@ FROM sys.database_permissions dp $"SCHEMA::{QuoteIdentifier(schemaName)}", referenceLines); + private static IEnumerable ReadPrincipalDatabasePermissions( + SqlConnection connection, + string principalName, + string[]? referenceLines) + => ExecutePermissionQuery( + connection, + @" +SELECT dp.permission_name, dp.state_desc, pr.name AS principal_name +FROM sys.database_permissions dp +JOIN sys.database_principals pr ON pr.principal_id = dp.grantee_principal_id +WHERE dp.class_desc = 'DATABASE' + AND pr.name = @name +ORDER BY dp.permission_name;", + command => command.Parameters.AddWithValue("@name", principalName), + string.Empty, + referenceLines); + private static IEnumerable ExecutePermissionQuery( SqlConnection connection, string commandText, @@ -4596,11 +4630,15 @@ private static string FormatPermissionLine( string targetClause, string principal) { + var onTarget = string.IsNullOrWhiteSpace(targetClause) + ? string.Empty + : $" ON {targetClause}"; + return normalizedState switch { - "GRANT_WITH_GRANT_OPTION" => $"GRANT {permission} ON {targetClause} TO [{principal}] WITH GRANT OPTION", - "DENY" => $"DENY {permission} ON {targetClause} TO [{principal}]", - _ => $"GRANT {permission} ON {targetClause} TO [{principal}]" + "GRANT_WITH_GRANT_OPTION" => $"GRANT {permission}{onTarget} TO [{principal}] WITH GRANT OPTION", + "DENY" => $"DENY {permission}{onTarget} TO [{principal}]", + _ => $"GRANT {permission}{onTarget} TO [{principal}]" }; } @@ -5475,6 +5513,32 @@ FROM sys.database_permissions dp $"XML SCHEMA COLLECTION::{QuoteIdentifier(schema)}.{QuoteIdentifier(name)}", referenceLines); + private static IEnumerable ReadUserDefinedTypePermissions( + SqlConnection connection, + string schema, + string name, + string[]? referenceLines) + => ExecutePermissionQuery( + connection, + @" +SELECT dp.permission_name, dp.state_desc, pr.name AS principal_name +FROM sys.database_permissions dp +JOIN sys.database_principals pr ON pr.principal_id = dp.grantee_principal_id +JOIN sys.types t ON t.user_type_id = dp.major_id +JOIN sys.schemas s ON s.schema_id = t.schema_id +WHERE dp.class_desc = 'TYPE' + AND s.name = @schema + AND t.name = @name + AND t.is_user_defined = 1 +ORDER BY pr.name, dp.permission_name;", + command => + { + command.Parameters.AddWithValue("@schema", schema); + command.Parameters.AddWithValue("@name", name); + }, + $"TYPE::{QuoteIdentifier(schema)}.{QuoteIdentifier(name)}", + referenceLines); + private static IEnumerable ReadXmlSchemaCollectionExtendedProperties( SqlConnection connection, string schema, diff --git a/src/SqlChangeTracker/Sync/SyncCommandService.cs b/src/SqlChangeTracker/Sync/SyncCommandService.cs index 0c26fa0..89ecb73 100644 --- a/src/SqlChangeTracker/Sync/SyncCommandService.cs +++ b/src/SqlChangeTracker/Sync/SyncCommandService.cs @@ -54,12 +54,21 @@ internal sealed class SyncCommandService : ISyncCommandService private static readonly Regex ClrTableValuedFunctionReturnColumnNullWithCloseParenRegex = new( @"^(?\s*(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*)\s+(?:(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*)(?:\.(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*))?)(?:\s*\([^)]*\))?)\s+NULL(?\s*\)\s*)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex ClrTableValuedFunctionReturnColumnCollationRegex = new( + @"^(?\s*(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*)\s+(?:(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*)(?:\.(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*))?)(?:\s*\([^)]*\))?)\s+COLLATE\s+(?:\[[^\]]+\]|""(?:""""|[^""])+""|[A-Za-z0-9_]+)(?\s+(?:NULL)?\s*,?\s*\)?\s*)$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex RoleMembershipLegacySyntaxRegex = new( @"^\s*EXEC(?:UTE)?\s+sp_addrolemember\s+N'(?(?:''|[^'])*)'\s*,\s*N'(?(?:''|[^'])*)'\s*;?\s*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex RoleMembershipAlterRoleSyntaxRegex = new( @"^\s*ALTER\s+ROLE\s+(?\[[^\]]+(?:\]\])*\]|""(?:""""|[^""])+""|[^\s;]+)\s+ADD\s+MEMBER\s+(?\[[^\]]+(?:\]\])*\]|""(?:""""|[^""])+""|[^\s;]+)\s*;?\s*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex PermissionStatementRegex = new( + @"^\s*(GRANT|DENY)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex InlineCreateTableKeyConstraintRegex = new( + @"^\s*(?:CONSTRAINT\b.+?\b)?(?:PRIMARY\s+KEY|UNIQUE)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex ExtendedPropertyStatementRegex = new( @"^\s*EXEC(?:UTE)?\s+(?:sys\.)?sp_addextendedproperty\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); @@ -1640,8 +1649,9 @@ private static ComparableLine[] BuildComparableLinesForDiff( var keyLines = keyScript.Length == 0 ? Array.Empty() : keyScript.Split('\n'); + keyLines = TrimOptionalTerminalGoLineArray(keyLines); - if (normalizedDiff || !NeedsReadableDiffDisplayNormalization(objectType)) + if (normalizedDiff) { return keyLines.Select(line => new ComparableLine(line, line)).ToArray(); } @@ -1659,6 +1669,7 @@ private static ComparableLine[] BuildComparableLinesForDiff( var displayLines = displayScript.Length == 0 ? Array.Empty() : displayScript.Split('\n'); + displayLines = TrimOptionalTerminalGoLineArray(displayLines); if (keyLines.Length != displayLines.Length) { @@ -1732,6 +1743,11 @@ private static string PrepareScriptForReadableDiffDisplay(string script, string? var joined = string.Join("\n", lines); joined = NormalizeEmptyGoBatchesForComparison(joined); + if (joined.Contains("GRANT ", StringComparison.OrdinalIgnoreCase) || + joined.Contains("DENY ", StringComparison.OrdinalIgnoreCase)) + { + joined = NormalizePermissionBlocksForDiffDisplay(joined); + } if (joined.Contains("sp_addextendedproperty", StringComparison.OrdinalIgnoreCase)) { @@ -1794,15 +1810,30 @@ private static string[] BuildTableDisplayUnitsForDiff(string script) NormalizeTableBlockForDiffDisplay(block))); } - if (!TryBuildComparableLinesForTableLikeBlock(blocks[createTableIndex], out var createTableComparableLines)) + IReadOnlyList extractedInlineConstraintPackages = Array.Empty(); + IReadOnlyList createTableComparableLines; + if (TryPrepareCreateTableBlockForComparison( + blocks[createTableIndex], + out var createTableParts, + out extractedInlineConstraintPackages)) + { + createTableComparableLines = BuildComparableLinesForParsedTableLikeParts(createTableParts); + } + else if (!TryBuildComparableLinesForTableLikeBlock(blocks[createTableIndex], out var fallbackCreateTableComparableLines)) { return null; } + else + { + createTableComparableLines = fallbackCreateTableComparableLines; + } comparableLines.AddRange(createTableComparableLines); - var postCreatePackages = BuildTablePostCreatePackages(blocks, createTableIndex + 1); - foreach (var package in postCreatePackages.OrderBy(NormalizeTablePostCreatePackageForComparison, StringComparer.Ordinal)) + var postCreatePackages = BuildTablePostCreatePackages(blocks, createTableIndex + 1) + .Concat(extractedInlineConstraintPackages) + .OrderBy(NormalizeTablePostCreatePackageForComparison, StringComparer.Ordinal); + foreach (var package in postCreatePackages) { comparableLines.Add(BuildComparableLineForStructuredBlock( NormalizeTablePostCreatePackageForComparison(package), @@ -1847,8 +1878,9 @@ private static string[] BuildUserDefinedTypeDisplayUnitsForDiff(string script) } var comparableLines = new List(); - foreach (var block in blocks) + for (var i = 0; i < blocks.Count; i++) { + var block = blocks[i]; var firstLine = GetFirstMeaningfulLine(block); if (!string.IsNullOrEmpty(firstLine) && firstLine.StartsWith("CREATE TYPE", StringComparison.OrdinalIgnoreCase)) @@ -1862,6 +1894,33 @@ private static string[] BuildUserDefinedTypeDisplayUnitsForDiff(string script) continue; } + if (!string.IsNullOrEmpty(firstLine) && IsPermissionStatementLine(firstLine)) + { + var permissionPackages = new List(); + while (i < blocks.Count) + { + var currentFirstLine = GetFirstMeaningfulLine(blocks[i]); + if (string.IsNullOrEmpty(currentFirstLine) || !IsPermissionStatementLine(currentFirstLine)) + { + break; + } + + permissionPackages.Add(JoinBlockLines(blocks[i])); + i++; + } + + foreach (var permissionPackage in permissionPackages + .OrderBy(package => NormalizeForComparison(package, "UserDefinedType"), StringComparer.Ordinal)) + { + comparableLines.Add(BuildComparableLineForStructuredBlock( + NormalizeForComparison(permissionPackage, "UserDefinedType"), + NormalizeForDiffDisplay(permissionPackage, "UserDefinedType", null))); + } + + i--; + continue; + } + comparableLines.Add(BuildComparableLineForStructuredBlock( JoinBlockLines(block), JoinBlockLines(block))); @@ -1899,6 +1958,37 @@ private static IReadOnlyList GetDisplayUnitsForBlock(IEnumerable private static ComparableLine BuildComparableLineForStructuredBlock(string key, string display) => new(key, display); + private static IReadOnlyList BuildComparableLinesForParsedTableLikeParts(TableLikeStatementParts parts) + { + var lines = new List(parts.Items.Count + 4) + { + new ComparableLine( + NormalizeLegacyTableStatementTextForComparison(parts.Prefix), + parts.Prefix), + new ComparableLine("(", "(") + }; + + foreach (var item in parts.Items) + { + var display = item.HasTrailingComma + ? $" {item.Text}," + : $" {item.Text}"; + var key = NormalizeLegacyTableStatementTextForComparison( + item.HasTrailingComma ? item.Text + "," : item.Text); + lines.Add(new ComparableLine(key, display)); + } + + var closeDisplay = string.IsNullOrWhiteSpace(parts.Suffix) + ? ")" + : $") {parts.Suffix}"; + lines.Add(new ComparableLine( + NormalizeLegacyTableStatementTextForComparison(closeDisplay), + closeDisplay)); + lines.Add(new ComparableLine("GO", "GO")); + + return lines; + } + private static bool TryBuildComparableLinesForTableLikeBlock( IEnumerable block, out IReadOnlyList comparableLines) @@ -1921,36 +2011,73 @@ private static bool TryBuildComparableLinesForTableLikeBlock( return false; } - var lines = new List(parts.Items.Count + 4) + comparableLines = BuildComparableLinesForParsedTableLikeParts(parts); + return true; + } + + private static bool TryPrepareCreateTableBlockForComparison( + IEnumerable block, + out TableLikeStatementParts createTableParts, + out IReadOnlyList extractedInlineConstraintPackages) + { + var statement = string.Join( + " ", + block.Where(line => !string.Equals(line.Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + .Select(line => line.Trim()) + .Where(line => line.Length > 0)); + + if (statement.Length == 0 || + !TryParseTableLikeStatementForDiffDisplay(statement, out var parts) || + !parts.Prefix.StartsWith("CREATE TABLE", StringComparison.OrdinalIgnoreCase)) { - new ComparableLine( - NormalizeLegacyTableStatementTextForComparison(parts.Prefix), - parts.Prefix), - new ComparableLine("(", "(") - }; + createTableParts = null!; + extractedInlineConstraintPackages = Array.Empty(); + return false; + } - foreach (var item in parts.Items) + var createTableTarget = parts.Prefix["CREATE TABLE".Length..].Trim(); + if (createTableTarget.Length == 0) { - var display = item.HasTrailingComma - ? $" {item.Text}," - : $" {item.Text}"; - var key = NormalizeLegacyTableStatementTextForComparison( - item.HasTrailingComma ? item.Text + "," : item.Text); - lines.Add(new ComparableLine(key, display)); + createTableParts = null!; + extractedInlineConstraintPackages = Array.Empty(); + return false; } - var closeDisplay = string.IsNullOrWhiteSpace(parts.Suffix) - ? ")" - : $") {parts.Suffix}"; - lines.Add(new ComparableLine( - NormalizeLegacyTableStatementTextForComparison(closeDisplay), - closeDisplay)); - lines.Add(new ComparableLine("GO", "GO")); + var remainingItemTexts = new List(parts.Items.Count); + var extractedPackages = new List(); + foreach (var item in parts.Items) + { + if (IsInlineCreateTableKeyConstraintItem(item.Text)) + { + extractedPackages.Add($"ALTER TABLE {createTableTarget} ADD {item.Text}\nGO"); + continue; + } + + remainingItemTexts.Add(item.Text); + } - comparableLines = lines; + createTableParts = RebuildTableLikeStatementParts(parts.Prefix, remainingItemTexts, parts.Suffix); + extractedInlineConstraintPackages = extractedPackages; return true; } + private static bool IsInlineCreateTableKeyConstraintItem(string text) + => InlineCreateTableKeyConstraintRegex.IsMatch(text); + + private static TableLikeStatementParts RebuildTableLikeStatementParts( + string prefix, + IReadOnlyList itemTexts, + string suffix) + { + var items = new List(itemTexts.Count); + for (var i = 0; i < itemTexts.Count; i++) + { + items.Add(new TableLikeBodyItem(itemTexts[i], i < itemTexts.Count - 1)); + } + + return new TableLikeStatementParts(prefix, items, suffix); + } + private static bool TryBuildGroupedComparableLines( IReadOnlyList keyLines, IReadOnlyList displayLines, @@ -2011,12 +2138,26 @@ private static bool TryBuildGroupedComparableLines( private static bool ComparableLinesEqual(ComparableLine[] source, ComparableLine[] target) { - if (source.Length != target.Length) + var sourceLength = source.Length; + while (sourceLength > 0 && + string.Equals(source[sourceLength - 1].Key, "GO", StringComparison.OrdinalIgnoreCase)) + { + sourceLength--; + } + + var targetLength = target.Length; + while (targetLength > 0 && + string.Equals(target[targetLength - 1].Key, "GO", StringComparison.OrdinalIgnoreCase)) + { + targetLength--; + } + + if (sourceLength != targetLength) { return false; } - for (var i = 0; i < source.Length; i++) + for (var i = 0; i < sourceLength; i++) { if (!string.Equals(source[i].Key, target[i].Key, StringComparison.Ordinal)) { @@ -2530,27 +2671,23 @@ internal static string NormalizeForComparison( if (string.Equals(objectType, "Queue", StringComparison.OrdinalIgnoreCase)) { - return NormalizeQueueScriptForComparison(joined); + joined = NormalizeQueueScriptForComparison(joined); } - - if (string.Equals(objectType, "Role", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(objectType, "Role", StringComparison.OrdinalIgnoreCase)) { - return NormalizeRoleScriptForComparison(joined); + joined = NormalizeRoleScriptForComparison(joined); } - - if (string.Equals(objectType, "MessageType", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(objectType, "MessageType", StringComparison.OrdinalIgnoreCase)) { - return NormalizeServiceBrokerScriptForComparison(joined, NormalizeMessageTypeBaseBlockForComparison); + joined = NormalizeServiceBrokerScriptForComparison(joined, NormalizeMessageTypeBaseBlockForComparison); } - - if (string.Equals(objectType, "Contract", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(objectType, "Contract", StringComparison.OrdinalIgnoreCase)) { - return NormalizeServiceBrokerScriptForComparison(joined, NormalizeContractBaseBlockForComparison); + joined = NormalizeServiceBrokerScriptForComparison(joined, NormalizeContractBaseBlockForComparison); } - - if (string.Equals(objectType, "Service", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(objectType, "Service", StringComparison.OrdinalIgnoreCase)) { - return NormalizeServiceBrokerScriptForComparison(joined, NormalizeServiceBaseBlockForComparison); + joined = NormalizeServiceBrokerScriptForComparison(joined, NormalizeServiceBaseBlockForComparison); } if (string.Equals(objectType, "Function", StringComparison.OrdinalIgnoreCase)) @@ -2558,6 +2695,12 @@ internal static string NormalizeForComparison( joined = NormalizeClrTableValuedFunctionScriptForComparison(joined); } + if (joined.Contains("GRANT ", StringComparison.OrdinalIgnoreCase) || + joined.Contains("DENY ", StringComparison.OrdinalIgnoreCase)) + { + joined = NormalizePermissionBlocksForComparison(joined); + } + if (joined.Contains("sp_addextendedproperty", StringComparison.OrdinalIgnoreCase)) { joined = NormalizeExtendedPropertyBlocksForComparison(joined); @@ -2575,6 +2718,8 @@ internal static string NormalizeForComparison( joined = NormalizeUserDefinedTypeScriptForComparison(joined); } + joined = RemoveOptionalTerminalGoForComparison(joined); + if (!joined.Contains("INSERT ", StringComparison.OrdinalIgnoreCase)) { return joined; @@ -2686,9 +2831,16 @@ private static string[] TrimLeadingEmptyLinesForComparison(string[] lines) private static string[] RemoveEmptyLinesForComparison(string[] lines) => lines.Where(line => line.Length > 0).ToArray(); - private static bool NeedsReadableDiffDisplayNormalization(string? objectType) - => string.Equals(objectType, "Table", StringComparison.OrdinalIgnoreCase) - || string.Equals(objectType, "UserDefinedType", StringComparison.OrdinalIgnoreCase); + private static string[] TrimOptionalTerminalGoLineArray(string[] lines) + { + if (lines.Length == 0 || + !string.Equals(lines[^1].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + { + return lines; + } + + return lines[..^1]; + } private static string NormalizeForDiffDisplay( string script, @@ -2734,15 +2886,15 @@ private static string NormalizeForDiffDisplay( joined = NormalizeCompatibleOmittedTextImageOnForComparison( joined, compatibleOmittedTextImageOnDataSpaceName); - return NormalizeTableScriptForDiffDisplay(joined); + return RemoveOptionalTerminalGoForComparison(NormalizeTableScriptForDiffDisplay(joined)); } if (string.Equals(objectType, "UserDefinedType", StringComparison.OrdinalIgnoreCase)) { - return NormalizeUserDefinedTypeScriptForDiffDisplay(joined); + return RemoveOptionalTerminalGoForComparison(NormalizeUserDefinedTypeScriptForDiffDisplay(joined)); } - return joined; + return RemoveOptionalTerminalGoForComparison(joined); } private static string NormalizeQueueScriptForComparison(string script) @@ -2938,7 +3090,7 @@ private static string NormalizeClrTableValuedFunctionScriptForComparison(string var outputLines = new List(inputLines.Length); for (var i = 0; i < inputLines.Length; i++) { - var line = inputLines[i]; + var line = NormalizeClrTableValuedFunctionReturnColumnCollationForComparison(inputLines[i]); var splitMatch = ClrTableValuedFunctionReturnColumnNullWithCloseParenRegex.Match(line); if (splitMatch.Success) { @@ -2956,6 +3108,14 @@ private static string NormalizeClrTableValuedFunctionScriptForComparison(string return string.Join("\n", outputLines); } + private static string NormalizeClrTableValuedFunctionReturnColumnCollationForComparison(string line) + { + var match = ClrTableValuedFunctionReturnColumnCollationRegex.Match(line); + return match.Success + ? match.Groups["prefix"].Value + match.Groups["suffix"].Value + : line; + } + // Checks whether a line begins with "INSERT " (ignoring leading whitespace) without // allocating a trimmed string. private static bool LineStartsWithInsert(string line) @@ -3034,6 +3194,86 @@ static void FlushBufferedInsertStatements(List segments, List in return string.Join("\n", normalizedSegments); } + private static string NormalizePermissionBlocksForComparison(string script) + { + var lines = script.Split('\n'); + var normalizedLines = new List(lines.Length); + + for (var i = 0; i < lines.Length; i++) + { + if (IsPermissionStatementLine(lines[i]) && + (i + 1 == lines.Length || + string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase))) + { + var statements = new List(); + while (i < lines.Length && + IsPermissionStatementLine(lines[i]) && + (i + 1 == lines.Length || + string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase))) + { + statements.Add(NormalizePermissionStatementForComparison(lines[i])); + i += i + 1 < lines.Length && + string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase) + ? 2 + : 1; + } + + foreach (var statement in statements.OrderBy(item => item, StringComparer.Ordinal)) + { + normalizedLines.Add(statement); + normalizedLines.Add("GO"); + } + + i--; + continue; + } + + normalizedLines.Add(lines[i]); + } + + return string.Join("\n", normalizedLines); + } + + private static string NormalizePermissionBlocksForDiffDisplay(string script) + { + var lines = script.Split('\n'); + var normalizedLines = new List(lines.Length); + + for (var i = 0; i < lines.Length; i++) + { + if (IsPermissionStatementLine(lines[i]) && + (i + 1 == lines.Length || + string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase))) + { + var statements = new List<(string Key, string Display)>(); + while (i < lines.Length && + IsPermissionStatementLine(lines[i]) && + (i + 1 == lines.Length || + string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase))) + { + statements.Add((NormalizePermissionStatementForComparison(lines[i]), lines[i].Trim())); + i += i + 1 < lines.Length && + string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase) + ? 2 + : 1; + } + + foreach (var statement in statements.OrderBy(item => item.Key, StringComparer.Ordinal)) + { + normalizedLines.Add(statement.Display); + normalizedLines.Add("GO"); + } + + i--; + continue; + } + + normalizedLines.Add(lines[i]); + } + + return string.Join("\n", normalizedLines); + } + private static string NormalizeExtendedPropertyBlocksForComparison(string script) { var lines = script.Split('\n'); @@ -3042,17 +3282,20 @@ private static string NormalizeExtendedPropertyBlocksForComparison(string script for (var i = 0; i < lines.Length; i++) { if (IsExtendedPropertyStatementLine(lines[i]) && - i + 1 < lines.Length && - string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + (i + 1 == lines.Length || + string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase))) { var statements = new List(); while (i < lines.Length && IsExtendedPropertyStatementLine(lines[i]) && - i + 1 < lines.Length && - string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + (i + 1 == lines.Length || + string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase))) { statements.Add(NormalizeExtendedPropertyStatementForComparison(lines[i])); - i += 2; + i += i + 1 < lines.Length && + string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase) + ? 2 + : 1; } foreach (var statement in statements.OrderBy(item => item, StringComparer.Ordinal)) @@ -3071,6 +3314,12 @@ private static string NormalizeExtendedPropertyBlocksForComparison(string script return string.Join("\n", normalizedLines); } + private static bool IsPermissionStatementLine(string line) + => PermissionStatementRegex.IsMatch(line); + + private static string NormalizePermissionStatementForComparison(string statement) + => NormalizeSqlStatementTokensForComparison(statement); + private static string NormalizeTableScriptForComparison(string script) { var blocks = SplitGoDelimitedBlocks(script); @@ -3087,9 +3336,27 @@ private static string NormalizeTableScriptForComparison(string script) var normalizedBlocks = new List(blocks.Count); normalizedBlocks.AddRange(blocks.Take(createTableIndex).Select(NormalizeTableBlockForComparison)); - normalizedBlocks.Add(NormalizeTableBlockForComparison(blocks[createTableIndex])); + IReadOnlyList extractedInlineConstraintPackages = Array.Empty(); + if (TryPrepareCreateTableBlockForComparison( + blocks[createTableIndex], + out var createTableParts, + out extractedInlineConstraintPackages)) + { + var createTableStatement = createTableParts.Prefix + " (" + string.Join(", ", createTableParts.Items.Select(item => item.Text)) + ")"; + if (!string.IsNullOrWhiteSpace(createTableParts.Suffix)) + { + createTableStatement += " " + createTableParts.Suffix; + } - var postCreatePackages = BuildTablePostCreatePackages(blocks, createTableIndex + 1); + normalizedBlocks.Add(NormalizeLegacyTableStatementTextForComparison(createTableStatement) + "\nGO"); + } + else + { + normalizedBlocks.Add(NormalizeTableBlockForComparison(blocks[createTableIndex])); + } + + var postCreatePackages = BuildTablePostCreatePackages(blocks, createTableIndex + 1) + .Concat(extractedInlineConstraintPackages); foreach (var package in postCreatePackages .Select(NormalizeTablePostCreatePackageForComparison) .OrderBy(NormalizeTablePostCreatePackageKey, StringComparer.Ordinal)) @@ -4010,8 +4277,7 @@ private static string NormalizeExtendedPropertyArgumentValueForComparison(string return "NULL"; } - if (!string.Equals(parameterName, "value", StringComparison.OrdinalIgnoreCase) && - trimmed.Length >= 3 && + if (trimmed.Length >= 3 && (trimmed[0] == 'N' || trimmed[0] == 'n') && trimmed[1] == '\'' && trimmed[^1] == '\'') @@ -4033,6 +4299,23 @@ private static void TrimTrailingSpaces(StringBuilder builder) private static string StripTrailingSemicolon(string line) => line.EndsWith(';') ? line[..^1] : line; + private static string RemoveOptionalTerminalGoForComparison(string script) + { + if (script.Length == 0) + { + return script; + } + + var lines = script.Split('\n').ToList(); + if (lines.Count > 0 && + string.Equals(lines[^1].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + { + lines.RemoveAt(lines.Count - 1); + } + + return string.Join("\n", lines); + } + private static (int StatementEndExclusive, int ConsumedEndExclusive)? TryFindInsertValuesStatementRange( string script, int start) @@ -4139,6 +4422,94 @@ private static (int StatementEndExclusive, int ConsumedEndExclusive)? TryFindIns } private static string NormalizeLegacyTableDataInsertStatement(string line) + { + if (TryNormalizeLegacyTableDataInsertColumnOrder(line, out var reordered)) + { + line = reordered; + } + + return StripTopLevelInsertUnicodePrefixes(line); + } + + private static bool TryNormalizeLegacyTableDataInsertColumnOrder(string statement, out string normalized) + { + normalized = statement; + + var valuesKeywordIndex = statement.IndexOf("VALUES", StringComparison.OrdinalIgnoreCase); + if (valuesKeywordIndex < 0) + { + return false; + } + + var columnListOpenParenIndex = statement.IndexOf('('); + if (columnListOpenParenIndex < 0 || columnListOpenParenIndex > valuesKeywordIndex) + { + return false; + } + + var columnListCloseParenIndex = FindMatchingCloseParenthesis(statement, columnListOpenParenIndex); + if (columnListCloseParenIndex < 0 || columnListCloseParenIndex > valuesKeywordIndex) + { + return false; + } + + var valuesOpenParenIndex = statement.IndexOf('(', valuesKeywordIndex); + if (valuesOpenParenIndex < 0) + { + return false; + } + + var valuesCloseParenIndex = FindMatchingCloseParenthesis(statement, valuesOpenParenIndex); + if (valuesCloseParenIndex < 0) + { + return false; + } + + var columns = SplitTopLevelSqlList(statement[(columnListOpenParenIndex + 1)..columnListCloseParenIndex]) + .Select(item => item.Trim()) + .ToArray(); + var values = SplitTopLevelSqlList(statement[(valuesOpenParenIndex + 1)..valuesCloseParenIndex]) + .Select(item => item.Trim()) + .ToArray(); + + if (columns.Length == 0 || columns.Length != values.Length) + { + return false; + } + + var mappings = new List<(string Key, string Column, string Value)>(columns.Length); + for (var i = 0; i < columns.Length; i++) + { + if (columns[i].Length == 0) + { + return false; + } + + mappings.Add(( + NormalizeSqlStatementTokensForComparison(columns[i]), + columns[i], + values[i])); + } + + mappings.Sort((left, right) => StringComparer.Ordinal.Compare(left.Key, right.Key)); + + var prefix = statement[..columnListOpenParenIndex].TrimEnd(); + var between = statement[(columnListCloseParenIndex + 1)..valuesOpenParenIndex].Trim(); + var suffix = statement[(valuesCloseParenIndex + 1)..].Trim(); + + normalized = + $"{prefix} ({string.Join(", ", mappings.Select(mapping => mapping.Column))}) " + + $"{between} ({string.Join(", ", mappings.Select(mapping => mapping.Value))})"; + + if (suffix.Length > 0) + { + normalized += " " + suffix; + } + + return true; + } + + private static string StripTopLevelInsertUnicodePrefixes(string line) { var valuesKeywordIndex = line.IndexOf("VALUES", StringComparison.OrdinalIgnoreCase); if (valuesKeywordIndex < 0) diff --git a/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs b/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs index f2038af..7f05d68 100644 --- a/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs +++ b/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs @@ -26,6 +26,33 @@ public void ResolveParallelism_ReturnsConfiguredValue_WhenPositive(int configure Assert.Equal(configured, resolved); } + [Theory] + [InlineData("sp_alterdiagram", true)] + [InlineData("sp_creatediagram", true)] + [InlineData("sp_dropdiagram", true)] + [InlineData("sp_helpdiagramdefinition", true)] + [InlineData("sp_helpdiagrams", true)] + [InlineData("sp_renamediagram", true)] + [InlineData("sp_upgraddiagrams", true)] + [InlineData("sp_CustomImport", false)] + [InlineData("usp_ProcessBatch", false)] + public void IsExcludedStoredProcedureName_RecognizesOnlyDatabaseDiagramSupportProcedures(string name, bool expected) + { + var actual = SqlServerIntrospector.IsExcludedStoredProcedureName(name); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("sp_helpdiagrams", false)] + [InlineData("usp_ProcessBatch", true)] + public void ShouldIncludeObject_FiltersOnlyExcludedDatabaseDiagramStoredProcedures(string name, bool expected) + { + var actual = SqlServerIntrospector.ShouldIncludeObject(new DbObjectInfo("dbo", name, "StoredProcedure")); + + Assert.Equal(expected, actual); + } + [Fact] public void ListObjects_ReturnsResults_WhenConfigured() { diff --git a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs index 794e638..4ae419a 100644 --- a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs +++ b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs @@ -318,6 +318,38 @@ public void ScriptSchemaRoleAndUser_EmitExpectedStatements_WhenSupportedObjectsE Assert.Contains($"CREATE USER [{user.Name}] WITHOUT LOGIN", userScript); } + [Fact] + public void ScriptRoleAndUser_EmitDatabasePermissionsAndWithoutLogin_WhenPresent() + { + var server = Environment.GetEnvironmentVariable("SQLCT_TEST_SERVER"); + if (string.IsNullOrWhiteSpace(server)) + { + return; + } + + var databaseName = $"SqlctPrincipalPerms_{Guid.NewGuid():N}"; + try + { + CreatePrincipalPermissionsFixtureDatabase(server, databaseName); + var options = new SqlConnectionOptions(server, databaseName, "integrated", null, null, true); + + var scripter = new SqlServerScripter(); + var roleScript = scripter.ScriptObject(options, new DbObjectInfo(string.Empty, "AppViewer", "Role")); + var userScript = scripter.ScriptObject(options, new DbObjectInfo(string.Empty, "AppUser", "User")); + + Assert.Contains("CREATE ROLE [AppViewer]", roleScript); + Assert.Contains("EXEC sp_addrolemember N'AppViewer', N'AppUser'", roleScript); + Assert.Contains("GRANT VIEW DATABASE STATE TO [AppViewer]", roleScript); + + Assert.Contains("CREATE USER [AppUser] WITHOUT LOGIN WITH DEFAULT_SCHEMA=[AppSchema]", userScript); + Assert.Contains("GRANT VIEW DEFINITION TO [AppUser]", userScript); + } + finally + { + DropDatabase(server, databaseName); + } + } + [Fact] public void ScriptSchema_EmitsExtendedProperties_WhenSchemaWithExtendedPropertiesExists() { @@ -472,6 +504,36 @@ public void ScriptUserDefinedType_EmitsCreateType_ForAdventureWorksWhenConfigure Assert.Contains("GO", script); } + [Fact] + public void ScriptUserDefinedType_EmitsTypePermissions_WhenPresent() + { + var server = Environment.GetEnvironmentVariable("SQLCT_TEST_SERVER"); + if (string.IsNullOrWhiteSpace(server)) + { + return; + } + + var databaseName = $"SqlctTypePerms_{Guid.NewGuid():N}"; + try + { + CreateUserDefinedTypePermissionsFixtureDatabase(server, databaseName); + var options = new SqlConnectionOptions(server, databaseName, "integrated", null, null, true); + + var scripter = new SqlServerScripter(); + var scalarScript = scripter.ScriptObject(options, new DbObjectInfo("dbo", "SampleCodeType", "UserDefinedType", UserDefinedTypeKind.Scalar)); + var tableScript = scripter.ScriptObject(options, new DbObjectInfo("dbo", "SampleCodeTableType", "UserDefinedType", UserDefinedTypeKind.Table)); + + Assert.Contains("GRANT EXECUTE ON TYPE::[dbo].[SampleCodeType] TO [AppConsumer]", scalarScript); + Assert.Contains("GRANT REFERENCES ON TYPE::[dbo].[SampleCodeType] TO [AppConsumer]", scalarScript); + Assert.Contains("GRANT EXECUTE ON TYPE::[dbo].[SampleCodeTableType] TO [AppConsumer]", tableScript); + Assert.Contains("GRANT REFERENCES ON TYPE::[dbo].[SampleCodeTableType] TO [AppConsumer]", tableScript); + } + finally + { + DropDatabase(server, databaseName); + } + } + [Fact] public void ScriptProcedure_EmitsParameterExtendedProperties_WhenProcedureWithParameterExtendedPropertiesExists() { @@ -1128,6 +1190,76 @@ INSERT INTO [dbo].[SampleTable] ([KeyAlpha], [KeyBeta], [KeyGamma], [StatusFlag] return expectedStatisticsLine; } + private static void CreateUserDefinedTypePermissionsFixtureDatabase(string server, string databaseName) + { + using var connection = SqlConnectionFactory.Create(new SqlConnectionOptions(server, "master", "integrated", null, null, true)); + connection.Open(); + + using (var createDatabase = connection.CreateCommand()) + { + createDatabase.CommandText = $"CREATE DATABASE [{databaseName}];"; + createDatabase.ExecuteNonQuery(); + } + + using var fixtureConnection = SqlConnectionFactory.Create(new SqlConnectionOptions(server, databaseName, "integrated", null, null, true)); + fixtureConnection.Open(); + + var setupStatements = new[] + { + "CREATE ROLE [AppConsumer];", + "CREATE TYPE [dbo].[SampleCodeType] FROM [nvarchar](20) NOT NULL;", + """ +CREATE TYPE [dbo].[SampleCodeTableType] AS TABLE ( + [SequenceId] [int] NOT NULL, + [CodeValue] [nvarchar](20) NOT NULL +); +""", + "GRANT REFERENCES ON TYPE::[dbo].[SampleCodeType] TO [AppConsumer];", + "GRANT EXECUTE ON TYPE::[dbo].[SampleCodeType] TO [AppConsumer];", + "GRANT REFERENCES ON TYPE::[dbo].[SampleCodeTableType] TO [AppConsumer];", + "GRANT EXECUTE ON TYPE::[dbo].[SampleCodeTableType] TO [AppConsumer];" + }; + + foreach (var statement in setupStatements) + { + using var command = fixtureConnection.CreateCommand(); + command.CommandText = statement; + command.ExecuteNonQuery(); + } + } + + private static void CreatePrincipalPermissionsFixtureDatabase(string server, string databaseName) + { + using var connection = SqlConnectionFactory.Create(new SqlConnectionOptions(server, "master", "integrated", null, null, true)); + connection.Open(); + + using (var createDatabase = connection.CreateCommand()) + { + createDatabase.CommandText = $"CREATE DATABASE [{databaseName}];"; + createDatabase.ExecuteNonQuery(); + } + + using var fixtureConnection = SqlConnectionFactory.Create(new SqlConnectionOptions(server, databaseName, "integrated", null, null, true)); + fixtureConnection.Open(); + + var setupStatements = new[] + { + "CREATE SCHEMA [AppSchema] AUTHORIZATION [dbo];", + "CREATE ROLE [AppViewer] AUTHORIZATION [dbo];", + "CREATE USER [AppUser] WITHOUT LOGIN WITH DEFAULT_SCHEMA=[AppSchema];", + "EXEC sp_addrolemember N'AppViewer', N'AppUser';", + "GRANT VIEW DATABASE STATE TO [AppViewer];", + "GRANT VIEW DEFINITION TO [AppUser];" + }; + + foreach (var statement in setupStatements) + { + using var command = fixtureConnection.CreateCommand(); + command.CommandText = statement; + command.ExecuteNonQuery(); + } + } + private static (string PrimaryKeyLine, string IndexLine) CreateIndexOptionsFixtureDatabase(string server, string databaseName) { using var connection = SqlConnectionFactory.Create(new SqlConnectionOptions(server, "master", "integrated", null, null, true)); diff --git a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs index f8d5174..1b2cb15 100644 --- a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs +++ b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs @@ -999,10 +999,69 @@ public void BuildUnifiedDiff_Role_PreservesMembershipTargetDifferences() var diff = SyncCommandService.BuildUnifiedDiff("Role", "db", "folder", source, target); - Assert.Contains("ALTER ROLE [db_datareader] ADD MEMBER [ReadOnlyUser]", diff); + Assert.Contains("EXEC sp_addrolemember N'db_datareader', N'ReadOnlyUser'", diff); Assert.Contains("ALTER ROLE [db_datareader] ADD MEMBER [ReportUser]", diff); } + [Fact] + public void BuildUnifiedDiff_UserDefinedType_SuppressesEquivalentPermissionOrderDifferences() + { + var source = + "CREATE TYPE [dbo].[SampleType] AS TABLE\n" + + "(\n" + + " [ItemId] [smallint] NOT NULL\n" + + ")\n" + + "GO\n" + + "GRANT EXECUTE ON TYPE:: [dbo].[SampleType] TO [AppRole]\n" + + "GO\n" + + "GRANT REFERENCES ON TYPE:: [dbo].[SampleType] TO [AppRole]\n" + + "GO"; + var target = + "CREATE TYPE [dbo].[SampleType] AS TABLE\n" + + "(\n" + + " [ItemId] [smallint] NOT NULL\n" + + ")\n" + + "GO\n" + + "GRANT REFERENCES ON TYPE:: [dbo].[SampleType] TO [AppRole]\n" + + "GO\n" + + "GRANT EXECUTE ON TYPE:: [dbo].[SampleType] TO [AppRole]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("UserDefinedType", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_UserDefinedType_PreservesPermissionDifferencesWhenOrderAlsoDiffers() + { + var source = + "CREATE TYPE [dbo].[SampleType] AS TABLE\n" + + "(\n" + + " [ItemId] [smallint] NOT NULL\n" + + ")\n" + + "GO\n" + + "GRANT EXECUTE ON TYPE:: [dbo].[SampleType] TO [AppRole]\n" + + "GO\n" + + "GRANT REFERENCES ON TYPE:: [dbo].[SampleType] TO [AppRole]\n" + + "GO"; + var target = + "CREATE TYPE [dbo].[SampleType] AS TABLE\n" + + "(\n" + + " [ItemId] [smallint] NOT NULL\n" + + ")\n" + + "GO\n" + + "GRANT REFERENCES ON TYPE:: [dbo].[SampleType] TO [AppRole]\n" + + "GO\n" + + "GRANT EXECUTE ON TYPE:: [dbo].[SampleType] TO [OtherRole]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("UserDefinedType", "db", "folder", source, target); + + Assert.Contains("GRANT EXECUTE ON TYPE:: [dbo].[SampleType] TO [AppRole]", diff); + Assert.Contains("GRANT EXECUTE ON TYPE:: [dbo].[SampleType] TO [OtherRole]", diff); + } + [Fact] public void BuildUnifiedDiff_MessageType_SuppressesLegacyValidationXmlSynonymAndSpacing() { @@ -1109,7 +1168,7 @@ public void BuildUnifiedDiff_Service_PreservesContractMembershipDifferences() var diff = SyncCommandService.BuildUnifiedDiff("Service", "db", "folder", source, target); - Assert.Contains("ON QUEUE [dbo].[AppTargetQueue]([//App/Contract])", diff); + Assert.Contains("ON QUEUE [dbo].[AppTargetQueue] ([//App/Contract])", diff); } [Fact] @@ -1215,6 +1274,41 @@ public void BuildUnifiedDiff_Function_ReportsOnlyClrTableValuedFunctionExternalN Assert.DoesNotContain("[RndValue] [int] NULL)", diff); } + [Fact] + public void BuildUnifiedDiff_Function_SuppressesClrTableValuedFunctionCollationAndNullDifferences() + { + var source = + "SET QUOTED_IDENTIFIER OFF\n" + + "GO\n" + + "SET ANSI_NULLS OFF\n" + + "GO\n" + + "CREATE FUNCTION [dbo].[DecodeToken] (@input [nvarchar] (MAX))\n" + + "RETURNS TABLE (\n" + + "[TextCode] [nvarchar] (100),\n" + + "[SequenceId] [int]\n" + + ")\n" + + "WITH EXECUTE AS CALLER\n" + + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[DecodeToken]\n" + + "GO"; + var target = + "SET QUOTED_IDENTIFIER OFF\n" + + "GO\n" + + "SET ANSI_NULLS OFF\n" + + "GO\n" + + "CREATE FUNCTION [dbo].[DecodeToken] (@input [nvarchar] (MAX))\n" + + "RETURNS TABLE (\n" + + "[TextCode] [nvarchar] (100) COLLATE Polish_CI_AS NULL,\n" + + "[SequenceId] [int]\n" + + ")\n" + + "WITH EXECUTE AS CALLER\n" + + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[DecodeToken]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Function", "db", "folder", source, target); + + Assert.Empty(diff); + } + [Fact] public void NormalizeForComparison_TableData_NormalizesLegacyIdentityInsertAndUnicodeLiteralPrefixes() { @@ -1340,6 +1434,37 @@ public void BuildUnifiedDiff_TableData_SuppressesEquivalentInsertOrderDifference Assert.Empty(diff); } + [Fact] + public void NormalizeForComparison_TableData_NormalizesEquivalentInsertColumnOrderDifferences() + { + var canonical = SyncCommandService.NormalizeForComparison( + "INSERT INTO [dbo].[SampleConfig] ([ConfigCode], [TaskCode], [DisplayName], [IsEnabled]) VALUES ('A01', 'TaskAlpha', 'Alpha', 1);", + SyncCommandService.TableDataObjectType); + var reordered = SyncCommandService.NormalizeForComparison( + "INSERT INTO [dbo].[SampleConfig] ([ConfigCode], [DisplayName], [IsEnabled], [TaskCode]) VALUES ('A01', 'Alpha', 1, 'TaskAlpha')", + SyncCommandService.TableDataObjectType); + + Assert.Equal(canonical, reordered); + } + + [Fact] + public void BuildUnifiedDiff_TableData_SuppressesEquivalentInsertColumnOrderDifferences() + { + var source = + "INSERT INTO [dbo].[SampleConfig] ([ConfigCode], [TaskCode], [DisplayName], [IsEnabled]) VALUES ('A01', 'TaskAlpha', 'Alpha', 1);"; + var target = + "INSERT INTO [dbo].[SampleConfig] ([ConfigCode], [DisplayName], [IsEnabled], [TaskCode]) VALUES ('A01', 'Alpha', 1, 'TaskAlpha')"; + + var diff = SyncCommandService.BuildUnifiedDiff( + SyncCommandService.TableDataObjectType, + "db", + "folder", + source, + target); + + Assert.Empty(diff); + } + [Fact] public void BuildUnifiedDiff_TableData_PreservesValueDifferencesWhenInsertOrderAlsoDiffers() { @@ -1361,6 +1486,25 @@ public void BuildUnifiedDiff_TableData_PreservesValueDifferencesWhenInsertOrderA Assert.Contains("VALUES (1, 'Z')", diff); } + [Fact] + public void BuildUnifiedDiff_TableData_PreservesValueDifferencesWhenInsertColumnOrderAlsoDiffers() + { + var source = + "INSERT INTO [dbo].[SampleConfig] ([ConfigCode], [TaskCode], [DisplayName], [IsEnabled]) VALUES ('A01', 'TaskAlpha', 'Alpha', 1);"; + var target = + "INSERT INTO [dbo].[SampleConfig] ([ConfigCode], [DisplayName], [IsEnabled], [TaskCode]) VALUES ('A01', 'Alpha', 1, 'TaskBeta')"; + + var diff = SyncCommandService.BuildUnifiedDiff( + SyncCommandService.TableDataObjectType, + "db", + "folder", + source, + target); + + Assert.Contains("'TaskAlpha'", diff); + Assert.Contains("'TaskBeta'", diff); + } + [Fact] public void BuildUnifiedDiff_Table_SuppressesEquivalentExtendedPropertyOrderAndSpacingDifferences() { @@ -1394,6 +1538,35 @@ public void BuildUnifiedDiff_Table_SuppressesEquivalentExtendedPropertyOrderAndS Assert.Empty(diff); } + [Fact] + public void BuildUnifiedDiff_Table_SuppressesEquivalentExtendedPropertyOrderAndUnicodePrefixDifferences() + { + var source = + "CREATE TABLE [dbo].[SessionLog]\n" + + "(\n" + + "[SessionLogID] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "EXEC sp_addextendedproperty 'MS_Description', N'Primary detail', 'SCHEMA', 'dbo', 'TABLE', 'SessionLog', 'COLUMN', 'DetailAlpha'\n" + + "GO\n" + + "EXEC sp_addextendedproperty 'MS_Description', N'Secondary detail', 'SCHEMA', 'dbo', 'TABLE', 'SessionLog', 'COLUMN', 'DetailBeta'\n" + + "GO"; + var target = + "CREATE TABLE [dbo].[SessionLog]\n" + + "(\n" + + "[SessionLogID] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "EXEC sp_addextendedproperty 'MS_Description', 'Secondary detail', 'SCHEMA', 'dbo', 'TABLE', 'SessionLog', 'COLUMN', 'DetailBeta'\n" + + "GO\n" + + "EXEC sp_addextendedproperty 'MS_Description', 'Primary detail', 'SCHEMA', 'dbo', 'TABLE', 'SessionLog', 'COLUMN', 'DetailAlpha'\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Empty(diff); + } + [Fact] public void BuildUnifiedDiff_Table_PreservesExtendedPropertyValueDifferencesWhenOrderAlsoDiffers() { @@ -1420,8 +1593,8 @@ public void BuildUnifiedDiff_Table_PreservesExtendedPropertyValueDifferencesWhen var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); - Assert.Contains("N'System details'", diff); - Assert.Contains("N'Updated system details'", diff); + Assert.Contains("'System details'", diff); + Assert.Contains("'Updated system details'", diff); } [Fact] @@ -1640,6 +1813,57 @@ public void BuildUnifiedDiff_UserDefinedType_SuppressesEquivalentLegacyTableValu Assert.Empty(diff); } + [Fact] + public void BuildUnifiedDiff_Table_SuppressesEquivalentInlineKeyConstraintAndPostCreateKeyConstraintDifferences() + { + var source = + "CREATE TABLE [dbo].[MigrationLog]\n" + + "(\n" + + " [MigrationName] [varchar] (255) NOT NULL,\n" + + " [ExecutedAt] [datetime] NOT NULL\n" + + ") ON [PRIMARY]\n" + + "GO\n" + + "ALTER TABLE [dbo].[MigrationLog] ADD CONSTRAINT [PK_MigrationLog] PRIMARY KEY CLUSTERED ([MigrationName]) ON [PRIMARY]\n" + + "GO"; + var target = + "CREATE TABLE dbo.MigrationLog(\n" + + " MigrationName VARCHAR(255) NOT NULL,\n" + + " ExecutedAt DATETIME NOT NULL,\n" + + " CONSTRAINT [PK_MigrationLog] PRIMARY KEY CLUSTERED ( MigrationName ) ON PRIMARY\n" + + ") ON PRIMARY\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Table_PreservesInlineKeyConstraintSemanticDifferences() + { + var source = + "CREATE TABLE [dbo].[MigrationLog]\n" + + "(\n" + + " [MigrationName] [varchar] (255) NOT NULL,\n" + + " [ExecutedAt] [datetime] NOT NULL\n" + + ") ON [PRIMARY]\n" + + "GO\n" + + "ALTER TABLE [dbo].[MigrationLog] ADD CONSTRAINT [PK_MigrationLog] PRIMARY KEY CLUSTERED ([MigrationName]) ON [PRIMARY]\n" + + "GO"; + var target = + "CREATE TABLE dbo.MigrationLog(\n" + + " MigrationName VARCHAR(255) NOT NULL,\n" + + " ExecutedAt DATETIME NOT NULL,\n" + + " CONSTRAINT [PK_MigrationLog] PRIMARY KEY CLUSTERED ( ExecutedAt ) ON PRIMARY\n" + + ") ON PRIMARY\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Contains("PRIMARY KEY CLUSTERED ([MigrationName])", diff); + Assert.Contains("PRIMARY KEY CLUSTERED ( ExecutedAt )", diff); + } + [Fact] public void BuildUnifiedDiff_Table_PreservesPostCreatePackageContentDifferencesWhenOrderAlsoDiffers() { @@ -1695,7 +1919,7 @@ public void BuildUnifiedDiff_Table_PreservesReadableStatementText_WhenLegacyForm ") ON [PRIMARY]\n" + "GO"; var target = - "create table lab.samplemeasure(batchid int not null,measurevalue decimal(15,8) null,) on primary;\n" + + "create table lab.samplemeasure(batchid int not null,measurevalue decimal(15,9) null,) on primary;\n" + "GO"; var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); @@ -1703,8 +1927,8 @@ public void BuildUnifiedDiff_Table_PreservesReadableStatementText_WhenLegacyForm Assert.Contains(" CREATE TABLE [lab].[SampleMeasure]", diff); Assert.Contains(" [BatchId] [int] NOT NULL,", diff); Assert.Contains("- [MeasureValue] [decimal] (15, 8) NULL", diff); - Assert.Contains("+ measurevalue decimal(15,8) null", diff, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("create table lab.samplemeasure(batchid int not null,measurevalue decimal(15,8) null) on primary", diff, StringComparison.Ordinal); + Assert.Contains("+ measurevalue decimal(15,9) null", diff, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("create table lab.samplemeasure(batchid int not null,measurevalue decimal(15,9) null) on primary", diff, StringComparison.Ordinal); } [Fact] @@ -1751,17 +1975,53 @@ public void BuildUnifiedDiff_Table_CanRenderNormalizedDiffForDebuggingWhenReques ")\n" + "GO"; var target = - "create table lab.samplemeasure(batchid int not null,measurevalue decimal(15,8) null,) on primary;\n" + + "create table lab.samplemeasure(batchid int not null,measurevalue decimal(15,9) null,) on primary;\n" + "GO"; var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target, normalizedDiff: true); Assert.Contains("create table lab.samplemeasure", diff, StringComparison.Ordinal); Assert.Contains("measurevalue decimal(15,8) null", diff, StringComparison.Ordinal); - Assert.Contains("measurevalue decimal(15,8) null,)", diff, StringComparison.Ordinal); + Assert.Contains("measurevalue decimal(15,9) null", diff, StringComparison.Ordinal); Assert.DoesNotContain("[lab].[SampleMeasure]", diff, StringComparison.Ordinal); } + [Fact] + public void BuildUnifiedDiff_User_PreservesReadablePermissionText_ByDefault() + { + var source = + "CREATE USER [AppReader] FOR LOGIN [AppReader]\n" + + "GO\n" + + "GRANT CONNECT TO [AppReader]\n" + + "GO"; + var target = + "CREATE USER [AppReader] FOR LOGIN [AppReader]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("User", "db", "folder", source, target); + + Assert.Contains("GRANT CONNECT TO [AppReader]", diff, StringComparison.Ordinal); + Assert.DoesNotContain("grant connect to appreader", diff, StringComparison.Ordinal); + } + + [Fact] + public void BuildUnifiedDiff_Role_SuppressesMissingTerminalGoAfterFinalStatement() + { + var source = + "CREATE ROLE [AppViewer]\n" + + "GO\n" + + "GRANT VIEW DATABASE STATE TO [AppViewer]\n" + + "GO"; + var target = + "CREATE ROLE [AppViewer]\n" + + "GO\n" + + "GRANT VIEW DATABASE STATE TO [AppViewer]"; + + var diff = SyncCommandService.BuildUnifiedDiff("Role", "db", "folder", source, target); + + Assert.Empty(diff); + } + [Fact] public void BuildUnifiedDiff_StoredProcedure_SuppressesLeadingSsmsHeaderComment() { From 492c4f6d7e4842066d06c2c0ebad5c54f4713834 Mon Sep 17 00:00:00 2001 From: zacateras Date: Fri, 10 Apr 2026 16:00:07 +0200 Subject: [PATCH 5/7] feat: enhance assembly comparison handling to ignore legacy formatting differences --- CHANGELOG.md | 1 + specs/01-cli.md | 2 + specs/04-scripting.md | 1 + .../Sync/SyncCommandService.cs | 228 ++++++++++++++++++ .../Sync/SyncCommandServiceTests.cs | 57 +++++ 5 files changed, 289 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f02db73..c90cd82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Treat legacy standalone table-level inline `PRIMARY KEY` and `UNIQUE` constraints as compatible during comparison with canonical post-create key-constraint statements. - Treat legacy CLR table-valued function return-column collation clauses as compatible during comparison when SQL Server ignores them in the effective return shape. - 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. +- 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. - 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). - 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. - Empty separator lines are now ignored during `status` and `diff`, and whitespace-only separator lines compare as compatible after normalization. diff --git a/specs/01-cli.md b/specs/01-cli.md index 480fdb6..08223d0 100644 --- a/specs/01-cli.md +++ b/specs/01-cli.md @@ -217,6 +217,7 @@ Behavior: - Equivalent `Role` membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` compare as compatible. - Equivalent `MessageType` validation synonyms/spacing and equivalent `Contract` and `Service` body formatting and item ordering compare as compatible. - Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Trigger`) compare as compatible. +- 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. - When `data.trackedTables` is configured, `status` also reports data-script differences for tracked tables. - Status output MUST report schema and data summaries separately. - Exit codes: @@ -255,6 +256,7 @@ Behavior: - Equivalent `Role` membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` compare as compatible. - Equivalent `MessageType` validation synonyms/spacing and equivalent `Contract` and `Service` body formatting and item ordering compare as compatible. - Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Trigger`) compare as compatible. +- 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. - Diff output uses a chunked format: only changed lines and their surrounding context are shown, not the entire file. - `--context ` controls the number of unchanged context lines shown before and after each changed segment (default: 3). Negative values are treated as 0. - `--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. diff --git a/specs/04-scripting.md b/specs/04-scripting.md index d6e5f59..bc285e2 100644 --- a/specs/04-scripting.md +++ b/specs/04-scripting.md @@ -842,6 +842,7 @@ When compatibility reference files are available, `sqlct` MAY apply reconciliati - 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. - 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. - For CLR table-valued `Function` scripts, comparison normalization MAY treat legacy `COLLATE ` clauses on return-column lines as compatible when SQL Server ignores that collation metadata for the effective return shape. +- 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. ## 11. Error and Unsupported Behavior - Missing SQL object metadata for requested object MUST fail with an error. diff --git a/src/SqlChangeTracker/Sync/SyncCommandService.cs b/src/SqlChangeTracker/Sync/SyncCommandService.cs index 89ecb73..7ca0741 100644 --- a/src/SqlChangeTracker/Sync/SyncCommandService.cs +++ b/src/SqlChangeTracker/Sync/SyncCommandService.cs @@ -39,6 +39,8 @@ internal enum ComparisonTarget internal sealed class SyncCommandService : ISyncCommandService { internal const string TableDataObjectType = "TableData"; + private const string SqlIdentifierTokenPattern = @"(?:\[(?:[^\]]|\]\])+\]|""(?:""""|[^""])+""|[^\s;]+)"; + private const string SqlStringOrIdentifierTokenPattern = @"(?:\[(?:[^\]]|\]\])+\]|""(?:""""|[^""])+""|'(?:''|[^'])*'|[^\s;]+)"; private static readonly Regex ScalarUserDefinedTypeScriptRegex = new( @"\bCREATE\s+TYPE\b.*\bFROM\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.Compiled); @@ -75,9 +77,18 @@ internal sealed class SyncCommandService : ISyncCommandService private static readonly Regex SsmsObjectHeaderCommentRegex = new( @"^\s*/\*{5,}\s*Object:\s+(?:StoredProcedure|Procedure|View|Function|Trigger)\b.*Script Date:.*\*+/\s*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex AssemblyHeaderCommentRegex = new( + @"^\s*--\s*Assembly\b.*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex CompatibleTextImageOnRegex = new( @"^(?\)\s*(?:ON\s+(?:\[[^\]]+(?:\]\])*\]|""(?:""""|[^""])+""|[^\s]+)(?:\s*\([^)]+\))?)?)\s+TEXTIMAGE_ON\s+(?\[[^\]]+(?:\]\])*\]|""(?:""""|[^""])+""|[^\s]+)(?\s*)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex CreateAssemblyBlockRegex = new( + $@"^\s*CREATE\s+ASSEMBLY\s+(?{SqlIdentifierTokenPattern})\s+(?:AUTHORIZATION\s+(?{SqlIdentifierTokenPattern})\s+)?FROM\s+(?0x(?:[0-9A-Fa-f\\\s])+?)\s+WITH\s+PERMISSION_SET\s*=\s*(?[A-Za-z_]+)\s*;?\s*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex AlterAssemblyAddFileBlockRegex = new( + $@"^\s*ALTER\s+ASSEMBLY\s+(?{SqlIdentifierTokenPattern})\s+ADD\s+FILE\s+FROM\s+(?0x(?:[0-9A-Fa-f\\\s])+?)\s+AS\s+(?{SqlStringOrIdentifierTokenPattern})\s*;?\s*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.Compiled); private static readonly IReadOnlyList ActiveObjectTypes = SupportedSqlObjectTypes.ActiveSync; private readonly SqlctConfigReader _configReader; @@ -1710,6 +1721,12 @@ private static ComparableLine[] BuildComparableLinesForDiff( return BuildUserDefinedTypeComparableLinesForDiff(normalized); } + if (string.Equals(objectType, "Assembly", StringComparison.OrdinalIgnoreCase)) + { + var normalized = PrepareScriptForReadableDiffDisplay(script, objectType); + return BuildAssemblyComparableLinesForDiff(normalized); + } + return null; } @@ -1732,6 +1749,11 @@ private static string PrepareScriptForReadableDiffDisplay(string script, string? { lines[i] = string.Empty; } + else if (IsAssemblyObjectTypeForHeaderCommentCompatibility(objectType) && + AssemblyHeaderCommentRegex.IsMatch(lines[i])) + { + lines[i] = string.Empty; + } } if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType)) @@ -1929,6 +1951,37 @@ private static string[] BuildUserDefinedTypeDisplayUnitsForDiff(string script) return comparableLines.ToArray(); } + private static ComparableLine[] BuildAssemblyComparableLinesForDiff(string script) + { + var blocks = SplitGoDelimitedBlocks(script); + if (blocks.Count == 0) + { + return Array.Empty(); + } + + var comparableLines = new List(blocks.Count * 2); + foreach (var block in blocks) + { + var hasTerminalGo = + block.Length > 0 && + string.Equals(block[^1].Trim(), "GO", StringComparison.OrdinalIgnoreCase); + var contentLines = hasTerminalGo ? block[..^1] : block; + if (contentLines.Any(line => line.Length > 0)) + { + comparableLines.Add(BuildComparableLineForStructuredBlock( + NormalizeAssemblyBlockContentForComparison(contentLines), + string.Join("\n", contentLines))); + } + + if (hasTerminalGo) + { + comparableLines.Add(new ComparableLine("GO", "GO")); + } + } + + return comparableLines.ToArray(); + } + private static IReadOnlyList GetDisplayUnitsForTablePostCreatePackage(string package) { var blocks = SplitGoDelimitedBlocks(package); @@ -2649,6 +2702,11 @@ internal static string NormalizeForComparison( { lines[i] = string.Empty; } + else if (IsAssemblyObjectTypeForHeaderCommentCompatibility(objectType) && + AssemblyHeaderCommentRegex.IsMatch(lines[i])) + { + lines[i] = string.Empty; + } } if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType)) @@ -2690,6 +2748,11 @@ internal static string NormalizeForComparison( joined = NormalizeServiceBrokerScriptForComparison(joined, NormalizeServiceBaseBlockForComparison); } + if (string.Equals(objectType, "Assembly", StringComparison.OrdinalIgnoreCase)) + { + joined = NormalizeAssemblyScriptForComparison(joined); + } + if (string.Equals(objectType, "Function", StringComparison.OrdinalIgnoreCase)) { joined = NormalizeClrTableValuedFunctionScriptForComparison(joined); @@ -2864,6 +2927,11 @@ private static string NormalizeForDiffDisplay( { lines[i] = string.Empty; } + else if (IsAssemblyObjectTypeForHeaderCommentCompatibility(objectType) && + AssemblyHeaderCommentRegex.IsMatch(lines[i])) + { + lines[i] = string.Empty; + } } if (IsProgrammableObjectTypeForHeaderCommentCompatibility(objectType)) @@ -3061,6 +3129,163 @@ private static string NormalizeServiceBaseBlockForComparison(string baseBlock) : rebuilt + " " + suffix; } + private static string NormalizeAssemblyScriptForComparison(string script) + { + var blocks = SplitGoDelimitedBlocks(script); + if (blocks.Count == 0) + { + return script; + } + + var normalizedBlocks = new List(blocks.Count); + foreach (var block in blocks) + { + var hasTerminalGo = + block.Length > 0 && + string.Equals(block[^1].Trim(), "GO", StringComparison.OrdinalIgnoreCase); + var contentLines = hasTerminalGo ? block[..^1] : block; + if (contentLines.Any(line => line.Length > 0)) + { + normalizedBlocks.Add(NormalizeAssemblyBlockContentForComparison(contentLines)); + } + + if (hasTerminalGo) + { + normalizedBlocks.Add("GO"); + } + } + + return string.Join("\n", normalizedBlocks); + } + + private static string NormalizeAssemblyBlockContentForComparison(IEnumerable blockLines) + { + var contentLines = blockLines + .Select(line => line.Trim()) + .Where(line => line.Length > 0) + .ToArray(); + if (contentLines.Length == 0) + { + return string.Empty; + } + + if (TryNormalizeCreateAssemblyBlockForComparison(contentLines, out var createAssembly)) + { + return createAssembly; + } + + if (TryNormalizeAlterAssemblyAddFileBlockForComparison(contentLines, out var addFileAssembly)) + { + return addFileAssembly; + } + + if (contentLines.Length == 1 && IsPermissionStatementLine(contentLines[0])) + { + return NormalizePermissionStatementForComparison(contentLines[0]); + } + + if (contentLines.Length == 1 && IsExtendedPropertyStatementLine(contentLines[0])) + { + return NormalizeExtendedPropertyStatementForComparison(contentLines[0]); + } + + return NormalizeSqlStatementTokensForComparison(string.Join(" ", contentLines)); + } + + private static bool TryNormalizeCreateAssemblyBlockForComparison( + IReadOnlyList contentLines, + out string normalized) + { + var match = CreateAssemblyBlockRegex.Match(string.Join("\n", contentLines)); + if (!match.Success) + { + normalized = string.Empty; + return false; + } + + var normalizedLines = new List + { + NormalizeSqlStatementTokensForComparison( + $"CREATE ASSEMBLY {QuoteIdentifierForComparison(UnquoteSqlIdentifier(match.Groups["name"].Value))}") + }; + + if (match.Groups["owner"].Success) + { + normalizedLines.Add(NormalizeSqlStatementTokensForComparison( + $"AUTHORIZATION {QuoteIdentifierForComparison(UnquoteSqlIdentifier(match.Groups["owner"].Value))}")); + } + + normalizedLines.Add(NormalizeSqlStatementTokensForComparison( + $"FROM 0x{NormalizeAssemblyHexLiteralForComparison(match.Groups["hex"].Value)}")); + normalizedLines.Add(NormalizeSqlStatementTokensForComparison( + $"WITH PERMISSION_SET = {NormalizeAssemblyPermissionSetForComparison(match.Groups["permission"].Value)}")); + normalized = string.Join("\n", normalizedLines); + return true; + } + + private static bool TryNormalizeAlterAssemblyAddFileBlockForComparison( + IReadOnlyList contentLines, + out string normalized) + { + var match = AlterAssemblyAddFileBlockRegex.Match(string.Join("\n", contentLines)); + if (!match.Success) + { + normalized = string.Empty; + return false; + } + + normalized = NormalizeSqlStatementTokensForComparison( + $"ALTER ASSEMBLY {QuoteIdentifierForComparison(UnquoteSqlIdentifier(match.Groups["name"].Value))} " + + $"ADD FILE FROM 0x{NormalizeAssemblyHexLiteralForComparison(match.Groups["hex"].Value)} " + + $"AS {NormalizeAssemblyFileTokenForComparison(match.Groups["file"].Value)}"); + return true; + } + + private static string NormalizeAssemblyHexLiteralForComparison(string value) + { + var hexPrefixIndex = value.IndexOf("0x", StringComparison.OrdinalIgnoreCase); + if (hexPrefixIndex < 0) + { + return value.Trim().ToLowerInvariant(); + } + + var builder = new StringBuilder(value.Length); + for (var i = hexPrefixIndex + 2; i < value.Length; i++) + { + var current = value[i]; + if (char.IsWhiteSpace(current) || current == '\\') + { + continue; + } + + if (IsHexDigit(current)) + { + builder.Append(char.ToLowerInvariant(current)); + } + } + + return builder.ToString(); + } + + private static string NormalizeAssemblyPermissionSetForComparison(string value) + => value.Trim().ToUpperInvariant() switch + { + "SAFE" or "SAFE_ACCESS" => "SAFE", + "EXTERNAL_ACCESS" => "EXTERNAL_ACCESS", + "UNSAFE" or "UNSAFE_ACCESS" => "UNSAFE", + _ => value.Trim().ToUpperInvariant() + }; + + private static string NormalizeAssemblyFileTokenForComparison(string value) + { + if (value.Length >= 2 && value[0] == '\'' && value[^1] == '\'') + { + return QuoteIdentifierForComparison(UnescapeSqlStringLiteral(value[1..^1])); + } + + return QuoteIdentifierForComparison(UnquoteSqlIdentifier(value)); + } + private static string CollapseServiceBrokerWhitespace(string text) => Regex.Replace(text, @"\s+", " ", RegexOptions.CultureInvariant).Trim(); @@ -3948,6 +4173,9 @@ private static bool IsProgrammableObjectTypeForHeaderCommentCompatibility(string || string.Equals(objectType, "Function", StringComparison.OrdinalIgnoreCase) || string.Equals(objectType, "Trigger", StringComparison.OrdinalIgnoreCase); + private static bool IsAssemblyObjectTypeForHeaderCommentCompatibility(string? objectType) + => string.Equals(objectType, "Assembly", StringComparison.OrdinalIgnoreCase); + private static bool IsExtendedPropertyStatementLine(string line) => ExtendedPropertyStatementRegex.IsMatch(line); diff --git a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs index 1b2cb15..df6a3c1 100644 --- a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs +++ b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs @@ -2050,6 +2050,63 @@ public void BuildUnifiedDiff_StoredProcedure_SuppressesLeadingSsmsHeaderComment( Assert.Empty(diff); } + [Fact] + public void BuildUnifiedDiff_Assembly_SuppressesEquivalentBannerWrappedHexAndQuotedAddFileDifferences() + { + var source = + "CREATE ASSEMBLY [AppLibrary]\n" + + "AUTHORIZATION [dbo]\n" + + "FROM 0xAABBCCDD\n" + + "WITH PERMISSION_SET = SAFE\n" + + "GO\n" + + "ALTER ASSEMBLY [AppLibrary] ADD FILE FROM 0x11223344 AS [AppLibrary.pdb]\n" + + "GO"; + var target = + "--Assembly applibrary, version=0.0.0.0, culture=neutral, publickeytoken=null, processorarchitecture=msil\n" + + "--Assembly applibrary, version=0.0.0.0, culture=neutral, publickeytoken=null, processorarchitecture=msil\n" + + "CREATE ASSEMBLY applibrary\n" + + "AUTHORIZATION dbo\n" + + "FROM 0xaabb\\\n" + + "ccdd\n" + + "WITH PERMISSION_SET=safe\n" + + "GO\n" + + "ALTER ASSEMBLY applibrary\n" + + "ADD FILE FROM\n" + + "0x1122\\\n" + + "3344\n" + + "AS 'AppLibrary.pdb'\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Assembly", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Assembly_PreservesPermissionSetDifferencesWhenLegacyFormattingAlsoDiffers() + { + var source = + "CREATE ASSEMBLY [AppLibrary]\n" + + "AUTHORIZATION [dbo]\n" + + "FROM 0xAABBCCDD\n" + + "WITH PERMISSION_SET = SAFE\n" + + "GO"; + var target = + "--Assembly applibrary, version=0.0.0.0, culture=neutral, publickeytoken=null, processorarchitecture=msil\n" + + "CREATE ASSEMBLY applibrary\n" + + "AUTHORIZATION dbo\n" + + "FROM 0xaabb\\\n" + + "ccdd\n" + + "WITH PERMISSION_SET=unsafe\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Assembly", "db", "folder", source, target); + + Assert.NotEmpty(diff); + Assert.Contains("PERMISSION_SET", diff, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("--Assembly", diff, StringComparison.Ordinal); + } + [Fact] public void NormalizeForComparison_DoesNotNormalizeUnicodeLiteralPrefixesOutsideTableData() { From 1fb1d80ba0ca6aadcc10459484fd604794745187 Mon Sep 17 00:00:00 2001 From: zacateras Date: Sun, 12 Apr 2026 15:50:26 +0200 Subject: [PATCH 6/7] chore: update documentation and tests for improved compatibility handling --- CHANGELOG.md | 2 + specs/04-scripting.md | 1 + src/SqlChangeTracker/Sql/SqlServerScripter.cs | 45 ++++++++++++++--- .../SqlServerScripterCompatibilityTests.cs | 50 +++++++++++++++++++ 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c90cd82..a5959d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Treat legacy CLR table-valued function return-column collation clauses as compatible during comparison when SQL Server ignores them in the effective return shape. - 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. - 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. +- Rewrite programmable-object declaration lines to the current metadata name when SQL Server stores stale module text after an object rename. +- Fix table-trigger scripting after the declaration rewrite change by resolving trigger schema without referencing a non-existent `sys.triggers.schema_id` column. - 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). - 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. - Empty separator lines are now ignored during `status` and `diff`, and whitespace-only separator lines compare as compatible after normalization. diff --git a/specs/04-scripting.md b/specs/04-scripting.md index bc285e2..3bc39e8 100644 --- a/specs/04-scripting.md +++ b/specs/04-scripting.md @@ -110,6 +110,7 @@ The following types are defined in this specification family and not fully imple - `SET QUOTED_IDENTIFIER ` + `GO` - `SET ANSI_NULLS ` + `GO` - Programmable-object body MUST be followed by `GO`. +- Programmable-object declaration lines MUST reflect the current metadata schema/name even when the stored module text is stale after an object rename. - Object-level permissions and extended properties MUST be emitted after object DDL body. - Canonical programmable-object whitespace MUST be: - no blank line between the final header `GO` and the first definition line, diff --git a/src/SqlChangeTracker/Sql/SqlServerScripter.cs b/src/SqlChangeTracker/Sql/SqlServerScripter.cs index a65d390..5003e21 100644 --- a/src/SqlChangeTracker/Sql/SqlServerScripter.cs +++ b/src/SqlChangeTracker/Sql/SqlServerScripter.cs @@ -19,7 +19,7 @@ internal class SqlServerScripter @"^\s*\[[^\]]+\]\s+AS\s+", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private static readonly Regex ModuleDeclarationLineRegex = new( - @"^(?CREATE\s+(?:PROC(?:EDURE)?|FUNCTION|VIEW|TRIGGER)\s+)(?(?:\[[^\]]+(?:\]\])*\]|[A-Za-z_][\w@#$]*)(?:\.(?:\[[^\]]+(?:\]\])*\]|[A-Za-z_][\w@#$]*))*)(?.*)$", + @"^(?\s*)(?CREATE(?:\s+OR\s+ALTER)?\s+(?:PROC(?:EDURE)?|FUNCTION|VIEW|TRIGGER)\s+)(?(?:\[[^\]]+(?:\]\])*\]|[A-Za-z_][\w@#$]*)(?:\.(?:\[[^\]]+(?:\]\])*\]|[A-Za-z_][\w@#$]*))*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private static readonly Regex ClrTableValuedFunctionReturnColumnNullRegex = new( @"^(?\s*(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*)\s+(?:(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*)(?:\.(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*))?)(?:\s*\([^)]*\))?)\s+NULL(?\s*,?\s*)$", @@ -631,6 +631,8 @@ FROM sys.objects o definitionText = BuildClrTableValuedFunctionDefinition(connection, objectId, obj.Schema, obj.Name); } + definitionText = RewriteModuleDeclarationLine(definitionText, obj.Schema, obj.Name); + var (lines, hasGoAfterDefinition) = BuildProgrammableObjectLines( definitionText, ansiNulls, @@ -1039,6 +1041,8 @@ FROM sys.objects o var quotedIdentifier = reader.IsDBNull(2) || reader.GetBoolean(2); reader.Close(); + definitionText = RewriteModuleDeclarationLine(definitionText, obj.Schema, obj.Name); + var (lines, hasGoAfterDefinition) = BuildProgrammableObjectLines( definitionText, ansiNulls, @@ -3885,7 +3889,7 @@ private static IEnumerable ReadTableTriggers( { using var command = connection.CreateCommand(); command.CommandText = @" -SELECT t.name, m.definition, m.uses_ansi_nulls, m.uses_quoted_identifier +SELECT OBJECT_SCHEMA_NAME(t.object_id), t.name, m.definition, m.uses_ansi_nulls, m.uses_quoted_identifier FROM sys.triggers t JOIN sys.sql_modules m ON m.object_id = t.object_id WHERE t.parent_class_desc = 'OBJECT_OR_COLUMN' @@ -3898,10 +3902,12 @@ FROM sys.triggers t using var reader = command.ExecuteReader(); while (reader.Read()) { - var triggerName = reader.GetString(0); - var definitionText = reader.IsDBNull(1) ? string.Empty : reader.GetString(1); - var ansiNulls = reader.IsDBNull(2) || reader.GetBoolean(2); - var quotedIdentifier = reader.IsDBNull(3) || reader.GetBoolean(3); + var triggerSchema = reader.IsDBNull(0) ? string.Empty : reader.GetString(0); + var triggerName = reader.GetString(1); + var definitionText = reader.IsDBNull(2) ? string.Empty : reader.GetString(2); + var ansiNulls = reader.IsDBNull(3) || reader.GetBoolean(3); + var quotedIdentifier = reader.IsDBNull(4) || reader.GetBoolean(4); + definitionText = RewriteModuleDeclarationLine(definitionText, triggerSchema, triggerName); var triggerReferenceLines = TryGetReferenceTriggerBlock(referenceLines, triggerName); var (triggerLines, _) = BuildProgrammableObjectLines( definitionText, @@ -6452,6 +6458,33 @@ internal static string ApplyDefinitionFormatting(string definition, string[]? re return string.Join(Environment.NewLine, lines); } + internal static string RewriteModuleDeclarationLine(string definition, string schema, string name) + { + if (string.IsNullOrWhiteSpace(definition)) + { + return definition; + } + + var lines = definition.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + for (var i = 0; i < lines.Length; i++) + { + var match = ModuleDeclarationLineRegex.Match(lines[i]); + if (!match.Success) + { + continue; + } + + lines[i] = + match.Groups["indent"].Value + + match.Groups["prefix"].Value + + $"{QuoteIdentifier(schema)}.{QuoteIdentifier(name)}" + + match.Groups["suffix"].Value; + break; + } + + return string.Join(Environment.NewLine, lines); + } + private static string[]? GetReferenceDefinitionBlock(string[]? referenceLines) { var range = TryGetReferenceDefinitionRange(referenceLines); diff --git a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs index 713461e..d0ef0b5 100644 --- a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs +++ b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs @@ -113,6 +113,56 @@ public void ApplyDefinitionFormatting_PreservesReferenceCreateLineIdentifierQuot Assert.Equal("CREATE PROCEDURE [Reporting].[Sample_Proc]", createLine); } + [Fact] + public void RewriteModuleDeclarationLine_ReplacesStaleStoredProcedureNameWithCurrentObjectName() + { + var definition = string.Join(Environment.NewLine, new[] + { + "CREATE PROCEDURE [Accounting].[LegacyProcedure] @BatchId int", + "AS", + "SELECT @BatchId" + }); + + var rewritten = SqlServerScripter.RewriteModuleDeclarationLine( + definition, + "Accounting", + "CurrentProcedure__1_12_3_0"); + + Assert.Equal( + string.Join(Environment.NewLine, new[] + { + "CREATE PROCEDURE [Accounting].[CurrentProcedure__1_12_3_0] @BatchId int", + "AS", + "SELECT @BatchId" + }), + rewritten); + } + + [Fact] + public void RewriteModuleDeclarationLine_PreservesCreateOrAlterPrefix_WhenReplacingCurrentName() + { + var definition = string.Join(Environment.NewLine, new[] + { + "CREATE OR ALTER VIEW [Reporting].[LegacyView]", + "AS", + "SELECT 1" + }); + + var rewritten = SqlServerScripter.RewriteModuleDeclarationLine( + definition, + "Reporting", + "CurrentView"); + + Assert.Equal( + string.Join(Environment.NewLine, new[] + { + "CREATE OR ALTER VIEW [Reporting].[CurrentView]", + "AS", + "SELECT 1" + }), + rewritten); + } + [Fact] public void ApplyDefinitionFormatting_PreservesCompatibleClrFunctionReferenceDefinition() { From 7d9260b52f5cf90425f30ef0c39b1632fbfcc7cb Mon Sep 17 00:00:00 2001 From: zacateras Date: Sun, 12 Apr 2026 20:13:23 +0200 Subject: [PATCH 7/7] feat: Add support for CLR aggregates in SQL Change Tracker - Excluded SSMS database-diagram support functions and tables from discovery and scripting. - Implemented discovery and scripting for SQL CLR aggregates as `Aggregate` objects. - Updated documentation to reflect the addition of `Aggregate` to the supported object types. - Enhanced the CLI and schema folder structure to accommodate aggregates. - Added tests to ensure proper functionality for aggregate detection and scripting. --- CHANGELOG.md | 2 + README.md | 3 + specs/01-cli.md | 6 +- specs/03-schema-folder.md | 1 + specs/04-scripting.md | 30 ++++++- specs/12-project-plan.md | 2 +- src/SqlChangeTracker/PACKAGE_README.md | 3 + .../Schema/SupportedSqlObjectTypes.cs | 1 + .../Sql/SqlServerIntrospector.cs | 54 +++++++++++- src/SqlChangeTracker/Sql/SqlServerScripter.cs | 86 ++++++++++++++++++- .../Sync/SyncCommandService.cs | 48 +++++++++-- .../Schema/SchemaFolderMapperTests.cs | 2 + .../Sql/SqlServerIntrospectorTests.cs | 82 +++++++++++++++++- .../Sql/SqlServerScripterTests.cs | 47 ++++++++++ .../Sync/SyncCommandServiceTests.cs | 62 ++++++++++++- 15 files changed, 404 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5959d4..3d216e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Keep readable `diff` output for `Table` and table-valued `UserDefinedType` bodies at per-entry granularity instead of collapsing the entire body into one changed line. - Align readable `Table` and table-valued `UserDefinedType` diffs by individual body entries so a single changed column or inline constraint does not mark the entire body as changed. - Exclude SSMS database-diagram support stored procedures from discovery and scripting even when SQL Server does not mark them as system-shipped. +- Exclude SSMS database-diagram support table and function objects from discovery and scripting even when SQL Server does not mark them as system-shipped. - Script `TYPE::` permissions for scalar and table-valued `UserDefinedType` objects. - Script database-level permissions granted directly to `Role` and `User` principals, and emit `CREATE USER ... WITHOUT LOGIN` when no server-login metadata is available. - Treat equivalent contiguous permission statement ordering as compatible during comparison. @@ -50,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Discover and script SQL CLR scalar functions as `Function` objects. - Discover and script SQL CLR table-valued functions as `Function` objects. - Discover and script SQL CLR stored procedures as `StoredProcedure` objects. +- Discover and script SQL CLR aggregates as `Aggregate` objects in `Functions/`. - Discover and script built-in `dbo` as a `Schema` object when it has explicit schema permissions or schema-level extended properties, without emitting `CREATE SCHEMA`. - `sqlct init` now prompts interactively for connection details (server, database, auth, credentials, trust-server-certificate) when run without flags in a new project directory (#36). - Connection flags (`--server`, `--database`, `--auth`, `--user`, `--password`, `--trust-server-certificate`) for non-interactive/scripted `init` use (#36). diff --git a/README.md b/README.md index 5f70f6a..5d52b3c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Current runtime scope for `status`, `diff`, and `pull` covers: - `View` - `StoredProcedure` - `Function` +- `Aggregate` - `Sequence` - `Schema` - `Role` @@ -62,6 +63,8 @@ Table scripting also includes standalone user-created table statistics (`CREATE Function scripting covers T-SQL scalar/table functions and SQL CLR scalar/table-valued functions (`sys.objects.type = 'FS'` and `FT`), including `EXTERNAL NAME` assembly bindings. +Aggregate scripting covers SQL CLR aggregates (`sys.objects.type = 'AF'`), including `CREATE AGGREGATE ... RETURNS ... EXTERNAL NAME` bindings. + When `data.trackedTables` is configured, `status`, `diff`, and `pull` also process `TableData` artifacts for those explicit tracked tables. `--object` selectors support: diff --git a/specs/01-cli.md b/specs/01-cli.md index 08223d0..b18ca3e 100644 --- a/specs/01-cli.md +++ b/specs/01-cli.md @@ -36,7 +36,7 @@ Use git-style verbs: short, task-oriented commands with clear intent. - pull ## v1 Scope -- Active schema object types: `Assembly`, `Table`, `View`, `StoredProcedure`, `Function`, `Sequence`, `Schema`, `Role`, `User`, `Synonym`, `UserDefinedType`, `XmlSchemaCollection`, `PartitionFunction`, `PartitionScheme`, `MessageType`, `Contract`, `Queue`, `Service`, `Route`, `EventNotification`, `ServiceBinding`, `FullTextCatalog`, `FullTextStoplist`, `SearchPropertyList`. +- Active schema object types: `Assembly`, `Table`, `View`, `StoredProcedure`, `Function`, `Aggregate`, `Sequence`, `Schema`, `Role`, `User`, `Synonym`, `UserDefinedType`, `XmlSchemaCollection`, `PartitionFunction`, `PartitionScheme`, `MessageType`, `Contract`, `Queue`, `Service`, `Route`, `EventNotification`, `ServiceBinding`, `FullTextCatalog`, `FullTextStoplist`, `SearchPropertyList`. - `status`, `diff`, and `pull` process the active schema object types. - When `data.trackedTables` is configured, `status`, `diff`, and `pull` also process `TableData` artifacts for those explicit tracked tables. - `UserDefinedType` covers scalar alias types and table-valued types. @@ -216,7 +216,7 @@ Behavior: - Equivalent `Queue` option spacing, line wrapping, explicit default `ON [PRIMARY]`, and disabled default activation compare as compatible. - Equivalent `Role` membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` compare as compatible. - Equivalent `MessageType` validation synonyms/spacing and equivalent `Contract` and `Service` body formatting and item ordering compare as compatible. -- Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Trigger`) compare as compatible. +- Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Aggregate`, `Trigger`) compare as compatible. - 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. - When `data.trackedTables` is configured, `status` also reports data-script differences for tracked tables. - Status output MUST report schema and data summaries separately. @@ -255,7 +255,7 @@ Behavior: - Equivalent `Queue` option spacing, line wrapping, explicit default `ON [PRIMARY]`, and disabled default activation compare as compatible. - Equivalent `Role` membership statements written as `EXEC sp_addrolemember ...` or `ALTER ROLE ... ADD MEMBER ...` compare as compatible. - Equivalent `MessageType` validation synonyms/spacing and equivalent `Contract` and `Service` body formatting and item ordering compare as compatible. -- Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Trigger`) compare as compatible. +- Leading SSMS-generated object banner comments on programmable objects (`StoredProcedure`, `View`, `Function`, `Aggregate`, `Trigger`) compare as compatible. - 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. - Diff output uses a chunked format: only changed lines and their surrounding context are shown, not the entire file. - `--context ` controls the number of unchanged context lines shown before and after each changed segment (default: 3). Negative values are treated as 0. diff --git a/specs/03-schema-folder.md b/specs/03-schema-folder.md index 4c9fea8..1251425 100644 --- a/specs/03-schema-folder.md +++ b/specs/03-schema-folder.md @@ -51,6 +51,7 @@ Defines the baseline `sqlct` schema-folder structure and naming rules. ## Naming Rules - Schema-scoped object scripts use `Schema.Object.sql` in their object-type folder (for example `Tables/`, `Views/`, `Functions/`, `Stored Procedures/`, `Sequences/`, `Synonyms/`, `Service Broker/Queues/`, `Types/XML Schema Collections/`, and `Types/User-defined Data Types/`). +- `Functions/` stores both `Function` and `Aggregate` scripts. - `Types/User-defined Data Types/` stores both scalar alias types and table-valued types. - Data scripts use `Schema.Object_Data.sql` in `Data/`. - Data tracking uses `Data/` for scripts derived from tables explicitly listed in `data.trackedTables`. diff --git a/specs/04-scripting.md b/specs/04-scripting.md index 3bc39e8..b76be5f 100644 --- a/specs/04-scripting.md +++ b/specs/04-scripting.md @@ -43,8 +43,11 @@ This specification defines normative scripting rules for `sqlct`. - Database-scoped objects with no explicit schema MUST be mapped consistently with schema-folder rules. - `Schema` discovery covers user-defined schemas, excludes `sys` and `INFORMATION_SCHEMA`, and includes built-in `dbo` only when it has explicit schema permissions or schema-level extended properties that are in scope for scripting. - `StoredProcedure` discovery excludes SSMS database-diagram support procedures: `sp_alterdiagram`, `sp_creatediagram`, `sp_dropdiagram`, `sp_helpdiagramdefinition`, `sp_helpdiagrams`, `sp_renamediagram`, and `sp_upgraddiagrams`. +- `Function` discovery excludes the SSMS database-diagram support function `fn_diagramobjects`. +- `Table` discovery excludes the SSMS database-diagram support table `sysdiagrams`. - `Role` discovery covers user-defined roles and fixed roles that have non-system members tracked in role membership metadata. - `Assembly` discovery covers user-defined assemblies from `sys.assemblies` and excludes SQL Server system assemblies (`is_user_defined = 0`). +- `Aggregate` discovery covers CLR aggregates (`sys.objects.type = 'AF'`). - `UserDefinedType` discovery covers both scalar alias types and table-valued types. - `XmlSchemaCollection` discovery covers user-defined XML schema collections and excludes collections in `sys` and `INFORMATION_SCHEMA`. - `MessageType` and `Contract` discovery covers user-defined Service Broker objects and excludes SQL Server-owned broker artifacts named `DEFAULT` and broker/notification artifacts whose names start with `http://schemas.microsoft.com/SQL/`. @@ -68,6 +71,7 @@ This specification defines normative scripting rules for `sqlct`. - Views - Stored Procedures - Functions (`FN`, `TF`, `IF`, `FS`, `FT`) +- Aggregates (`AF`) - Sequences - Schema - Role @@ -106,7 +110,7 @@ The following types are defined in this specification family and not fully imple ### 6.1 Statement Framing - Batch separators for schema-object scripts MUST be emitted as `GO` on its own line. -- Programmable objects (views, procedures, functions, and table-scoped DML triggers) MUST include: +- Programmable objects (views, procedures, functions, aggregates, and table-scoped DML triggers) MUST include: - `SET QUOTED_IDENTIFIER ` + `GO` - `SET ANSI_NULLS ` + `GO` - Programmable-object body MUST be followed by `GO`. @@ -115,7 +119,7 @@ The following types are defined in this specification family and not fully imple - Canonical programmable-object whitespace MUST be: - no blank line between the final header `GO` and the first definition line, - no blank line between the final definition line and the trailing `GO`. -- The canonical programmable-object whitespace rule applies to views, stored procedures, functions, and table-scoped DML triggers unless compatibility reconciliation explicitly preserves reference spacing (see Section 9). +- The canonical programmable-object whitespace rule applies to views, stored procedures, functions, aggregates, and table-scoped DML triggers unless compatibility reconciliation explicitly preserves reference spacing (see Section 9). ### 6.2 Statement Ordering - Statement ordering MUST be deterministic. @@ -437,6 +441,26 @@ Each emitted statement MUST be followed by `GO`. 2. parameter-level (by parameter name, then property name). - Function scripting SHOULD support compatibility definition line-map reconciliation using the same algorithm as stored procedures. +### 8.4A Aggregates +- CLR aggregates are scripted through programmable-object framing rules with object type `AF` and level type `AGGREGATE`. +- CLR aggregate metadata MUST be sourced from `sys.assembly_modules`, `sys.assemblies`, and `sys.parameters`. +- CLR aggregate output MUST emit: + - `CREATE AGGREGATE [schema].[name] ()` + - `RETURNS ` + - `EXTERNAL NAME [assembly].[class]` +- Aggregate input parameters MUST be ordered by `parameter_id`. +- Aggregate return-type metadata MUST come from the aggregate parameter row with `parameter_id = 0`; missing return-type metadata MUST fail explicitly. +- Regex replacements from overrides MUST be applied before final emission. +- When no compatible reference spacing is preserved, aggregate emission MUST use the canonical programmable-object whitespace rules from Section 6.1. +- Grants and extended properties MUST follow the aggregate body. +- Aggregate-level extended properties MUST use: + - `EXEC sp_addextendedproperty ..., 'SCHEMA', N'', 'AGGREGATE', N'', NULL, NULL` +- Aggregate parameter-level extended properties MUST use: + - `EXEC sp_addextendedproperty ..., 'SCHEMA', N'', 'AGGREGATE', N'', 'PARAMETER', N'@'` +- Aggregate extended properties MUST be emitted in this order: + 1. aggregate-level (by property name), + 2. parameter-level (by parameter name, then property name). + ### 8.5 Sequences - Sequence metadata MUST be sourced from `sys.sequences` joined with schema and type metadata. - Output shape MUST be: @@ -835,7 +859,7 @@ When compatibility reference files are available, `sqlct` MAY apply reconciliati - Comparison normalization MAY treat reordered contiguous `GRANT` and `DENY` statement blocks as compatible when the normalized permission statement set is otherwise identical. - For `Table`, comparison normalization MAY treat omitted `TEXTIMAGE_ON [name]` as compatible with an explicit clause only when DB metadata shows that the table `lob_data_space_id` resolves to the current default data space named `[name]`; otherwise omission remains a semantic difference. - For extended-property blocks, comparison normalization MAY treat reordered `EXEC sp_addextendedproperty ...` statements as compatible within the same contiguous extended-property block when the normalized property statement set is otherwise identical, MAY ignore equivalent spacing around commas and arguments in those statements, and MAY treat equivalent named-vs-positional argument forms with omitted trailing `NULL` levels and top-level Unicode-literal prefixes on string arguments as compatible. -- For programmable `StoredProcedure`, `View`, `Function`, and `Trigger` scripts, comparison normalization MAY ignore leading SSMS-generated `/*** Object: ... Script Date: ... ***/` banner comments. +- For programmable `StoredProcedure`, `View`, `Function`, `Aggregate`, and `Trigger` scripts, comparison normalization MAY ignore leading SSMS-generated `/*** Object: ... Script Date: ... ***/` banner comments. - For `Queue`, comparison normalization MUST treat equivalent single-line and multi-line queue option formatting as compatible, MAY treat explicit `ON [PRIMARY]` as equivalent to an omitted default primary filegroup, and MAY treat disabled activation containing only default owner execution context as equivalent to omitted activation. - For `Role`, comparison normalization MAY treat legacy `EXEC sp_addrolemember N'', N''` statements as compatible with `ALTER ROLE [role] ADD MEMBER [member]` when the effective role-membership change is otherwise identical. - For `MessageType`, comparison normalization MAY treat legacy `VALIDATION = XML` as compatible with canonical `VALIDATION = WELL_FORMED_XML`, and MAY ignore equivalent spacing around the validation assignment. diff --git a/specs/12-project-plan.md b/specs/12-project-plan.md index dade24b..ec7801e 100644 --- a/specs/12-project-plan.md +++ b/specs/12-project-plan.md @@ -8,7 +8,7 @@ This plan reflects the current repository state and defines execution order to r ## Current State Summary - CLI entrypoint exists with commands: `init`, `config`, `status`, `diff`, `pull`. - `init`, `config`, `status`, `diff`, and `pull` are implemented. -- `status`, `diff`, and `pull` are active for: `Assembly`, `Table`, `View`, `StoredProcedure`, `Function`, `Sequence`, `Schema`, `Role`, `User`, `Synonym`, `UserDefinedType`, `XmlSchemaCollection`, `PartitionFunction`, `PartitionScheme`, `MessageType`, `Contract`, `Queue`, `Service`, `Route`, `EventNotification`, `ServiceBinding`, `FullTextCatalog`, `FullTextStoplist`, `SearchPropertyList`. +- `status`, `diff`, and `pull` are active for: `Assembly`, `Table`, `View`, `StoredProcedure`, `Function`, `Aggregate`, `Sequence`, `Schema`, `Role`, `User`, `Synonym`, `UserDefinedType`, `XmlSchemaCollection`, `PartitionFunction`, `PartitionScheme`, `MessageType`, `Contract`, `Queue`, `Service`, `Route`, `EventNotification`, `ServiceBinding`, `FullTextCatalog`, `FullTextStoplist`, `SearchPropertyList`. - Additional defined-but-inactive object types remain tracked in `specs/04-scripting.md` Section 5.2. - Config runtime contract is simplified to: - `database` (existing fields) diff --git a/src/SqlChangeTracker/PACKAGE_README.md b/src/SqlChangeTracker/PACKAGE_README.md index 33f19cc..74e5e37 100644 --- a/src/SqlChangeTracker/PACKAGE_README.md +++ b/src/SqlChangeTracker/PACKAGE_README.md @@ -33,6 +33,7 @@ Current runtime scope for `status`, `diff`, and `pull` covers: - `View` - `StoredProcedure` - `Function` +- `Aggregate` - `Sequence` - `Schema` - `Role` @@ -63,6 +64,8 @@ Table scripting also includes standalone user-created table statistics (`CREATE Function scripting covers T-SQL scalar/table functions and SQL CLR scalar/table-valued functions (`sys.objects.type = 'FS'` and `FT`), including `EXTERNAL NAME` assembly bindings. +Aggregate scripting covers SQL CLR aggregates (`sys.objects.type = 'AF'`), including `CREATE AGGREGATE ... RETURNS ... EXTERNAL NAME` bindings. + When `data.trackedTables` is configured, `status`, `diff`, and `pull` also process `TableData` artifacts for those explicit tracked tables. Feature-backed object types are included when the target database exposes them, such as `FullTextCatalog`, `FullTextStoplist`, and `SearchPropertyList`. diff --git a/src/SqlChangeTracker/Schema/SupportedSqlObjectTypes.cs b/src/SqlChangeTracker/Schema/SupportedSqlObjectTypes.cs index fb0e248..4ebba93 100644 --- a/src/SqlChangeTracker/Schema/SupportedSqlObjectTypes.cs +++ b/src/SqlChangeTracker/Schema/SupportedSqlObjectTypes.cs @@ -19,6 +19,7 @@ internal static class SupportedSqlObjectTypes new("View", "Views", false, true), new("StoredProcedure", "Stored Procedures", false, true), new("Function", "Functions", false, true), + new("Aggregate", "Functions", false, true), new("Sequence", "Sequences", false, true), new("Schema", Path.Combine("Security", "Schemas"), true, true), new("Role", Path.Combine("Security", "Roles"), true, true), diff --git a/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs b/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs index bfb0dc6..9d8e103 100644 --- a/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs +++ b/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs @@ -17,6 +17,16 @@ internal class SqlServerIntrospector "sp_upgraddiagrams" }; + private static readonly HashSet ExcludedFunctionNames = new(StringComparer.OrdinalIgnoreCase) + { + "fn_diagramobjects" + }; + + private static readonly HashSet ExcludedTableNames = new(StringComparer.OrdinalIgnoreCase) + { + "sysdiagrams" + }; + public virtual IReadOnlyList ListObjects(SqlConnectionOptions options, int maxParallelism = 0) { var dop = ResolveParallelism(maxParallelism); @@ -34,7 +44,7 @@ FROM sys.assemblies a SELECT s.name AS schema_name, o.name AS object_name, o.type FROM sys.objects o JOIN sys.schemas s ON s.schema_id = o.schema_id -WHERE o.is_ms_shipped = 0 AND o.type IN ('U','V','P','PC','FN','TF','IF','FS','FT') +WHERE o.is_ms_shipped = 0 AND o.type IN ('U','V','P','PC','FN','TF','IF','FS','FT','AF') ORDER BY s.name, o.name;", MapObjectType), () => RunQuery(options, @" @@ -491,6 +501,21 @@ AND o.type IN ('FN','TF','IF','FS','FT') AND s.name = @schema AND o.name = @name ORDER BY s.name, o.name; +"""; + command.Parameters.AddWithValue("@schema", schema); + command.Parameters.AddWithValue("@name", name); + break; + + case "Aggregate": + command.CommandText = """ +SELECT s.name AS schema_name, o.name AS object_name +FROM sys.objects o +JOIN sys.schemas s ON s.schema_id = o.schema_id +WHERE o.is_ms_shipped = 0 + AND o.type = 'AF' + AND s.name = @schema + AND o.name = @name +ORDER BY s.name, o.name; """; command.Parameters.AddWithValue("@schema", schema); command.Parameters.AddWithValue("@name", name); @@ -792,12 +817,34 @@ FROM sys.registered_search_property_lists sp } internal static bool ShouldIncludeObject(DbObjectInfo item) - => !string.Equals(item.ObjectType, "StoredProcedure", StringComparison.OrdinalIgnoreCase) - || !IsExcludedStoredProcedureName(item.Name); + { + if (string.Equals(item.ObjectType, "StoredProcedure", StringComparison.OrdinalIgnoreCase)) + { + return !IsExcludedStoredProcedureName(item.Name); + } + + if (string.Equals(item.ObjectType, "Function", StringComparison.OrdinalIgnoreCase)) + { + return !IsExcludedFunctionName(item.Name); + } + + if (string.Equals(item.ObjectType, "Table", StringComparison.OrdinalIgnoreCase)) + { + return !IsExcludedTableName(item.Name); + } + + return true; + } internal static bool IsExcludedStoredProcedureName(string name) => ExcludedStoredProcedureNames.Contains(name); + internal static bool IsExcludedFunctionName(string name) + => ExcludedFunctionNames.Contains(name); + + internal static bool IsExcludedTableName(string name) + => ExcludedTableNames.Contains(name); + private static bool ObjectExists(SqlConnection connection, string objectName) { using var command = connection.CreateCommand(); @@ -817,6 +864,7 @@ private static string MapObjectType(string type) "V" => "View", "P" or "PC" => "StoredProcedure", "FN" or "TF" or "IF" or "FS" or "FT" => "Function", + "AF" => "Aggregate", "SQ" => "Sequence", "SC" => "Schema", "SY" => "Synonym", diff --git a/src/SqlChangeTracker/Sql/SqlServerScripter.cs b/src/SqlChangeTracker/Sql/SqlServerScripter.cs index 5003e21..18224df 100644 --- a/src/SqlChangeTracker/Sql/SqlServerScripter.cs +++ b/src/SqlChangeTracker/Sql/SqlServerScripter.cs @@ -19,7 +19,7 @@ internal class SqlServerScripter @"^\s*\[[^\]]+\]\s+AS\s+", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private static readonly Regex ModuleDeclarationLineRegex = new( - @"^(?\s*)(?CREATE(?:\s+OR\s+ALTER)?\s+(?:PROC(?:EDURE)?|FUNCTION|VIEW|TRIGGER)\s+)(?(?:\[[^\]]+(?:\]\])*\]|[A-Za-z_][\w@#$]*)(?:\.(?:\[[^\]]+(?:\]\])*\]|[A-Za-z_][\w@#$]*))*)(?.*)$", + @"^(?\s*)(?CREATE(?:\s+OR\s+ALTER)?\s+(?:PROC(?:EDURE)?|FUNCTION|VIEW|TRIGGER|AGGREGATE)\s+)(?(?:\[[^\]]+(?:\]\])*\]|[A-Za-z_][\w@#$]*)(?:\.(?:\[[^\]]+(?:\]\])*\]|[A-Za-z_][\w@#$]*))*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private static readonly Regex ClrTableValuedFunctionReturnColumnNullRegex = new( @"^(?\s*(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*)\s+(?:(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*)(?:\.(?:\[[^\]]+\]|[A-Za-z_][\w@#$]*))?)(?:\s*\([^)]*\))?)\s+NULL(?\s*,?\s*)$", @@ -54,6 +54,10 @@ private sealed record ClrFunctionMetadata( int? ExecuteAsPrincipalId, string ExecuteAsPrincipalName); + private sealed record ClrAggregateMetadata( + string AssemblyName, + string AssemblyClass); + private sealed record ClrFunctionParameter( int ParameterId, string Name, @@ -134,6 +138,7 @@ public virtual string ScriptObject(SqlConnectionOptions options, DbObjectInfo ob "StoredProcedure" => ScriptModule(connection, obj, true, referenceLines), "View" => ScriptView(connection, obj, referenceLines), "Function" => ScriptModule(connection, obj, true, referenceLines), + "Aggregate" => ScriptModule(connection, obj, true, referenceLines), "Sequence" => ScriptSequence(connection, obj, referenceLines), "Schema" => ScriptSchema(connection, obj, referenceLines), "Role" => ScriptRole(connection, obj, referenceLines), @@ -613,12 +618,17 @@ FROM sys.objects o var isClrStoredProcedure = string.Equals(objectType, "PC", StringComparison.OrdinalIgnoreCase); var isClrScalarFunction = string.Equals(objectType, "FS", StringComparison.OrdinalIgnoreCase); var isClrTableValuedFunction = string.Equals(objectType, "FT", StringComparison.OrdinalIgnoreCase); - var isClrModule = isClrStoredProcedure || isClrScalarFunction || isClrTableValuedFunction; + var isClrAggregate = string.Equals(objectType, "AF", StringComparison.OrdinalIgnoreCase); + var isClrModule = isClrStoredProcedure || isClrScalarFunction || isClrTableValuedFunction || isClrAggregate; ansiNulls = ReadModuleSetOption(reader, moduleColumn: 3, propertyColumn: 5, defaultValue: !isClrModule); quotedIdentifier = ReadModuleSetOption(reader, moduleColumn: 4, propertyColumn: 6, defaultValue: !isClrModule); reader.Close(); - if (isClrStoredProcedure && string.IsNullOrWhiteSpace(definitionText)) + if (isClrAggregate) + { + definitionText = BuildClrAggregateDefinition(connection, objectId, obj.Schema, obj.Name); + } + else if (isClrStoredProcedure && string.IsNullOrWhiteSpace(definitionText)) { definitionText = BuildClrStoredProcedureDefinition(connection, objectId, obj.Schema, obj.Name); } @@ -703,6 +713,28 @@ FROM sys.assembly_modules am reader.IsDBNull(4) ? string.Empty : reader.GetString(4)); } + private static ClrAggregateMetadata ReadClrAggregateMetadata(SqlConnection connection, int objectId, string schema, string name) + { + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT a.name AS assembly_name, + am.assembly_class +FROM sys.assembly_modules am +JOIN sys.assemblies a ON a.assembly_id = am.assembly_id +WHERE am.object_id = @object_id;"; + command.Parameters.AddWithValue("@object_id", objectId); + + using var reader = command.ExecuteReader(); + if (!reader.Read()) + { + throw new InvalidOperationException($"CLR aggregate metadata not found: {QuoteIdentifier(schema)}.{QuoteIdentifier(name)}."); + } + + return new ClrAggregateMetadata( + reader.GetString(0), + reader.IsDBNull(1) ? name : reader.GetString(1)); + } + private static string BuildClrScalarFunctionDefinition( SqlConnection connection, int objectId, @@ -897,6 +929,50 @@ private static string BuildClrTableValuedFunctionDefinition( return string.Join(Environment.NewLine, lines); } + private static string BuildClrAggregateDefinition( + SqlConnection connection, + int objectId, + string schema, + string name) + { + var metadata = ReadClrAggregateMetadata(connection, objectId, schema, name); + var parameters = ReadClrFunctionParameters(connection, objectId); + var inputParameters = parameters + .Where(parameter => parameter.ParameterId > 0) + .OrderBy(parameter => parameter.ParameterId) + .ToArray(); + if (inputParameters.Length == 0) + { + throw new InvalidOperationException($"CLR aggregate input parameter metadata not found: {QuoteIdentifier(schema)}.{QuoteIdentifier(name)}."); + } + + var returnParameter = parameters.FirstOrDefault(parameter => parameter.ParameterId == 0); + if (returnParameter == null) + { + throw new InvalidOperationException($"CLR aggregate return type metadata not found: {QuoteIdentifier(schema)}.{QuoteIdentifier(name)}."); + } + + var argumentList = string.Join( + ", ", + inputParameters.Select(parameter => + $"{parameter.Name} {FormatTypeName(parameter.TypeName, parameter.TypeSchema, parameter.IsUserDefined, parameter.MaxLength, parameter.Precision, parameter.Scale)}")); + var returnType = FormatTypeName( + returnParameter.TypeName, + returnParameter.TypeSchema, + returnParameter.IsUserDefined, + returnParameter.MaxLength, + returnParameter.Precision, + returnParameter.Scale); + + return string.Join( + Environment.NewLine, + [ + $"CREATE AGGREGATE {QuoteIdentifier(schema)}.{QuoteIdentifier(name)} ({argumentList})", + $"RETURNS {returnType}", + $"EXTERNAL NAME {QuoteIdentifier(metadata.AssemblyName)}.{QuoteIdentifier(metadata.AssemblyClass)}" + ]); + } + private static IReadOnlyList ReadClrFunctionParameters(SqlConnection connection, int objectId) { var parameters = new List(); @@ -4944,7 +5020,8 @@ FROM sys.extended_properties ep } if (string.Equals(obj.ObjectType, "StoredProcedure", StringComparison.OrdinalIgnoreCase) || - string.Equals(obj.ObjectType, "Function", StringComparison.OrdinalIgnoreCase)) + string.Equals(obj.ObjectType, "Function", StringComparison.OrdinalIgnoreCase) || + string.Equals(obj.ObjectType, "Aggregate", StringComparison.OrdinalIgnoreCase)) { command.CommandText = @" SELECT ep.name AS prop_name, ep.value AS prop_value, p.name AS param_name @@ -5331,6 +5408,7 @@ FROM sys.fulltext_index_columns fic "View" => "VIEW", "StoredProcedure" => "PROCEDURE", "Function" => "FUNCTION", + "Aggregate" => "AGGREGATE", "Trigger" => "TRIGGER", _ => null }; diff --git a/src/SqlChangeTracker/Sync/SyncCommandService.cs b/src/SqlChangeTracker/Sync/SyncCommandService.cs index 7ca0741..8f1db9b 100644 --- a/src/SqlChangeTracker/Sync/SyncCommandService.cs +++ b/src/SqlChangeTracker/Sync/SyncCommandService.cs @@ -47,6 +47,12 @@ internal sealed class SyncCommandService : ISyncCommandService private static readonly Regex TableUserDefinedTypeScriptRegex = new( @"\bCREATE\s+TYPE\b.*\bAS\s+TABLE\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex FunctionScriptRegex = new( + @"\bCREATE(?:\s+OR\s+ALTER)?\s+FUNCTION\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex AggregateScriptRegex = new( + @"\bCREATE(?:\s+OR\s+ALTER)?\s+AGGREGATE\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.Compiled); private static readonly Regex SqlIdentifierRegex = new( """\G\s*(?\[(?:[^\]]|\]\])+\]|"(?:""|[^"])+"|[^\s(]+)""", RegexOptions.CultureInvariant | RegexOptions.Compiled); @@ -75,7 +81,7 @@ internal sealed class SyncCommandService : ISyncCommandService @"^\s*EXEC(?:UTE)?\s+(?:sys\.)?sp_addextendedproperty\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex SsmsObjectHeaderCommentRegex = new( - @"^\s*/\*{5,}\s*Object:\s+(?:StoredProcedure|Procedure|View|Function|Trigger)\b.*Script Date:.*\*+/\s*$", + @"^\s*/\*{5,}\s*Object:\s+(?:StoredProcedure|Procedure|View|Function|Aggregate|Trigger)\b.*Script Date:.*\*+/\s*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex AssemblyHeaderCommentRegex = new( @"^\s*--\s*Assembly\b.*$", @@ -577,6 +583,7 @@ private CommandExecutionResult ScanFolder(string projectDir) var objects = new Dictionary(StringComparer.OrdinalIgnoreCase); var warnings = new List(); var scannedSqlFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + var processedSchemaFiles = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var entry in ActiveObjectTypes) { @@ -606,6 +613,10 @@ private CommandExecutionResult ScanFolder(string projectDir) { var normalizedFilePath = Path.GetFullPath(file); scannedSqlFiles.Add(normalizedFilePath); + if (!processedSchemaFiles.Add(normalizedFilePath)) + { + continue; + } var fileName = Path.GetFileNameWithoutExtension(file); if (!TryParseObjectFileName(fileName, entry.IsSchemaLess, out var schema, out var name)) @@ -628,7 +639,18 @@ private CommandExecutionResult ScanFolder(string projectDir) ExitCodes.ExecutionFailure); } - if (string.Equals(objectType, "UserDefinedType", StringComparison.OrdinalIgnoreCase) + var resolvedObjectType = objectType; + if ((string.Equals(objectType, "Function", StringComparison.OrdinalIgnoreCase) || + string.Equals(objectType, "Aggregate", StringComparison.OrdinalIgnoreCase)) && + !TryClassifyFunctionFolderScript(script, out resolvedObjectType)) + { + warnings.Add(new CommandWarning( + "invalid_function_script", + $"skipped '{Path.Combine(folder, Path.GetFileName(file))}' because it is not a recognized function or aggregate script.")); + continue; + } + + if (string.Equals(resolvedObjectType, "UserDefinedType", StringComparison.OrdinalIgnoreCase) && !TryClassifyUserDefinedTypeScript(script, out _)) { warnings.Add(new CommandWarning( @@ -643,13 +665,13 @@ private CommandExecutionResult ScanFolder(string projectDir) name = scriptName; } - var key = BuildObjectKey(objectType, schema, name); + var key = BuildObjectKey(resolvedObjectType, schema, name); if (objects.ContainsKey(key)) { var displayName = FormatDisplayName(schema, name); warnings.Add(new CommandWarning( "duplicate_script", - $"skipped duplicate folder object '{displayName}' of type '{objectType}'.")); + $"skipped duplicate folder object '{displayName}' of type '{resolvedObjectType}'.")); continue; } @@ -658,7 +680,7 @@ private CommandExecutionResult ScanFolder(string projectDir) key, schema, name, - objectType, + resolvedObjectType, script, relativePath, normalizedFilePath); @@ -2399,6 +2421,21 @@ internal static bool TryClassifyUserDefinedTypeScript(string script, out UserDef return true; } + internal static bool TryClassifyFunctionFolderScript(string script, out string objectType) + { + var matchesFunction = FunctionScriptRegex.IsMatch(script); + var matchesAggregate = AggregateScriptRegex.IsMatch(script); + + if (matchesFunction == matchesAggregate) + { + objectType = string.Empty; + return false; + } + + objectType = matchesAggregate ? "Aggregate" : "Function"; + return true; + } + internal static bool TryParseObjectFileName(string fileNameWithoutExtension, bool isSchemaLess, out string schema, out string name) { if (isSchemaLess) @@ -4171,6 +4208,7 @@ private static bool IsProgrammableObjectTypeForHeaderCommentCompatibility(string => string.Equals(objectType, "StoredProcedure", StringComparison.OrdinalIgnoreCase) || string.Equals(objectType, "View", StringComparison.OrdinalIgnoreCase) || string.Equals(objectType, "Function", StringComparison.OrdinalIgnoreCase) + || string.Equals(objectType, "Aggregate", StringComparison.OrdinalIgnoreCase) || string.Equals(objectType, "Trigger", StringComparison.OrdinalIgnoreCase); private static bool IsAssemblyObjectTypeForHeaderCommentCompatibility(string? objectType) diff --git a/tests/SqlChangeTracker.Tests/Schema/SchemaFolderMapperTests.cs b/tests/SqlChangeTracker.Tests/Schema/SchemaFolderMapperTests.cs index 18031ca..aa46c4c 100644 --- a/tests/SqlChangeTracker.Tests/Schema/SchemaFolderMapperTests.cs +++ b/tests/SqlChangeTracker.Tests/Schema/SchemaFolderMapperTests.cs @@ -60,6 +60,7 @@ public void Formats_SchemaLessObjects(string objectType, string folder, string n [Theory] [InlineData("Synonym", "Synonyms", "Reporting", "CurrentSales")] + [InlineData("Aggregate", "Functions", "dbo", "RowAccumulator")] [InlineData("UserDefinedType", "Types\\User-defined Data Types", "dbo", "PhoneNumber")] [InlineData("XmlSchemaCollection", "Types\\XML Schema Collections", "dbo", "PayloadSchema")] [InlineData("Queue", "Service Broker\\Queues", "dbo", "Log_InitiatorQueue")] @@ -127,6 +128,7 @@ private static IReadOnlyList BuildFolderMap() new FolderMapEntry("View", "Views"), new FolderMapEntry("StoredProcedure", "Stored Procedures"), new FolderMapEntry("Function", "Functions"), + new FolderMapEntry("Aggregate", "Functions"), new FolderMapEntry("Sequence", "Sequences"), new FolderMapEntry("Schema", @"Security\Schemas"), new FolderMapEntry("Role", @"Security\Roles"), diff --git a/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs b/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs index 7f05d68..b7a1a9d 100644 --- a/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs +++ b/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs @@ -44,11 +44,36 @@ public void IsExcludedStoredProcedureName_RecognizesOnlyDatabaseDiagramSupportPr } [Theory] - [InlineData("sp_helpdiagrams", false)] - [InlineData("usp_ProcessBatch", true)] - public void ShouldIncludeObject_FiltersOnlyExcludedDatabaseDiagramStoredProcedures(string name, bool expected) + [InlineData("fn_diagramobjects", true)] + [InlineData("fn_CustomReport", false)] + public void IsExcludedFunctionName_RecognizesOnlyDatabaseDiagramSupportFunctions(string name, bool expected) { - var actual = SqlServerIntrospector.ShouldIncludeObject(new DbObjectInfo("dbo", name, "StoredProcedure")); + var actual = SqlServerIntrospector.IsExcludedFunctionName(name); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("sysdiagrams", true)] + [InlineData("CustomerLedger", false)] + public void IsExcludedTableName_RecognizesOnlyDatabaseDiagramSupportTables(string name, bool expected) + { + var actual = SqlServerIntrospector.IsExcludedTableName(name); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("StoredProcedure", "sp_helpdiagrams", false)] + [InlineData("StoredProcedure", "usp_ProcessBatch", true)] + [InlineData("Function", "fn_diagramobjects", false)] + [InlineData("Function", "fn_CustomReport", true)] + [InlineData("Table", "sysdiagrams", false)] + [InlineData("Table", "CustomerLedger", true)] + [InlineData("View", "sysdiagrams", true)] + public void ShouldIncludeObject_FiltersOnlyExcludedDatabaseDiagramSupportObjects(string objectType, string name, bool expected) + { + var actual = SqlServerIntrospector.ShouldIncludeObject(new DbObjectInfo("dbo", name, objectType)); Assert.Equal(expected, actual); } @@ -155,6 +180,31 @@ public void ListObjects_IncludesClrStoredProcedures_WhenPresent() string.Equals(item.Name, expected.Value.Name, StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void ListObjects_IncludesClrAggregates_WhenPresent() + { + var options = GetOptions(); + if (options == null) + { + return; + } + + var expected = FindFirstClrAggregate(options); + if (expected == null) + { + return; + } + + var introspector = new SqlServerIntrospector(); + var results = introspector.ListObjects(options); + + Assert.Contains( + results, + item => string.Equals(item.ObjectType, "Aggregate", StringComparison.OrdinalIgnoreCase) && + string.Equals(item.Schema, expected.Value.Schema, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.Name, expected.Value.Name, StringComparison.OrdinalIgnoreCase)); + } + private static SqlConnectionOptions? GetOptions() { var server = Environment.GetEnvironmentVariable("SQLCT_TEST_SERVER"); @@ -220,4 +270,28 @@ FROM sys.objects o return (reader.GetString(0), reader.GetString(1)); } + + private static (string Schema, string Name)? FindFirstClrAggregate(SqlConnectionOptions options) + { + using var connection = SqlConnectionFactory.Create(options); + connection.Open(); + + using var command = connection.CreateCommand(); + command.CommandText = """ +SELECT TOP 1 s.name, o.name +FROM sys.objects o +JOIN sys.schemas s ON s.schema_id = o.schema_id +WHERE o.is_ms_shipped = 0 + AND o.type = 'AF' +ORDER BY s.name, o.name; +"""; + + using var reader = command.ExecuteReader(); + if (!reader.Read()) + { + return null; + } + + return (reader.GetString(0), reader.GetString(1)); + } } diff --git a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs index 4ae419a..d38f4d3 100644 --- a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs +++ b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs @@ -682,6 +682,29 @@ public void ScriptProcedure_EmitsClrStoredProcedureDefinition_WhenPresent() Assert.Contains("AS EXTERNAL NAME", script); } + [Fact] + public void ScriptAggregate_EmitsClrAggregateDefinition_WhenPresent() + { + var options = GetOptions(); + if (options == null) + { + return; + } + + var objInfo = FindFirstClrAggregate(options); + if (objInfo == null) + { + return; + } + + var scripter = new SqlServerScripter(); + var script = scripter.ScriptObject(options, objInfo); + + Assert.Contains("CREATE AGGREGATE", script); + Assert.Contains("RETURNS ", script); + Assert.Contains("EXTERNAL NAME", script); + } + private static SqlConnectionOptions? GetOptions() { var server = Environment.GetEnvironmentVariable("SQLCT_TEST_SERVER"); @@ -1097,6 +1120,30 @@ FROM sys.objects o return new DbObjectInfo(reader.GetString(0), reader.GetString(1), "StoredProcedure"); } + private static DbObjectInfo? FindFirstClrAggregate(SqlConnectionOptions options) + { + using var connection = SqlConnectionFactory.Create(options); + connection.Open(); + + using var command = connection.CreateCommand(); + command.CommandText = """ +SELECT TOP 1 s.name, o.name +FROM sys.objects o +JOIN sys.schemas s ON s.schema_id = o.schema_id +WHERE o.is_ms_shipped = 0 + AND o.type = 'AF' +ORDER BY s.name, o.name; +"""; + + using var reader = command.ExecuteReader(); + if (!reader.Read()) + { + return null; + } + + return new DbObjectInfo(reader.GetString(0), reader.GetString(1), "Aggregate"); + } + private static string CreateModuleReferenceWithObjectLevelExtendedProperty(string levelType) { var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.sql"); diff --git a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs index df6a3c1..be02817 100644 --- a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs +++ b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs @@ -459,7 +459,7 @@ public void RunDiff_WithObjectSelector_UsesTargetedDatabaseDiscovery() Assert.Equal(string.Empty, result.Payload!.Diff); Assert.False(introspector.ListObjectsCalled); Assert.True(introspector.ListMatchingObjectsCalled); - var expectedCandidateTypes = new[] { "Function", "Queue", "Sequence", "StoredProcedure", "Synonym", "Table", "UserDefinedType", "View", "XmlSchemaCollection" }; + var expectedCandidateTypes = new[] { "Aggregate", "Function", "Queue", "Sequence", "StoredProcedure", "Synonym", "Table", "UserDefinedType", "View", "XmlSchemaCollection" }; Assert.Equal( expectedCandidateTypes, introspector.LastRequestedObjectTypes.OrderBy(item => item, StringComparer.OrdinalIgnoreCase)); @@ -769,6 +769,64 @@ public void TryClassifyUserDefinedTypeScript_DetectsSupportedShapes(string scrip Assert.Equal(expected, success); } + [Theory] + [InlineData("CREATE FUNCTION [dbo].[RowSelector] () RETURNS [int] AS BEGIN RETURN 1 END", true, "Function")] + [InlineData("CREATE AGGREGATE [dbo].[RowAccumulator] (@Value [int]) RETURNS [int] EXTERNAL NAME [AppClr].[App.RowAccumulator]", true, "Aggregate")] + [InlineData("CREATE VIEW [dbo].[BrokenScript] AS SELECT 1 AS Value", false, "")] + public void TryClassifyFunctionFolderScript_DetectsSupportedShapes(string script, bool expected, string expectedType) + { + var success = SyncCommandService.TryClassifyFunctionFolderScript(script, out var objectType); + + Assert.Equal(expected, success); + Assert.Equal(expectedType, objectType); + } + + [Theory] + [InlineData( + "dbo.RowSelector.sql", + "CREATE FUNCTION [dbo].[RowSelector] ()\r\nRETURNS [int]\r\nAS\r\nBEGIN\r\n RETURN 1\r\nEND\r\nGO", + "Function")] + [InlineData( + "dbo.RowAccumulator.sql", + "SET QUOTED_IDENTIFIER OFF\r\nGO\r\nSET ANSI_NULLS OFF\r\nGO\r\nCREATE AGGREGATE [dbo].[RowAccumulator] (@Value [int])\r\nRETURNS [int]\r\nEXTERNAL NAME [AppClr].[App.RowAccumulator]\r\nGO", + "Aggregate")] + public void RunStatus_RecognizesFunctionFolderEntriesByScriptShape(string fileName, string script, string expectedType) + { + var tempDir = CreateTempDir(); + + try + { + var projectDir = Path.Combine(tempDir, "project"); + var seed = new BaselineProjectSeeder().Seed(projectDir); + Assert.True(seed.Success); + + var config = SqlctConfigWriter.CreateDefault(); + config.Database.Server = "localhost"; + config.Database.Name = "TestDb"; + var write = new SqlctConfigWriter().Write(SqlctConfigWriter.GetDefaultPath(projectDir), config, overwriteExisting: true); + Assert.True(write.Success); + + CreateFile(projectDir, Path.Combine("Functions", fileName), script); + + var service = new SyncCommandService( + new SqlctConfigReader(), + new TrackingIntrospector { AllObjects = [] }, + new TrackingScripter(), + new SchemaFolderMapper(SupportedSqlObjectTypes.DefaultFolderMap, dataWriteAllFilesInOneDirectory: true)); + + var result = service.RunStatus(projectDir, "folder"); + + Assert.True(result.Success, result.Error?.Detail ?? result.Error?.Message); + Assert.Single(result.Payload!.Objects); + Assert.Equal(expectedType, result.Payload.Objects[0].Type); + Assert.Empty(result.Payload.Warnings); + } + finally + { + CleanupTempDir(tempDir); + } + } + [Theory] [InlineData("CREATE TYPE [dbo].[PhoneNumber] FROM [nvarchar] (20) NOT NULL")] [InlineData("CREATE TYPE [dbo].[RequestList] AS TABLE ([Id] [int] NOT NULL)")] @@ -2324,7 +2382,7 @@ public void RunPull_WithObjectSelector_UsesTargetedDatabaseDiscovery() Assert.Equal(ExitCodes.Success, result.ExitCode); Assert.False(introspector.ListObjectsCalled); Assert.True(introspector.ListMatchingObjectsCalled); - var expectedCandidateTypes = new[] { "Function", "Queue", "Sequence", "StoredProcedure", "Synonym", "Table", "UserDefinedType", "View", "XmlSchemaCollection" }; + var expectedCandidateTypes = new[] { "Aggregate", "Function", "Queue", "Sequence", "StoredProcedure", "Synonym", "Table", "UserDefinedType", "View", "XmlSchemaCollection" }; Assert.Equal( expectedCandidateTypes, introspector.LastRequestedObjectTypes.OrderBy(item => item, StringComparer.OrdinalIgnoreCase));