diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c3cf7d..60cd548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Fixed +- Script schema-level permissions after schema creation and before schema extended properties. +- Match legacy non-canonical schema-less object filenames to the scripted object name when the canonical name requires percent escaping. +- Allow bare `--object` selectors with dotted schema-less names (for example assemblies) to resolve correctly during targeted `status`, `diff`, and `pull`. +- Treat equivalent queue option formatting, explicit default `ON [PRIMARY]`, and disabled default activation as compatible during comparison. +- 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 `Table` scripts as compatible during comparison when post-create statement packages differ only by ordering after the base `CREATE TABLE` block. +- 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 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`. +- 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. ### Added +- 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 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). - `--skip-connection-test` flag for `sqlct init` to bypass the connection test step (#36). @@ -22,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - 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. - 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. - 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/README.md b/README.md index afd5f98..222d6f3 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,14 @@ Current runtime scope for `status`, `diff`, and `pull` covers: - `FullTextStoplist` - `SearchPropertyList` +Schema scripting covers user-defined schemas and also emits built-in `dbo` when it has explicit schema permissions or schema-level extended properties; `dbo` is scripted without `CREATE SCHEMA`. + +Stored procedure scripting covers T-SQL procedures and SQL CLR stored procedures (`sys.objects.type = 'P'` and `PC`). + +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. + 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 a7ad25e..3fda4d3 100644 --- a/specs/01-cli.md +++ b/specs/01-cli.md @@ -195,8 +195,17 @@ Behavior: - Deleted: object exists only in target. - Changed: normalized script content differs. - Suppress changes when scripts are identical after normalization. -- Normalization in v1 is limited to line-ending/trailing-newline stability for deterministic comparison. +- 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. +- 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. +- 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. - 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: @@ -216,8 +225,17 @@ Behavior: - Without `--object`, output concatenated per-object diffs in stable order. - Changed objects use DB-vs-folder unified diff. - Added/deleted objects use empty-side vs script-side unified diff. -- Normalization in v1 is limited to line-ending/trailing-newline stability for deterministic comparison. +- 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. +- 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. +- 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. - 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/03-schema-folder.md b/specs/03-schema-folder.md index ba1ac67..4c9fea8 100644 --- a/specs/03-schema-folder.md +++ b/specs/03-schema-folder.md @@ -56,6 +56,7 @@ Defines the baseline `sqlct` schema-folder structure and naming rules. - Data tracking uses `Data/` for scripts derived from tables explicitly listed in `data.trackedTables`. - Schema-less objects omit the schema prefix (e.g., `Assemblies/AppClr.sql`, `Security/Schemas/AppSecurity.sql`, `Security/Roles/AppReader.sql`, `Storage/Partition Functions/FiscalYear_PF.sql`, `Storage/Full Text Catalogs/DocumentCatalog.sql`, `Storage/Search Property Lists/DocumentProperties.sql`, `Service Broker/Contracts/%2F%2FApp%2FMessaging%2FContract.sql`, `Service Broker/Services/AppInitiatorService.sql`, `Service Broker/Event Notifications/NotifySchemaChanges.sql`, `Service Broker/Remote Service Bindings/AppRemoteBinding.sql`). - Replace invalid file name characters in `Schema` or `Object` with percent-encoded hex (e.g., `:` -> `%3A`, `/` -> `%2F`). +- Folder readers MAY recover object identity for legacy schema-less files whose names are not canonical percent-encoded paths by reading the top-level `CREATE` statement when the scripted object name contains characters that require escaping; writers MUST continue to use canonical percent-encoded file names. - Folder names and casing must remain stable within a project. - Line endings must match existing output (typically CRLF); do not force-normalize. - Object matching is case-insensitive. diff --git a/specs/04-scripting.md b/specs/04-scripting.md index 3e49207..957fd17 100644 --- a/specs/04-scripting.md +++ b/specs/04-scripting.md @@ -41,7 +41,7 @@ This specification defines normative scripting rules for `sqlct`. - Discovery MUST ignore system objects by default. - 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 and excludes `dbo`, `sys`, and `INFORMATION_SCHEMA`. +- `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. - `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. @@ -66,7 +66,7 @@ This specification defines normative scripting rules for `sqlct`. - Assemblies - Views - Stored Procedures -- Functions (`FN`, `TF`, `IF`) +- Functions (`FN`, `TF`, `IF`, `FS`, `FT`) - Sequences - Schema - Role @@ -228,12 +228,13 @@ After base table `CREATE` block and its `GO`, statements MUST be emitted in this 2. CHECK constraints 3. Key constraints (PRIMARY KEY / UNIQUE) 4. Non-constraint indexes -5. XML indexes -6. Foreign keys -7. Grants -8. Extended properties -9. Full-text indexes -10. Lock escalation (only when not `TABLE`) +5. User-created statistics +6. XML indexes +7. Foreign keys +8. Grants +9. Extended properties +10. Full-text indexes +11. Lock escalation (only when not `TABLE`) Each emitted statement MUST be followed by `GO`. @@ -276,7 +277,33 @@ Each emitted statement MUST be followed by `GO`. - `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. -#### 8.1.8 XML Indexes +#### 8.1.8 User-Created Statistics +- User-created statistics MUST be sourced from `sys.stats` and `sys.stats_columns`. +- Include only statistics where: + - `user_created = 1`, + - the statistic is not backed by an index (`sys.indexes.index_id <> sys.stats.stats_id` for the same object). +- Statistics MUST be ordered by statistic name. +- Statistic column lists MUST be ordered by `stats_column_id`. +- Output MUST emit: + - `CREATE STATISTICS [name] ON [schema].[table] ([column_1], [column_2], ...)` + - optional `WHERE ` when the statistic is filtered + - optional `WITH ...` +- Sampling metadata MUST be sourced from `sys.dm_db_stats_properties` when available. +- Statistics `WITH` options MUST include: + - `FULLSCAN` when the effective sampled row count matches the table row count + - `SAMPLE PERCENT` when the effective sampled row count is non-zero and less than the table row count + - `PERSIST_SAMPLE_PERCENT = ON` when `sys.dm_db_stats_properties.persisted_sample_percent > 0` + - `NORECOMPUTE` when `sys.stats.no_recompute = 1` + - `INCREMENTAL=ON` when `sys.stats.is_incremental = 1` +- `AUTO_DROP = ON|OFF` MUST be emitted when the source server exposes `sys.stats.auto_drop`. +- Effective sampling percentages MUST be emitted in invariant-culture decimal form with trailing zeros trimmed. +- When multiple statistics `WITH` options are present, they MUST be emitted in this order: + - `WITH PERCENT>, PERSIST_SAMPLE_PERCENT = ON, NORECOMPUTE, INCREMENTAL=ON, AUTO_DROP = ` +- Statistics scripting MUST preserve the effective sampling state rather than the original `SAMPLE ... PERCENT` or `SAMPLE ... ROWS` construction text. +- Statistics scripting MUST NOT emit `MAXDOP`, `STATS_STREAM`, `ROWCOUNT`, or `PAGECOUNT` options in v1. +- Statistics MUST NOT be emitted as standalone top-level schema objects; they remain post-create table statements. + +#### 8.1.9 XML Indexes - XML indexes MUST be sourced from `sys.xml_indexes` together with column metadata. - XML indexes MUST be ordered by XML `index_id`. - Primary XML index format MUST be: @@ -288,12 +315,12 @@ Each emitted statement MUST be followed by `GO`. - `USING XML INDEX [primary_xml_index]` - `FOR ` -#### 8.1.9 Foreign Keys +#### 8.1.10 Foreign Keys - Foreign keys MUST be ordered by foreign key name. - Column lists MUST follow `constraint_column_id`. - `ON DELETE` and `ON UPDATE` clauses MUST be emitted only when action is not `NO_ACTION`. -#### 8.1.10 Table Extended Properties +#### 8.1.11 Table Extended Properties - Table-level extended properties MUST use: - `EXEC sp_addextendedproperty ..., 'SCHEMA', N'', 'TABLE', N'', NULL, NULL` - Column-level extended properties MUST use: @@ -311,7 +338,7 @@ Each emitted statement MUST be followed by `GO`. 4. index-level (by index name, then property name), 5. trigger-level (by trigger name, then property name). -#### 8.1.11 Full-Text Indexes +#### 8.1.12 Full-Text Indexes - Full-text indexes MUST be sourced from `sys.fulltext_indexes`, `sys.fulltext_index_columns`, `sys.fulltext_catalogs`, `sys.indexes`, and `sys.columns`. - Full-text index base statement MUST be: - `CREATE FULLTEXT INDEX ON [schema].[table] KEY INDEX [unique_key_index] ON [catalog]` @@ -320,7 +347,7 @@ Each emitted statement MUST be followed by `GO`. - `[column] LANGUAGE ` - `[column] TYPE COLUMN [type_column] LANGUAGE ` when a type column is configured. -#### 8.1.12 Lock Escalation +#### 8.1.13 Lock Escalation - `ALTER TABLE [schema].[table] SET ( LOCK_ESCALATION = )` MUST be emitted only when lock escalation is present and not `TABLE`. ### 8.2 Views @@ -346,8 +373,13 @@ Each emitted statement MUST be followed by `GO`. 2. index-level (by index name, then property name). ### 8.3 Stored Procedures -- Stored procedures are scripted through programmable-object framing rules with object type `P` and level type `PROCEDURE`. -- Definition text MUST come from `OBJECT_DEFINITION`. +- Stored procedures are scripted through programmable-object framing rules with object types `P`, `PC` and level type `PROCEDURE`. +- T-SQL stored procedure definition text for `P` MUST come from `OBJECT_DEFINITION`. +- CLR stored procedure (`PC`) metadata MUST be sourced from `sys.assembly_modules`, `sys.assemblies`, and `sys.parameters`. +- CLR stored procedure (`PC`) output MUST emit: + - `CREATE PROCEDURE [schema].[name] ()` + - optional `WITH EXECUTE AS ` + - `AS EXTERNAL NAME [assembly].[class].[method]` - Regex replacements from overrides MUST be applied before compatibility line-map reconciliation. - Compatibility definition line-map reconciliation MUST be applied when reference file exists. - When no compatible reference spacing is preserved, procedure emission MUST use the canonical programmable-object whitespace rules from Section 6.1. @@ -361,8 +393,24 @@ Each emitted statement MUST be followed by `GO`. 2. parameter-level (by parameter name, then property name). ### 8.4 Functions -- Functions are scripted through programmable-object framing rules with object types `FN`, `TF`, `IF` and level type `FUNCTION`. -- Definition text MUST come from `OBJECT_DEFINITION`. +- Functions are scripted through programmable-object framing rules with object types `FN`, `TF`, `IF`, `FS`, `FT` and level type `FUNCTION`. +- T-SQL function definition text for `FN`, `TF`, and `IF` MUST come from `OBJECT_DEFINITION`. +- CLR function metadata for `FS` and `FT` MUST be sourced from `sys.assembly_modules`, `sys.assemblies`, and `sys.parameters`. +- CLR scalar function (`FS`) output MUST emit: + - `CREATE FUNCTION [schema].[name] ()` + - `RETURNS ` + - `WITH EXECUTE AS ` + - `EXTERNAL NAME [assembly].[class].[method]` +- CLR table-valued function (`FT`) return-column metadata MUST be sourced from `sys.columns`, `sys.types`, and type-schema metadata for the function object. +- CLR table-valued function (`FT`) order metadata MUST be sourced from `sys.function_order_columns` when available. +- CLR table-valued function (`FT`) output MUST emit: + - `CREATE FUNCTION [schema].[name] ()` + - `RETURNS TABLE (` + - one return column per line in `column_id` order using `[name] ` + - `)` + - `WITH EXECUTE AS ` + - optional `ORDER ([column_1] , [column_2] , ...)` ordered by `order_column_id` + - `EXTERNAL NAME [assembly].[class].[method]` - Regex replacements from overrides MUST be applied before final emission. - When no compatible reference spacing is preserved, function emission MUST use the canonical programmable-object whitespace rules from Section 6.1. - Grants and extended properties MUST follow module body. @@ -399,13 +447,16 @@ Each emitted statement MUST be followed by `GO`. - Sequence extended properties MUST be emitted after the sequence `GO`, ordered by property name. ### 8.6 Schemas -- Schema scripts MUST emit one `CREATE SCHEMA [name]` statement for each user-defined schema that is in scope. -- `dbo`, `sys`, and `INFORMATION_SCHEMA` MUST NOT be emitted as schema object files. -- When schema ownership metadata is present, `AUTHORIZATION [owner]` MUST be emitted on the following line. -- Schema scripts MUST end with `GO`. +- Schema scripts for user-defined schemas that are in scope MUST emit one `CREATE SCHEMA [name]` statement. +- `sys` and `INFORMATION_SCHEMA` MUST NOT be emitted as schema object files. +- Built-in `dbo` schema is in scope only when it has explicit schema permissions or schema-level extended properties. +- Built-in `dbo` schema scripts MUST omit `CREATE SCHEMA` and `AUTHORIZATION`. +- When schema ownership metadata is present for a user-defined schema, `AUTHORIZATION [owner]` MUST be emitted on the following line. +- The user-defined base schema-create block MUST end with `GO`. +- Schema permissions MUST use `ON SCHEMA::[name]` and MUST be emitted after the base schema `GO` when a base schema-create block is present; otherwise they begin the schema script. - Schema-level extended properties MUST use: - `EXEC sp_addextendedproperty ..., 'SCHEMA', N'', NULL, NULL, NULL, NULL` -- Schema extended properties MUST be emitted after the base schema `GO`, ordered by property name. +- Schema extended properties MUST be emitted after schema permissions, ordered by property name. ### 8.7 Roles - User-defined roles MUST emit `CREATE ROLE [name]` and optional `AUTHORIZATION [owner]`, followed by `GO`. @@ -728,11 +779,11 @@ When compatibility reference files are available, `sqlct` MAY apply reconciliati - after the final definition line and before the trailing `GO`. - Reconciliation MAY align generated definition lines to uniquely matched reference-definition lines. - Reconciliation MAY preserve semantically equivalent built-in/system type-token spelling for table columns when metadata resolves to the same canonical type (for example `[sys].[sysname]` vs `[sysname]`, `[sys].[hierarchyid]` vs `[hierarchyid]`, `(max)` vs `(MAX)`). -- Reconciliation MAY preserve semantically equivalent computed-column expression token spelling when metadata resolves to the same computed expression semantics (for example explicit default `CONVERT(..., (0))` versus the omitted default-style form). +- Reconciliation MAY preserve semantically equivalent computed-column expression token spelling when metadata resolves to the same computed expression semantics (for example explicit default `CONVERT(..., (0))` versus the omitted default-style form, or redundant arithmetic grouping parentheses around a multiplicative subexpression). - Table reconciliation MAY preserve compatible reference formatting within the `CREATE TABLE` block, including the table close line and semantically equivalent column type-token spelling already allowed by this section. - Table reconciliation MAY reuse the full reference `CREATE TABLE` block only when the normalized generated block and normalized reference block are semantically identical in column order, column semantics, and storage clause semantics. - Table reconciliation MAY reuse compatible CHECK-constraint statement lines. -- Table reconciliation MAY preserve compatible relative ordering of key-constraint, non-constraint-index, and XML-index statements within the post-create block segment between CHECK constraints and foreign keys. +- Table reconciliation MAY preserve compatible relative ordering of key-constraint, non-constraint-index, user-created-statistic, and XML-index statements within the post-create block segment between CHECK constraints and foreign keys. - When a reference `CREATE TABLE` block is not semantically identical after normalization, reconciliation MUST keep the generated canonical `CREATE TABLE` block. - Reconciliation MUST NOT be used to change: - user-defined type qualification, @@ -749,7 +800,21 @@ 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. +- 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 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 `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. +- 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. ## 11. Error and Unsupported Behavior - Missing SQL object metadata for requested object MUST fail with an error. diff --git a/specs/08-roadmap.md b/specs/08-roadmap.md index f94f682..4f67dc0 100644 --- a/specs/08-roadmap.md +++ b/specs/08-roadmap.md @@ -14,7 +14,7 @@ Last updated: 2026-04-08 - Improve dependency ordering and diff accuracy - Enhanced JSON outputs - Include/exclude object patterns for status/diff/pull -- Comparison ignore options (whitespace/comments/permissions/etc.) +- Comparison ignore options (whitespace/comments/permissions/etc.), including configurable whitespace comparison rules for blank separators and other whitespace-only differences - Compatibility option sync from other tools into `sqlct.config.json` - Linked-table expansion from foreign-key relationships for data scripting - Migrations support diff --git a/src/SqlChangeTracker/PACKAGE_README.md b/src/SqlChangeTracker/PACKAGE_README.md index 85f8f24..8637da5 100644 --- a/src/SqlChangeTracker/PACKAGE_README.md +++ b/src/SqlChangeTracker/PACKAGE_README.md @@ -53,6 +53,14 @@ Current runtime scope for `status`, `diff`, and `pull` covers: - `FullTextStoplist` - `SearchPropertyList` +Schema scripting covers user-defined schemas and also emits built-in `dbo` when it has explicit schema permissions or schema-level extended properties; `dbo` is scripted without `CREATE SCHEMA`. + +Stored procedure scripting covers T-SQL procedures and SQL CLR stored procedures (`sys.objects.type = 'P'` and `PC`). + +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. + 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/SchemaFolderMapper.cs b/src/SqlChangeTracker/Schema/SchemaFolderMapper.cs index e7b0119..ad63bf2 100644 --- a/src/SqlChangeTracker/Schema/SchemaFolderMapper.cs +++ b/src/SqlChangeTracker/Schema/SchemaFolderMapper.cs @@ -83,7 +83,7 @@ private static string FormatFileName(ObjectIdentifier identifier, bool isData, b return $"{schema}.{name}{suffix}.sql"; } - private static string EscapeFileNamePart(string value) + internal static string EscapeFileNamePart(string value) { if (string.IsNullOrEmpty(value)) { diff --git a/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs b/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs index 8a6d180..f81b196 100644 --- a/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs +++ b/src/SqlChangeTracker/Sql/SqlServerIntrospector.cs @@ -23,7 +23,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','FN','TF','IF') +WHERE o.is_ms_shipped = 0 AND o.type IN ('U','V','P','PC','FN','TF','IF','FS','FT') ORDER BY s.name, o.name;", MapObjectType), () => RunQuery(options, @" @@ -35,20 +35,36 @@ FROM sys.sequences seq () => RunQuery(options, @" SELECT s.name AS schema_name, s.name AS object_name, 'SC' AS type FROM sys.schemas s -WHERE s.name NOT IN ( - 'dbo', - 'guest', - 'sys', - 'INFORMATION_SCHEMA', - 'db_accessadmin', - 'db_backupoperator', - 'db_datareader', - 'db_datawriter', - 'db_ddladmin', - 'db_denydatareader', - 'db_denydatawriter', - 'db_owner', - 'db_securityadmin') +WHERE ( + s.name NOT IN ( + 'dbo', + 'guest', + 'sys', + 'INFORMATION_SCHEMA', + 'db_accessadmin', + 'db_backupoperator', + 'db_datareader', + 'db_datawriter', + 'db_ddladmin', + 'db_denydatareader', + 'db_denydatawriter', + 'db_owner', + 'db_securityadmin') + OR ( + s.name = 'dbo' + AND ( + EXISTS ( + SELECT 1 + FROM sys.database_permissions dp + WHERE dp.class_desc = 'SCHEMA' + AND dp.major_id = s.schema_id) + OR EXISTS ( + SELECT 1 + FROM sys.extended_properties ep + WHERE ep.class_desc = 'SCHEMA' + AND ep.major_id = s.schema_id)) + ) + ) ORDER BY s.name;", MapObjectType), () => RunQuery(options, @" @@ -273,6 +289,41 @@ public virtual IReadOnlyList ListMatchingObjects( .ToArray(); } + public virtual string? GetTableCompatibleOmittedTextImageOnDataSpaceName( + SqlConnectionOptions options, + string schema, + string name) + { + using var connection = SqlConnectionFactory.Create(options); + connection.Open(); + + using var command = connection.CreateCommand(); + command.CommandText = """ +SELECT CASE + WHEN t.lob_data_space_id <> 0 + AND default_ds.data_space_id IS NOT NULL + AND t.lob_data_space_id = default_ds.data_space_id + THEN lob_ds.name + ELSE NULL + END +FROM sys.tables t +JOIN sys.schemas s ON s.schema_id = t.schema_id +LEFT JOIN sys.data_spaces lob_ds ON lob_ds.data_space_id = t.lob_data_space_id +OUTER APPLY ( + SELECT TOP (1) ds.data_space_id + FROM sys.data_spaces ds + WHERE ds.is_default = 1 + ORDER BY ds.data_space_id +) default_ds +WHERE s.name = @schema + AND t.name = @name; +"""; + command.Parameters.AddWithValue("@schema", schema); + command.Parameters.AddWithValue("@name", name); + + return command.ExecuteScalar() as string; + } + internal static int ResolveParallelism(int configured) => configured > 0 ? configured : Environment.ProcessorCount; @@ -401,7 +452,7 @@ FROM sys.objects o FROM sys.objects o JOIN sys.schemas s ON s.schema_id = o.schema_id WHERE o.is_ms_shipped = 0 - AND o.type = 'P' + AND o.type IN ('P','PC') AND s.name = @schema AND o.name = @name ORDER BY s.name, o.name; @@ -416,7 +467,7 @@ FROM sys.objects o 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 ('FN','TF','IF') + AND o.type IN ('FN','TF','IF','FS','FT') AND s.name = @schema AND o.name = @name ORDER BY s.name, o.name; @@ -443,20 +494,36 @@ FROM sys.sequences seq SELECT '' AS schema_name, s.name AS object_name FROM sys.schemas s WHERE s.name = @name - AND s.name NOT IN ( - 'dbo', - 'guest', - 'sys', - 'INFORMATION_SCHEMA', - 'db_accessadmin', - 'db_backupoperator', - 'db_datareader', - 'db_datawriter', - 'db_ddladmin', - 'db_denydatareader', - 'db_denydatawriter', - 'db_owner', - 'db_securityadmin') + AND ( + s.name NOT IN ( + 'dbo', + 'guest', + 'sys', + 'INFORMATION_SCHEMA', + 'db_accessadmin', + 'db_backupoperator', + 'db_datareader', + 'db_datawriter', + 'db_ddladmin', + 'db_denydatareader', + 'db_denydatawriter', + 'db_owner', + 'db_securityadmin') + OR ( + s.name = 'dbo' + AND ( + EXISTS ( + SELECT 1 + FROM sys.database_permissions dp + WHERE dp.class_desc = 'SCHEMA' + AND dp.major_id = s.schema_id) + OR EXISTS ( + SELECT 1 + FROM sys.extended_properties ep + WHERE ep.class_desc = 'SCHEMA' + AND ep.major_id = s.schema_id)) + ) + ) ORDER BY s.name; """; command.Parameters.AddWithValue("@name", name); @@ -715,8 +782,8 @@ private static string MapObjectType(string type) "ASSEMBLY" => "Assembly", "U" => "Table", "V" => "View", - "P" => "StoredProcedure", - "FN" or "TF" or "IF" => "Function", + "P" or "PC" => "StoredProcedure", + "FN" or "TF" or "IF" or "FS" or "FT" => "Function", "SQ" => "Sequence", "SC" => "Schema", "SY" => "Synonym", diff --git a/src/SqlChangeTracker/Sql/SqlServerScripter.cs b/src/SqlChangeTracker/Sql/SqlServerScripter.cs index 32688cd..d92e4df 100644 --- a/src/SqlChangeTracker/Sql/SqlServerScripter.cs +++ b/src/SqlChangeTracker/Sql/SqlServerScripter.cs @@ -18,11 +18,18 @@ internal class SqlServerScripter private static readonly Regex ComputedColumnLineRegex = new( @"^\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@#$]*))*)(?.*)$", + 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*)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private enum TablePostCreateStatementKind { KeyConstraint, NonConstraintIndex, + UserCreatedStatistic, XmlIndex } @@ -40,6 +47,42 @@ internal sealed record AssemblyScriptingInfo( bool IsVisible, IReadOnlyList Files); + private sealed record ClrFunctionMetadata( + string AssemblyName, + string AssemblyClass, + string AssemblyMethod, + int? ExecuteAsPrincipalId, + string ExecuteAsPrincipalName); + + private sealed record ClrFunctionParameter( + int ParameterId, + string Name, + string TypeName, + string TypeSchema, + bool IsUserDefined, + short MaxLength, + byte Precision, + byte Scale, + bool IsOutput, + bool IsReadOnly, + bool HasDefaultValue, + object? DefaultValue); + + private sealed record ClrTableValuedFunctionColumn( + int ColumnId, + string Name, + string TypeName, + string TypeSchema, + bool IsUserDefined, + short MaxLength, + byte Precision, + byte Scale); + + private sealed record ClrTableValuedFunctionOrderColumn( + int OrderColumnId, + string Name, + bool IsDescending); + private readonly record struct TableStorageInfo( string? DataSpace, string? PartitionColumn, @@ -50,6 +93,13 @@ private readonly record struct IndexScriptingOptions( string? PartitionColumn, bool StatisticsIncremental); + private readonly record struct StatisticsScriptingOptions( + string? SamplingClause, + bool PersistSamplePercent, + bool NoRecompute, + bool Incremental, + bool? AutoDrop); + private sealed record TableDataColumn( int ColumnId, string Name, @@ -524,28 +574,58 @@ private static string ScriptModule( bool insertBlankLineAfterSet, string[]? referenceLines) { - var fullName = $"[{obj.Schema}].[{obj.Name}]"; + var fullName = $"{QuoteIdentifier(obj.Schema)}.{QuoteIdentifier(obj.Name)}"; using var command = connection.CreateCommand(); command.CommandText = @" -SELECT m.definition, m.uses_ansi_nulls, m.uses_quoted_identifier +SELECT o.object_id, + o.type, + m.definition, + m.uses_ansi_nulls, + m.uses_quoted_identifier, + OBJECTPROPERTY(o.object_id, 'ExecIsAnsiNullsOn') AS ansi_nulls_property, + OBJECTPROPERTY(o.object_id, 'ExecIsQuotedIdentOn') AS quoted_identifier_property FROM sys.objects o JOIN sys.schemas s ON s.schema_id = o.schema_id -JOIN sys.sql_modules m ON m.object_id = o.object_id +LEFT JOIN sys.sql_modules m ON m.object_id = o.object_id WHERE s.name = @schema AND o.name = @name"; command.Parameters.AddWithValue("@schema", obj.Schema); command.Parameters.AddWithValue("@name", obj.Name); + int objectId; + string objectType; + string definitionText; + bool ansiNulls; + bool quotedIdentifier; using var reader = command.ExecuteReader(); if (!reader.Read()) { - throw new InvalidOperationException($"Object not found: [{obj.Schema}].[{obj.Name}]."); + throw new InvalidOperationException($"Object not found: {fullName}."); } - var definitionText = reader.IsDBNull(0) ? string.Empty : reader.GetString(0); - var ansiNulls = reader.IsDBNull(1) || reader.GetBoolean(1); - var quotedIdentifier = reader.IsDBNull(2) || reader.GetBoolean(2); + objectId = reader.GetInt32(0); + objectType = reader.GetString(1); + definitionText = reader.IsDBNull(2) ? string.Empty : reader.GetString(2); + 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; + 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)) + { + definitionText = BuildClrStoredProcedureDefinition(connection, objectId, obj.Schema, obj.Name); + } + else if (isClrScalarFunction && string.IsNullOrWhiteSpace(definitionText)) + { + definitionText = BuildClrScalarFunctionDefinition(connection, objectId, obj.Schema, obj.Name); + } + else if (isClrTableValuedFunction && string.IsNullOrWhiteSpace(definitionText)) + { + definitionText = BuildClrTableValuedFunctionDefinition(connection, objectId, obj.Schema, obj.Name); + } + var (lines, hasGoAfterDefinition) = BuildProgrammableObjectLines( definitionText, ansiNulls, @@ -572,6 +652,361 @@ FROM sys.objects o return string.Join(Environment.NewLine, lines); } + private static bool ReadModuleSetOption(SqlDataReader reader, int moduleColumn, int propertyColumn, bool defaultValue) + { + if (!reader.IsDBNull(moduleColumn)) + { + return reader.GetBoolean(moduleColumn); + } + + if (!reader.IsDBNull(propertyColumn)) + { + return Convert.ToInt32(reader.GetValue(propertyColumn), CultureInfo.InvariantCulture) != 0; + } + + return defaultValue; + } + + private static ClrFunctionMetadata ReadClrFunctionMetadata(SqlConnection connection, int objectId, string schema, string name) + { + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT a.name AS assembly_name, + am.assembly_class, + am.assembly_method, + am.execute_as_principal_id, + dp.name AS execute_as_name +FROM sys.assembly_modules am +JOIN sys.assemblies a ON a.assembly_id = am.assembly_id +LEFT JOIN sys.database_principals dp ON dp.principal_id = am.execute_as_principal_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 function metadata not found: {QuoteIdentifier(schema)}.{QuoteIdentifier(name)}."); + } + + return new ClrFunctionMetadata( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + reader.IsDBNull(3) ? (int?)null : Convert.ToInt32(reader.GetValue(3), CultureInfo.InvariantCulture), + reader.IsDBNull(4) ? string.Empty : reader.GetString(4)); + } + + private static string BuildClrScalarFunctionDefinition( + SqlConnection connection, + int objectId, + string schema, + string name) + { + var metadata = ReadClrFunctionMetadata(connection, objectId, schema, name); + var parameters = ReadClrFunctionParameters(connection, objectId); + var returnParameter = parameters.FirstOrDefault(parameter => parameter.ParameterId == 0); + if (returnParameter == null) + { + throw new InvalidOperationException($"CLR scalar function return type metadata not found: {QuoteIdentifier(schema)}.{QuoteIdentifier(name)}."); + } + + var argumentList = string.Join( + ", ", + parameters + .Where(parameter => parameter.ParameterId > 0) + .OrderBy(parameter => parameter.ParameterId) + .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); + var executeAsClause = metadata.ExecuteAsPrincipalId switch + { + null => "CALLER", + -2 => "OWNER", + _ when !string.IsNullOrWhiteSpace(metadata.ExecuteAsPrincipalName) => $"'{EscapeSqlStringLiteral(metadata.ExecuteAsPrincipalName)}'", + _ => "CALLER" + }; + + return string.Join( + Environment.NewLine, + [ + $"CREATE FUNCTION {QuoteIdentifier(schema)}.{QuoteIdentifier(name)} ({argumentList})", + $"RETURNS {returnType}", + $"WITH EXECUTE AS {executeAsClause}", + $"EXTERNAL NAME {QuoteIdentifier(metadata.AssemblyName)}.{QuoteIdentifier(metadata.AssemblyClass)}.{QuoteIdentifier(metadata.AssemblyMethod)}" + ]); + } + + private static string BuildClrStoredProcedureDefinition( + SqlConnection connection, + int objectId, + string schema, + string name) + { + var metadata = ReadClrFunctionMetadata(connection, objectId, schema, name); + var parameters = ReadClrFunctionParameters(connection, objectId) + .Where(parameter => parameter.ParameterId > 0) + .OrderBy(parameter => parameter.ParameterId) + .ToArray(); + var parameterList = string.Join(", ", parameters.Select(FormatClrProcedureParameter)); + var executeAsClause = metadata.ExecuteAsPrincipalId switch + { + null => "CALLER", + -2 => "OWNER", + _ when !string.IsNullOrWhiteSpace(metadata.ExecuteAsPrincipalName) => $"'{EscapeSqlStringLiteral(metadata.ExecuteAsPrincipalName)}'", + _ => "CALLER" + }; + var createLine = parameters.Length == 0 + ? $"CREATE PROCEDURE {QuoteIdentifier(schema)}.{QuoteIdentifier(name)}" + : $"CREATE PROCEDURE {QuoteIdentifier(schema)}.{QuoteIdentifier(name)} ({parameterList})"; + + return string.Join( + Environment.NewLine, + [ + createLine, + $"WITH EXECUTE AS {executeAsClause}", + $"AS EXTERNAL NAME {QuoteIdentifier(metadata.AssemblyName)}.{QuoteIdentifier(metadata.AssemblyClass)}.{QuoteIdentifier(metadata.AssemblyMethod)}" + ]); + } + + private static string FormatClrProcedureParameter(ClrFunctionParameter parameter) + { + var typeName = FormatTypeName( + parameter.TypeName, + parameter.TypeSchema, + parameter.IsUserDefined, + parameter.MaxLength, + parameter.Precision, + parameter.Scale); + var builder = new StringBuilder() + .Append(parameter.Name) + .Append(' ') + .Append(typeName); + + if (parameter.HasDefaultValue) + { + builder.Append(" = ").Append(FormatClrParameterDefaultValue(parameter)); + } + + if (parameter.IsOutput) + { + builder.Append(" OUTPUT"); + } + + if (parameter.IsReadOnly) + { + builder.Append(" READONLY"); + } + + return builder.ToString(); + } + + private static string FormatClrParameterDefaultValue(ClrFunctionParameter parameter) + { + var value = parameter.DefaultValue; + if (value is null || value == DBNull.Value) + { + return "NULL"; + } + + var typeName = parameter.TypeName.ToLowerInvariant(); + return value switch + { + string s when typeName is "nchar" or "nvarchar" or "ntext" or "xml" => $"N'{EscapeSqlStringLiteral(s)}'", + string s => $"'{EscapeSqlStringLiteral(s)}'", + char ch when typeName is "nchar" or "nvarchar" or "ntext" or "xml" => $"N'{EscapeSqlStringLiteral(ch.ToString())}'", + char ch => $"'{EscapeSqlStringLiteral(ch.ToString())}'", + bool booleanValue => booleanValue ? "1" : "0", + byte[] bytes => $"0x{Convert.ToHexString(bytes)}", + Guid guid => $"'{guid:D}'", + DateTime dateTime => $"'{dateTime:yyyy-MM-ddTHH:mm:ss.fffffff}'", + DateTimeOffset dateTimeOffset => $"'{dateTimeOffset:yyyy-MM-ddTHH:mm:ss.fffffff zzz}'", + float singleValue => singleValue.ToString("R", CultureInfo.InvariantCulture), + double doubleValue => doubleValue.ToString("R", CultureInfo.InvariantCulture), + decimal decimalValue => decimalValue.ToString(CultureInfo.InvariantCulture), + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture) ?? "NULL", + _ => throw new InvalidOperationException( + $"Unsupported CLR parameter default value type '{value.GetType().FullName}' for parameter {parameter.Name}.") + }; + } + + private static string BuildClrTableValuedFunctionDefinition( + SqlConnection connection, + int objectId, + string schema, + string name) + { + var metadata = ReadClrFunctionMetadata(connection, objectId, schema, name); + var parameters = ReadClrFunctionParameters(connection, objectId) + .Where(parameter => parameter.ParameterId > 0) + .OrderBy(parameter => parameter.ParameterId) + .ToArray(); + var columns = ReadClrTableValuedFunctionColumns(connection, objectId); + if (columns.Count == 0) + { + throw new InvalidOperationException($"CLR table-valued function return columns not found: {QuoteIdentifier(schema)}.{QuoteIdentifier(name)}."); + } + + var orderColumns = ReadClrTableValuedFunctionOrderColumns(connection, objectId); + var argumentList = string.Join( + ", ", + parameters.Select(parameter => + $"{parameter.Name} {FormatTypeName(parameter.TypeName, parameter.TypeSchema, parameter.IsUserDefined, parameter.MaxLength, parameter.Precision, parameter.Scale)}")); + var executeAsClause = metadata.ExecuteAsPrincipalId switch + { + null => "CALLER", + -2 => "OWNER", + _ when !string.IsNullOrWhiteSpace(metadata.ExecuteAsPrincipalName) => $"'{EscapeSqlStringLiteral(metadata.ExecuteAsPrincipalName)}'", + _ => "CALLER" + }; + + var lines = new List + { + $"CREATE FUNCTION {QuoteIdentifier(schema)}.{QuoteIdentifier(name)} ({argumentList})", + "RETURNS TABLE (" + }; + + for (var i = 0; i < columns.Count; i++) + { + var column = columns[i]; + var dataType = FormatTypeName(column.TypeName, column.TypeSchema, column.IsUserDefined, column.MaxLength, column.Precision, column.Scale); + var suffix = i < columns.Count - 1 ? "," : string.Empty; + lines.Add($"{QuoteIdentifier(column.Name)} {dataType}{suffix}"); + } + + lines.Add(")"); + lines.Add($"WITH EXECUTE AS {executeAsClause}"); + + if (orderColumns.Count > 0) + { + lines.Add($"ORDER ({string.Join(", ", orderColumns.Select(column => $"{QuoteIdentifier(column.Name)} {(column.IsDescending ? "DESC" : "ASC")}"))})"); + } + + lines.Add($"EXTERNAL NAME {QuoteIdentifier(metadata.AssemblyName)}.{QuoteIdentifier(metadata.AssemblyClass)}.{QuoteIdentifier(metadata.AssemblyMethod)}"); + return string.Join(Environment.NewLine, lines); + } + + private static IReadOnlyList ReadClrFunctionParameters(SqlConnection connection, int objectId) + { + var parameters = new List(); + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT p.parameter_id, + p.name, + t.name AS type_name, + ts.name AS type_schema, + t.is_user_defined, + p.max_length, + p.precision, + p.scale, + p.is_output, + p.is_readonly, + p.has_default_value, + p.default_value +FROM sys.parameters p +JOIN sys.types t ON t.user_type_id = p.user_type_id +JOIN sys.schemas ts ON ts.schema_id = t.schema_id +WHERE p.object_id = @object_id +ORDER BY p.parameter_id;"; + command.Parameters.AddWithValue("@object_id", objectId); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + parameters.Add(new ClrFunctionParameter( + reader.GetInt32(0), + reader.IsDBNull(1) ? string.Empty : reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + reader.GetBoolean(4), + reader.GetInt16(5), + reader.GetByte(6), + reader.GetByte(7), + reader.GetBoolean(8), + reader.GetBoolean(9), + reader.GetBoolean(10), + reader.IsDBNull(11) ? null : reader.GetValue(11))); + } + + return parameters; + } + + private static IReadOnlyList ReadClrTableValuedFunctionColumns( + SqlConnection connection, + int objectId) + { + var columns = new List(); + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT c.column_id, + c.name, + t.name AS type_name, + ts.name AS type_schema, + t.is_user_defined, + c.max_length, + c.precision, + c.scale +FROM sys.columns c +JOIN sys.types t ON t.user_type_id = c.user_type_id +JOIN sys.schemas ts ON ts.schema_id = t.schema_id +WHERE c.object_id = @object_id +ORDER BY c.column_id;"; + command.Parameters.AddWithValue("@object_id", objectId); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + columns.Add(new ClrTableValuedFunctionColumn( + reader.GetInt32(0), + reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + reader.GetBoolean(4), + reader.GetInt16(5), + reader.GetByte(6), + reader.GetByte(7))); + } + + return columns; + } + + private static IReadOnlyList ReadClrTableValuedFunctionOrderColumns(SqlConnection connection, int objectId) + { + if (ResolveObjectId(connection, "sys.function_order_columns") is null) + { + return Array.Empty(); + } + + var columns = new List(); + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT foc.order_column_id, + c.name, + foc.is_descending +FROM sys.function_order_columns foc +JOIN sys.columns c + ON c.object_id = foc.object_id + AND c.column_id = foc.column_id +WHERE foc.object_id = @object_id +ORDER BY foc.order_column_id;"; + command.Parameters.AddWithValue("@object_id", objectId); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + columns.Add(new ClrTableValuedFunctionOrderColumn( + reader.GetInt32(0), + reader.GetString(1), + reader.GetBoolean(2))); + } + + return columns; + } + private static string ScriptView( SqlConnection connection, DbObjectInfo obj, @@ -774,13 +1209,23 @@ FROM sys.schemas s var schemaName = reader.GetString(0); var ownerName = reader.IsDBNull(1) ? string.Empty : reader.GetString(1); - var lines = new List { $"CREATE SCHEMA [{schemaName}]" }; - if (!string.IsNullOrWhiteSpace(ownerName)) + var isBuiltInDboSchema = string.Equals(schemaName, "dbo", StringComparison.OrdinalIgnoreCase); + var lines = new List(); + if (!isBuiltInDboSchema) { - lines.Add($"AUTHORIZATION [{ownerName}]"); + lines.Add($"CREATE SCHEMA [{schemaName}]"); + if (!string.IsNullOrWhiteSpace(ownerName)) + { + lines.Add($"AUTHORIZATION [{ownerName}]"); + } } reader.Close(); - lines.Add("GO"); + if (!isBuiltInDboSchema) + { + lines.Add("GO"); + } + + lines.AddRange(ReadSchemaPermissions(connection, schemaName, referenceLines)); AppendExtendedPropertyLines(lines, ReadSchemaExtendedProperties(connection, schemaName, referenceLines), referenceLines); AppendTrailingBlankLines(lines, referenceLines); return string.Join(Environment.NewLine, lines); @@ -2268,8 +2713,9 @@ private static string ScriptTable(SqlConnection connection, DbObjectInfo obj, st lines.AddRange(ReadIndexSetOptions(referenceLines)); var keyConstraintLines = ReadTableKeyConstraints(connection, fullName, referenceLines).ToList(); var nonConstraintIndexLines = ReadNonConstraintIndexes(connection, fullName, referenceLines).ToList(); + var userCreatedStatisticLines = ReadTableUserCreatedStatistics(connection, fullName, referenceLines).ToList(); var xmlIndexLines = ReadTableXmlIndexes(connection, fullName).ToList(); - lines.AddRange(ReorderTableKeyAndIndexStatements(referenceLines, keyConstraintLines, nonConstraintIndexLines, xmlIndexLines)); + lines.AddRange(ReorderTableKeyAndIndexStatements(referenceLines, keyConstraintLines, nonConstraintIndexLines, userCreatedStatisticLines, xmlIndexLines)); lines.AddRange(ReadTableForeignKeys(connection, fullName)); lines.AddRange(ReadTableGrants(connection, fullName)); lines.AddRange(ReadTableExtendedProperties(connection, obj.Schema, obj.Name, referenceLines)); @@ -2364,6 +2810,67 @@ internal static string BuildIndexWithClause(string compression, bool statisticsI : $" WITH ({string.Join(", ", options)})"; } + internal static string BuildStatisticsWithClause( + string? samplingClause, + bool persistSamplePercent, + bool noRecompute, + bool incremental, + bool? autoDrop) + { + var options = new List(); + if (!string.IsNullOrWhiteSpace(samplingClause)) + { + options.Add(samplingClause); + } + + if (persistSamplePercent) + { + options.Add("PERSIST_SAMPLE_PERCENT = ON"); + } + + if (noRecompute) + { + options.Add("NORECOMPUTE"); + } + + if (incremental) + { + options.Add("INCREMENTAL=ON"); + } + + if (autoDrop.HasValue) + { + options.Add(autoDrop.Value ? "AUTO_DROP = ON" : "AUTO_DROP = OFF"); + } + + return options.Count == 0 + ? string.Empty + : $" WITH {string.Join(", ", options)}"; + } + + internal static string? BuildStatisticsSamplingClause(long rowCount, long rowsSampled, double? persistedSamplePercent) + { + if (persistedSamplePercent.HasValue && persistedSamplePercent.Value > 0d) + { + return persistedSamplePercent.Value >= 100d + ? "FULLSCAN" + : $"SAMPLE {FormatStatisticsSamplePercent((decimal)persistedSamplePercent.Value)} PERCENT"; + } + + if (rowCount <= 0 || rowsSampled <= 0) + { + return null; + } + + if (rowsSampled >= rowCount) + { + return "FULLSCAN"; + } + + var effectivePercent = (rowsSampled * 100m) / rowCount; + return $"SAMPLE {FormatStatisticsSamplePercent(effectivePercent)} PERCENT"; + } + private static string? TryGetCompatibleReferenceTableOnLine(string[]? referenceLines, string generatedOnLine) { var referenceOnLine = TryGetReferenceTableOnLine(referenceLines); @@ -2597,52 +3104,81 @@ private static string NormalizeComputedColumnCompatibilityTokens(string line) position = normalizedCall.Value.NextIndex; } - return builder.ToString(); + return NormalizeRedundantComputedArithmeticGroupingParentheses(builder.ToString()); } - private static (string Text, int NextIndex)? NormalizeConvertCall(string line, int startIndex) + private static string NormalizeRedundantComputedArithmeticGroupingParentheses(string line) { - const string convertToken = "CONVERT"; - if (!line.AsSpan(startIndex).StartsWith(convertToken, StringComparison.OrdinalIgnoreCase)) + var normalized = line; + while (TryRemoveRedundantComputedArithmeticGroupingParentheses(normalized, out var updated)) { - return null; + normalized = updated; } - var openParenIndex = startIndex + convertToken.Length; - if (openParenIndex >= line.Length || line[openParenIndex] != '(') - { - return null; - } + return normalized; + } - var closeParenIndex = FindMatchingParenthesis(line, openParenIndex); - if (closeParenIndex < 0) + private static bool TryRemoveRedundantComputedArithmeticGroupingParentheses(string line, out string updated) + { + for (var i = 0; i < line.Length; i++) { - return null; + if (line[i] != '(' || !IsRedundantComputedArithmeticGroupingStart(line, i)) + { + continue; + } + + var closeIndex = FindMatchingParenthesis(line, i); + if (closeIndex < 0) + { + continue; + } + + var inner = line.Substring(i + 1, closeIndex - i - 1); + if (!CanRemoveComputedArithmeticGroupingParentheses(inner)) + { + continue; + } + + updated = line.Remove(closeIndex, 1).Remove(i, 1); + return true; } - var argumentsText = line.Substring(openParenIndex + 1, closeParenIndex - openParenIndex - 1); - var arguments = SplitTopLevelArguments(argumentsText); - if (arguments.Count == 3 && IsDefaultConvertStyleArgument(arguments[2])) + updated = line; + return false; + } + + private static bool IsRedundantComputedArithmeticGroupingStart(string line, int openParenIndex) + { + var operatorIndex = FindPreviousNonWhitespaceIndex(line, openParenIndex - 1); + if (operatorIndex < 0 || !IsArithmeticOperator(line[operatorIndex])) { - return ($"CONVERT({arguments[0].Trim()},{arguments[1].Trim()})", closeParenIndex + 1); + return false; } - return (line.Substring(startIndex, closeParenIndex - startIndex + 1), closeParenIndex + 1); + var operandEndIndex = FindPreviousNonWhitespaceIndex(line, operatorIndex - 1); + return operandEndIndex >= 0 && IsOperandTerminator(line[operandEndIndex]); } - private static int FindMatchingParenthesis(string text, int openParenIndex) + private static bool CanRemoveComputedArithmeticGroupingParentheses(string expression) { + if (string.IsNullOrWhiteSpace(expression)) + { + return false; + } + var depth = 0; var inSingleQuotedString = false; var inBracketedIdentifier = false; - for (var i = openParenIndex; i < text.Length; i++) + var expectUnaryOperator = true; + + for (var i = 0; i < expression.Length; i++) { - var ch = text[i]; + var ch = expression[i]; if (inSingleQuotedString) { if (ch == '\'') { - if (i + 1 < text.Length && text[i + 1] == '\'') + if (i + 1 < expression.Length && expression[i + 1] == '\'') { i++; } @@ -2674,12 +3210,179 @@ private static int FindMatchingParenthesis(string text, int openParenIndex) if (ch == '[') { inBracketedIdentifier = true; + expectUnaryOperator = false; continue; } - if (ch == '(') + if (char.IsLetter(ch)) { - depth++; + return false; + } + + if (char.IsDigit(ch)) + { + expectUnaryOperator = false; + continue; + } + + if (char.IsWhiteSpace(ch)) + { + continue; + } + + if (ch == '(') + { + depth++; + expectUnaryOperator = true; + continue; + } + + if (ch == ')') + { + if (depth == 0) + { + return false; + } + + depth--; + expectUnaryOperator = false; + continue; + } + + if (depth == 0) + { + if (ch is '+' or '-') + { + if (!expectUnaryOperator) + { + return false; + } + + continue; + } + + if (ch is '*' or '/' or '%') + { + expectUnaryOperator = true; + continue; + } + + if (ch is '.' or '$') + { + expectUnaryOperator = false; + continue; + } + + return false; + } + + if (ch is ',' or ';') + { + return false; + } + } + + return depth == 0 && !inSingleQuotedString && !inBracketedIdentifier; + } + + private static int FindPreviousNonWhitespaceIndex(string text, int startIndex) + { + for (var i = startIndex; i >= 0; i--) + { + if (!char.IsWhiteSpace(text[i])) + { + return i; + } + } + + return -1; + } + + private static bool IsArithmeticOperator(char ch) => ch is '+' or '-' or '*' or '/' or '%'; + + private static bool IsOperandTerminator(char ch) + => ch == ']' || ch == ')' || ch == '\'' || char.IsLetterOrDigit(ch); + + private static (string Text, int NextIndex)? NormalizeConvertCall(string line, int startIndex) + { + const string convertToken = "CONVERT"; + if (!line.AsSpan(startIndex).StartsWith(convertToken, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var openParenIndex = startIndex + convertToken.Length; + if (openParenIndex >= line.Length || line[openParenIndex] != '(') + { + return null; + } + + var closeParenIndex = FindMatchingParenthesis(line, openParenIndex); + if (closeParenIndex < 0) + { + return null; + } + + var argumentsText = line.Substring(openParenIndex + 1, closeParenIndex - openParenIndex - 1); + var arguments = SplitTopLevelArguments(argumentsText); + if (arguments.Count == 3 && IsDefaultConvertStyleArgument(arguments[2])) + { + return ($"CONVERT({arguments[0].Trim()},{arguments[1].Trim()})", closeParenIndex + 1); + } + + return (line.Substring(startIndex, closeParenIndex - startIndex + 1), closeParenIndex + 1); + } + + private static int FindMatchingParenthesis(string text, int openParenIndex) + { + var depth = 0; + var inSingleQuotedString = false; + var inBracketedIdentifier = 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 (inBracketedIdentifier) + { + if (ch == ']') + { + inBracketedIdentifier = false; + } + + continue; + } + + if (ch == '\'') + { + inSingleQuotedString = true; + continue; + } + + if (ch == '[') + { + inBracketedIdentifier = true; + continue; + } + + if (ch == '(') + { + depth++; } else if (ch == ')') { @@ -3400,6 +4103,158 @@ FROM sys.index_columns ic return lines; } + private static IEnumerable ReadTableUserCreatedStatistics( + SqlConnection connection, + string fullName, + string[]? referenceLines) + { + var supportsStatsAutoDrop = HasSystemObjectColumn(connection, "stats", "auto_drop"); + var supportsPersistedSamplePercent = HasSystemObjectColumn(connection, "dm_db_stats_properties", "persisted_sample_percent"); + + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT s.object_id, + s.stats_id, + s.name, + s.no_recompute, + s.has_filter, + s.filter_definition, + s.is_incremental +FROM sys.stats s +WHERE s.object_id = OBJECT_ID(@full) + AND s.user_created = 1 + AND NOT EXISTS ( + SELECT 1 + FROM sys.indexes i + WHERE i.object_id = s.object_id + AND i.index_id = s.stats_id) +ORDER BY s.name;"; + command.Parameters.AddWithValue("@full", fullName); + + var statisticsLineMap = BuildStatisticsLineMap(referenceLines); + var statistics = new List<(int ObjectId, int StatsId, string Name, bool HasFilter, string? FilterDefinition, bool NoRecompute, bool Incremental)>(); + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { + statistics.Add(( + reader.GetInt32(0), + reader.GetInt32(1), + reader.GetString(2), + reader.GetBoolean(4), + reader.IsDBNull(5) ? null : reader.GetString(5), + reader.GetBoolean(3), + !reader.IsDBNull(6) && reader.GetBoolean(6))); + } + } + + var lines = new List(); + foreach (var statistic in statistics) + { + if (statisticsLineMap != null && + statisticsLineMap.TryGetValue(statistic.Name, out var compatibleLine)) + { + lines.Add(compatibleLine); + lines.Add("GO"); + continue; + } + + command.Parameters.Clear(); + command.CommandText = @" +SELECT c.name +FROM sys.stats_columns sc +JOIN sys.columns c ON c.object_id = sc.object_id AND c.column_id = sc.column_id +WHERE sc.object_id = @obj + AND sc.stats_id = @stats +ORDER BY sc.stats_column_id;"; + command.Parameters.AddWithValue("@obj", statistic.ObjectId); + command.Parameters.AddWithValue("@stats", statistic.StatsId); + + var columns = new List(); + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { + columns.Add(QuoteIdentifier(reader.GetString(0))); + } + } + + if (columns.Count == 0) + { + continue; + } + + var scriptingOptions = ReadStatisticsScriptingOptions( + connection, + statistic.ObjectId, + statistic.StatsId, + statistic.NoRecompute, + statistic.Incremental, + supportsPersistedSamplePercent, + supportsStatsAutoDrop); + var filterClause = statistic.HasFilter && !string.IsNullOrWhiteSpace(statistic.FilterDefinition) + ? $" WHERE {statistic.FilterDefinition}" + : string.Empty; + var withClause = BuildStatisticsWithClause( + scriptingOptions.SamplingClause, + scriptingOptions.PersistSamplePercent, + scriptingOptions.NoRecompute, + scriptingOptions.Incremental, + scriptingOptions.AutoDrop); + + lines.Add($"CREATE STATISTICS {QuoteIdentifier(statistic.Name)} ON {fullName} ({string.Join(", ", columns)}){filterClause}{withClause}"); + lines.Add("GO"); + } + + return lines; + } + + private static StatisticsScriptingOptions ReadStatisticsScriptingOptions( + SqlConnection connection, + int objectId, + int statsId, + bool noRecompute, + bool incremental, + bool supportsPersistedSamplePercent, + bool supportsStatsAutoDrop) + { + using var command = connection.CreateCommand(); + command.CommandText = $@" +SELECT dsp.rows, + dsp.rows_sampled, + {(supportsPersistedSamplePercent ? "dsp.persisted_sample_percent" : "CAST(NULL AS float)")}, + {(supportsStatsAutoDrop ? "s.auto_drop" : "CAST(NULL AS bit)")} +FROM sys.stats s +OUTER APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) dsp +WHERE s.object_id = @obj + AND s.stats_id = @stats;"; + command.Parameters.AddWithValue("@obj", objectId); + command.Parameters.AddWithValue("@stats", statsId); + + using var reader = command.ExecuteReader(); + if (!reader.Read()) + { + return new StatisticsScriptingOptions( + null, + false, + noRecompute, + incremental, + null); + } + + var rowCount = reader.IsDBNull(0) ? 0L : Convert.ToInt64(reader.GetValue(0), CultureInfo.InvariantCulture); + var rowsSampled = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture); + var persistedSamplePercent = reader.IsDBNull(2) ? (double?)null : Convert.ToDouble(reader.GetValue(2), CultureInfo.InvariantCulture); + var autoDrop = reader.IsDBNull(3) ? (bool?)null : Convert.ToBoolean(reader.GetValue(3), CultureInfo.InvariantCulture); + + return new StatisticsScriptingOptions( + BuildStatisticsSamplingClause(rowCount, rowsSampled, persistedSamplePercent), + persistedSamplePercent.HasValue && persistedSamplePercent.Value > 0d, + noRecompute, + incremental, + autoDrop); + } + private static IndexScriptingOptions ReadIndexScriptingOptions(SqlConnection connection, int objectId, int indexId) { return new IndexScriptingOptions( @@ -3467,6 +4322,27 @@ FROM sys.stats s return command.ExecuteScalar() is bool isIncremental && isIncremental; } + private static bool HasSystemObjectColumn(SqlConnection connection, string objectName, string columnName) + { + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM sys.all_columns c + JOIN sys.all_objects o ON o.object_id = c.object_id + JOIN sys.schemas s ON s.schema_id = o.schema_id + WHERE s.name = N'sys' + AND o.name = @objectName + AND c.name = @columnName) +THEN CAST(1 AS bit) +ELSE CAST(0 AS bit) +END;"; + command.Parameters.AddWithValue("@objectName", objectName); + command.Parameters.AddWithValue("@columnName", columnName); + + return command.ExecuteScalar() is bool exists && exists; + } + private static IEnumerable ReadTableForeignKeys(SqlConnection connection, string fullName) { using var command = connection.CreateCommand(); @@ -3571,6 +4447,24 @@ FROM sys.database_permissions dp fullName, referenceLines); + private static IEnumerable ReadSchemaPermissions( + SqlConnection connection, + string schemaName, + 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.schemas s ON s.schema_id = dp.major_id +WHERE dp.class_desc = 'SCHEMA' + AND s.name = @name +ORDER BY pr.name, dp.permission_name;", + command => command.Parameters.AddWithValue("@name", schemaName), + $"SCHEMA::{QuoteIdentifier(schemaName)}", + referenceLines); + private static IEnumerable ExecutePermissionQuery( SqlConnection connection, string commandText, @@ -3971,11 +4865,13 @@ internal static IReadOnlyList ReorderTableKeyAndIndexStatements( string[]? referenceLines, IReadOnlyList keyConstraintLines, IReadOnlyList nonConstraintIndexLines, + IReadOnlyList userCreatedStatisticLines, IReadOnlyList xmlIndexLines) { var statements = new List(); statements.AddRange(ParseTablePostCreateStatements(keyConstraintLines, TablePostCreateStatementKind.KeyConstraint)); statements.AddRange(ParseTablePostCreateStatements(nonConstraintIndexLines, TablePostCreateStatementKind.NonConstraintIndex)); + statements.AddRange(ParseTablePostCreateStatements(userCreatedStatisticLines, TablePostCreateStatementKind.UserCreatedStatistic)); statements.AddRange(ParseTablePostCreateStatements(xmlIndexLines, TablePostCreateStatementKind.XmlIndex)); if (statements.Count == 0) @@ -4139,6 +5035,19 @@ private static bool TryGetTablePostCreateStatementInfo( return true; } + if (firstLine.StartsWith("CREATE STATISTICS [", StringComparison.OrdinalIgnoreCase)) + { + var statisticsName = ExtractBracketedIdentifier(firstLine, "CREATE STATISTICS ["); + if (statisticsName == null) + { + return false; + } + + kind = TablePostCreateStatementKind.UserCreatedStatistic; + name = statisticsName; + return true; + } + if (firstLine.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase) && firstLine.IndexOf(" INDEX [", StringComparison.OrdinalIgnoreCase) >= 0) { @@ -5372,7 +6281,7 @@ internal static string TrimOuterBlankLines(string? text) return string.Join(Environment.NewLine, trimmed); } - private static string ApplyDefinitionFormatting(string definition, string[]? referenceLines) + internal static string ApplyDefinitionFormatting(string definition, string[]? referenceLines) { var referenceBlock = GetReferenceDefinitionBlock(referenceLines); if (referenceBlock != null && DefinitionMatchesReference(definition, referenceBlock)) @@ -5406,48 +6315,14 @@ private static string ApplyDefinitionFormatting(string definition, string[]? ref private static string[]? GetReferenceDefinitionBlock(string[]? referenceLines) { - if (referenceLines == null || referenceLines.Length == 0) + var range = TryGetReferenceDefinitionRange(referenceLines); + if (range == null) { return null; } - var start = -1; - for (var i = 0; i < referenceLines.Length; i++) - { - if (referenceLines[i].TrimStart().StartsWith("CREATE ", StringComparison.OrdinalIgnoreCase)) - { - start = i; - break; - } - } - - if (start < 0) - { - return null; - } - - var end = -1; - for (var i = start + 1; i < referenceLines.Length; i++) - { - if (string.Equals(referenceLines[i].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) - { - end = i; - break; - } - } - - if (end < 0) - { - end = referenceLines.Length; - } - - if (end <= start) - { - return null; - } - - var block = new string[end - start]; - Array.Copy(referenceLines, start, block, 0, end - start); + var block = new string[range.Value.End - range.Value.Start]; + Array.Copy(referenceLines!, range.Value.Start, block, 0, block.Length); return block; } @@ -5511,51 +6386,17 @@ private static string ApplyDefinitionIndent(string definition, string indentPref private static Dictionary? BuildDefinitionLineMap(string[]? referenceLines) { - if (referenceLines == null || referenceLines.Length == 0) - { - return null; - } - - var start = -1; - for (var i = 0; i < referenceLines.Length; i++) - { - if (referenceLines[i].TrimStart().StartsWith("CREATE ", StringComparison.OrdinalIgnoreCase)) - { - start = i; - break; - } - } - - if (start < 0) - { - return null; - } - - var end = -1; - for (var i = start + 1; i < referenceLines.Length; i++) - { - if (string.Equals(referenceLines[i].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) - { - end = i; - break; - } - } - - if (end < 0) - { - end = referenceLines.Length; - } - - if (end <= start) + var range = TryGetReferenceDefinitionRange(referenceLines); + if (range == null) { return null; } var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - for (var i = start; i < end; i++) + for (var i = range.Value.Start; i < range.Value.End; i++) { - var key = NormalizeDefinitionLineKey(referenceLines[i]); + var key = NormalizeDefinitionLineKey(referenceLines![i]); if (key.Length == 0) { continue; @@ -5614,9 +6455,107 @@ private static string NormalizeDefinitionLineKey(string line) normalized = "CREATE " + normalized.Substring(createOrAlter.Length); } + normalized = NormalizeClrTableValuedFunctionReturnColumnLineKey(normalized); + normalized = NormalizeModuleDeclarationLineKey(normalized); return normalized; } + private static string NormalizeClrTableValuedFunctionReturnColumnLineKey(string normalized) + { + var match = ClrTableValuedFunctionReturnColumnNullRegex.Match(normalized); + return match.Success + ? match.Groups["prefix"].Value + match.Groups["suffix"].Value + : normalized; + } + + private static string NormalizeModuleDeclarationLineKey(string normalized) + { + var match = ModuleDeclarationLineRegex.Match(normalized); + if (!match.Success) + { + return normalized; + } + + var canonicalName = NormalizeMultipartIdentifierToken(match.Groups["name"].Value); + return match.Groups["prefix"].Value + canonicalName + match.Groups["suffix"].Value; + } + + private static string NormalizeMultipartIdentifierToken(string identifier) + { + var parts = SplitMultipartIdentifier(identifier); + if (parts.Count == 0) + { + return identifier; + } + + return string.Join(".", parts.Select(UnquoteIdentifierPart)); + } + + private static List SplitMultipartIdentifier(string identifier) + { + var parts = new List(); + var current = new StringBuilder(); + var inBracketedIdentifier = false; + + for (var i = 0; i < identifier.Length; i++) + { + var ch = identifier[i]; + if (inBracketedIdentifier) + { + current.Append(ch); + if (ch == ']') + { + if (i + 1 < identifier.Length && identifier[i + 1] == ']') + { + current.Append(identifier[i + 1]); + i++; + } + else + { + inBracketedIdentifier = false; + } + } + + continue; + } + + if (ch == '[') + { + inBracketedIdentifier = true; + current.Append(ch); + continue; + } + + if (ch == '.') + { + parts.Add(current.ToString()); + current.Clear(); + continue; + } + + current.Append(ch); + } + + if (current.Length > 0) + { + parts.Add(current.ToString()); + } + + return parts; + } + + private static string UnquoteIdentifierPart(string part) + { + if (part.Length >= 2 && + part[0] == '[' && + part[^1] == ']') + { + return part.Substring(1, part.Length - 2).Replace("]]", "]"); + } + + return part; + } + private static List BuildSetHeaderLines(string[]? referenceLines, string quotedLine, string ansiLine) { if (referenceLines == null || referenceLines.Length == 0) @@ -5961,9 +6900,16 @@ private static List BuildPartitionSchemeLines( } var indentPrefix = string.Empty; - if (createIndex >= 0) + var firstDefinitionIndex = goIndex + 1; + while (firstDefinitionIndex < referenceLines.Length && + referenceLines[firstDefinitionIndex].Trim().Length == 0) + { + firstDefinitionIndex++; + } + + if (firstDefinitionIndex < referenceLines.Length) { - var line = referenceLines[createIndex]; + var line = referenceLines[firstDefinitionIndex]; var firstNonSpace = line.Length - line.TrimStart().Length; indentPrefix = firstNonSpace > 0 ? line.Substring(0, firstNonSpace) : string.Empty; } @@ -5971,6 +6917,84 @@ private static List BuildPartitionSchemeLines( return new ModuleFormat(leadingBlank, blankBeforeGo, indentPrefix, hasGoAfterDefinition); } + private static (int Start, int End)? TryGetReferenceDefinitionRange(string[]? referenceLines) + { + if (referenceLines == null || referenceLines.Length == 0) + { + return null; + } + + var start = TryGetReferenceDefinitionStart(referenceLines); + if (start < 0) + { + return null; + } + + var end = -1; + for (var i = start + 1; i < referenceLines.Length; i++) + { + if (string.Equals(referenceLines[i].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + { + end = i; + break; + } + } + + if (end < 0) + { + end = referenceLines.Length; + } + + return end > start ? (start, end) : null; + } + + private static int TryGetReferenceDefinitionStart(string[] referenceLines) + { + var ansiIndex = -1; + var quotedIndex = -1; + for (var i = 0; i < referenceLines.Length; i++) + { + var normalized = NormalizeStatementTerminator(referenceLines[i]); + if (ansiIndex < 0 && + normalized.StartsWith("SET ANSI_NULLS ", StringComparison.OrdinalIgnoreCase)) + { + ansiIndex = i; + } + else if (quotedIndex < 0 && + normalized.StartsWith("SET QUOTED_IDENTIFIER ", StringComparison.OrdinalIgnoreCase)) + { + quotedIndex = i; + } + + if (ansiIndex >= 0 && quotedIndex >= 0) + { + break; + } + } + + var lastSetIndex = Math.Max(ansiIndex, quotedIndex); + if (lastSetIndex >= 0) + { + for (var i = lastSetIndex + 1; i < referenceLines.Length; i++) + { + if (string.Equals(referenceLines[i].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + { + return i + 1; + } + } + } + + for (var i = 0; i < referenceLines.Length; i++) + { + if (referenceLines[i].Trim().Length > 0) + { + return i; + } + } + + return -1; + } + private static string NormalizeStatementTerminator(string line) { var trimmed = line.Trim(); @@ -5979,6 +7003,12 @@ private static string NormalizeStatementTerminator(string line) : trimmed; } + private static string FormatStatisticsSamplePercent(decimal value) + { + var rounded = decimal.Round(value, 12, MidpointRounding.AwayFromZero); + return rounded.ToString("0.############", CultureInfo.InvariantCulture); + } + private static Dictionary? BuildCheckConstraintLineMap(string[]? referenceLines) { if (referenceLines == null || referenceLines.Length == 0) @@ -6106,6 +7136,39 @@ private static string NormalizeStatementTerminator(string line) return map.Count == 0 ? null : map; } + private static Dictionary? BuildStatisticsLineMap(string[]? referenceLines) + { + if (referenceLines == null || referenceLines.Length == 0) + { + return null; + } + + var map = new Dictionary(StringComparer.Ordinal); + foreach (var line in referenceLines) + { + var trimmed = line.TrimStart(); + if (!trimmed.StartsWith("CREATE STATISTICS [", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var start = "CREATE STATISTICS [".Length; + var end = trimmed.IndexOf(']', start); + if (end <= start) + { + continue; + } + + var name = trimmed.Substring(start, end - start); + if (!map.ContainsKey(name)) + { + map[name] = trimmed; + } + } + + return map.Count == 0 ? null : map; + } + private static List? GetReferenceIndexOrder(string[]? referenceLines) { if (referenceLines == null || referenceLines.Length == 0) diff --git a/src/SqlChangeTracker/Sync/SyncCommandService.cs b/src/SqlChangeTracker/Sync/SyncCommandService.cs index 9d986eb..27dcd29 100644 --- a/src/SqlChangeTracker/Sync/SyncCommandService.cs +++ b/src/SqlChangeTracker/Sync/SyncCommandService.cs @@ -45,6 +45,27 @@ 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 SqlIdentifierRegex = new( + """\G\s*(?\[(?:[^\]]|\]\])+\]|"(?:""|[^"])+"|[^\s(]+)""", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + 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*)$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + 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 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 ExtendedPropertyStatementRegex = new( + @"^\s*EXEC(?:UTE)?\s+(?:sys\.)?sp_addextendedproperty\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 IReadOnlyList ActiveObjectTypes = SupportedSqlObjectTypes.ActiveSync; private readonly SqlctConfigReader _configReader; @@ -355,7 +376,7 @@ public CommandExecutionResult RunPull(string? projectDir, string? ob continue; } - if (ScriptsEqualForComparison(dbObject.Script, folderObject.Script)) + if (ScriptsEqualForComparison(dbObject, folderObject)) { IncrementPullCounter(dbObject.ObjectType, ref schemaUnchanged, ref dataUnchanged); continue; @@ -572,16 +593,6 @@ private CommandExecutionResult ScanFolder(string projectDir) continue; } - var key = BuildObjectKey(objectType, 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}'.")); - continue; - } - string script; try { @@ -603,6 +614,22 @@ private CommandExecutionResult ScanFolder(string projectDir) continue; } + if (TryResolveSchemaLessFolderIdentityFromScript(objectType, fileName, script, name, out var scriptName)) + { + schema = string.Empty; + name = scriptName; + } + + var key = BuildObjectKey(objectType, 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}'.")); + continue; + } + var relativePath = Path.Combine(folder, Path.GetFileName(file)); objects[key] = new InternalObject( key, @@ -721,6 +748,28 @@ private CommandExecutionResult ScanDatabase( return; } + string? compatibleOmittedTextImageOnDataSpaceName = null; + if (string.Equals(dbObject.ObjectType, "Table", StringComparison.OrdinalIgnoreCase)) + { + try + { + compatibleOmittedTextImageOnDataSpaceName = + _introspector.GetTableCompatibleOmittedTextImageOnDataSpaceName( + context.ConnectionOptions, + dbObject.Schema, + dbObject.Name); + } + catch (Exception ex) + { + Interlocked.CompareExchange( + ref firstFailure, + ToRuntimeFailure(ex, "failed to read table comparison metadata."), + null); + loopState.Stop(); + return; + } + } + var key = BuildObjectKey(dbObject.ObjectType, dbObject.Schema, dbObject.Name); // TryAdd is a no-op for duplicate keys; duplicates are not expected since each catalog query // targets distinct object types, and this matches the original ContainsKey guard behavior. @@ -731,7 +780,8 @@ private CommandExecutionResult ScanDatabase( dbObject.ObjectType, script, relativePath, - fullPath)); + fullPath, + compatibleOmittedTextImageOnDataSpaceName)); }); if (firstFailure != null) @@ -930,6 +980,28 @@ private CommandExecutionResult ScanDatabaseForSelector( return; } + string? compatibleOmittedTextImageOnDataSpaceName = null; + if (string.Equals(dbObject.ObjectType, "Table", StringComparison.OrdinalIgnoreCase)) + { + try + { + compatibleOmittedTextImageOnDataSpaceName = + _introspector.GetTableCompatibleOmittedTextImageOnDataSpaceName( + context.ConnectionOptions, + dbObject.Schema, + dbObject.Name); + } + catch (Exception ex) + { + Interlocked.CompareExchange( + ref firstFailure, + ToRuntimeFailure(ex, "failed to read table comparison metadata."), + null); + loopState.Stop(); + return; + } + } + var key = BuildObjectKey(dbObject.ObjectType, dbObject.Schema, dbObject.Name); objects.TryAdd(key, new InternalObject( key, @@ -938,7 +1010,8 @@ private CommandExecutionResult ScanDatabaseForSelector( dbObject.ObjectType, script, relativePath, - fullPath)); + fullPath, + compatibleOmittedTextImageOnDataSpaceName)); }); } @@ -1168,6 +1241,23 @@ internal static CommandExecutionResult ParseObjectSelector(strin if (trimmed.Contains('.', StringComparison.Ordinal)) { + var dotCount = 0; + foreach (var character in trimmed) + { + if (character == '.') + { + dotCount++; + } + } + + if (dotCount > 1 && + TryParseObjectFileName(trimmed, isSchemaLess: true, out _, out var dottedSchemaLessName)) + { + return CommandExecutionResult.Ok( + new ObjectSelector(null, string.Empty, dottedSchemaLessName, true, trimmed), + ExitCodes.Success); + } + if (TryParseSchemaAndName(trimmed, out var parsedSchema, out var parsedName)) { return CommandExecutionResult.Ok( @@ -1296,7 +1386,7 @@ private static IReadOnlyList ComputeChanges(ComparisonSnapshot snap continue; } - if (ScriptsEqualForComparison(sourceObject.Script, targetObject.Script)) + if (ScriptsEqualForComparison(sourceObject, targetObject)) { continue; } @@ -1330,7 +1420,7 @@ private static ChangeEntry BuildChangeEntry(ComparisonSnapshot snapshot, Compari return new ChangeEntry(selected, null, null, "unchanged"); } - if (ScriptsEqualForComparison(sourceObject.Script, targetObject.Script)) + if (ScriptsEqualForComparison(sourceObject, targetObject)) { return new ChangeEntry(sourceObject, sourceObject, targetObject, "unchanged"); } @@ -1359,13 +1449,49 @@ private static string BuildDiffText(ChangeEntry entry, string sourceLabel, strin return string.Empty; } - return BuildUnifiedDiff(sourceLabel, targetLabel, sourceScript, targetScript, contextLines); + return BuildUnifiedDiff(entry.SourceObject, entry.TargetObject, sourceLabel, targetLabel, contextLines); } 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? objectType, string sourceLabel, string targetLabel, string sourceScript, string targetScript, int contextLines = 3) + => BuildUnifiedDiffCore(objectType, null, sourceLabel, targetLabel, sourceScript, targetScript, contextLines); + + private static string BuildUnifiedDiff( + InternalObject? sourceObject, + InternalObject? targetObject, + string sourceLabel, + string targetLabel, + int contextLines = 3) + { + var objectType = sourceObject?.ObjectType ?? targetObject?.ObjectType; + var sourceScript = sourceObject?.Script ?? string.Empty; + var targetScript = targetObject?.Script ?? string.Empty; + var compatibleOmittedTextImageOnDataSpaceName = + GetCompatibleOmittedTextImageOnDataSpaceName(sourceObject, targetObject); + + return BuildUnifiedDiffCore( + objectType, + compatibleOmittedTextImageOnDataSpaceName, + sourceLabel, + targetLabel, + sourceScript, + targetScript, + contextLines); + } + + private static string BuildUnifiedDiffCore( + string? objectType, + string? compatibleOmittedTextImageOnDataSpaceName, + string sourceLabel, + string targetLabel, + string sourceScript, + string targetScript, + int contextLines = 3) { - var normalizedSource = NormalizeForComparison(sourceScript); - var normalizedTarget = NormalizeForComparison(targetScript); + var normalizedSource = NormalizeForComparison(sourceScript, objectType, compatibleOmittedTextImageOnDataSpaceName); + var normalizedTarget = NormalizeForComparison(targetScript, objectType, compatibleOmittedTextImageOnDataSpaceName); if (string.Equals(normalizedSource, normalizedTarget, StringComparison.Ordinal)) { return string.Empty; @@ -1683,6 +1809,94 @@ internal static string UnescapeFileNamePart(string value) return builder.ToString(); } + internal static bool TryResolveSchemaLessFolderIdentityFromScript( + string objectType, + string fileNameWithoutExtension, + string script, + string parsedFileName, + out string name) + { + name = string.Empty; + if (!SupportedSqlObjectTypes.IsSchemaLess(objectType) || + !TryExtractSchemaLessCreateName(objectType, script, out var scriptName)) + { + return false; + } + + var canonicalFileName = SchemaFolderMapper.EscapeFileNamePart(scriptName); + if (string.Equals(canonicalFileName, scriptName, StringComparison.Ordinal) || + string.Equals(canonicalFileName, fileNameWithoutExtension.Trim(), StringComparison.OrdinalIgnoreCase) || + string.Equals(scriptName, parsedFileName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + name = scriptName; + return true; + } + + private static bool TryExtractSchemaLessCreateName(string objectType, string script, out string name) + { + name = string.Empty; + var prefixPattern = objectType switch + { + "Assembly" => @"\bCREATE\s+ASSEMBLY\b", + "Schema" => @"\bCREATE\s+SCHEMA\b", + "Role" => @"\bCREATE\s+ROLE\b", + "User" => @"\bCREATE\s+USER\b", + "MessageType" => @"\bCREATE\s+MESSAGE\s+TYPE\b", + "Contract" => @"\bCREATE\s+CONTRACT\b", + "EventNotification" => @"\bCREATE\s+EVENT\s+NOTIFICATION\b", + "ServiceBinding" => @"\bCREATE\s+REMOTE\s+SERVICE\s+BINDING\b", + "Service" => @"\bCREATE\s+SERVICE\b", + "Route" => @"\bCREATE\s+ROUTE\b", + "PartitionFunction" => @"\bCREATE\s+PARTITION\s+FUNCTION\b", + "PartitionScheme" => @"\bCREATE\s+PARTITION\s+SCHEME\b", + "FullTextCatalog" => @"\bCREATE\s+FULLTEXT\s+CATALOG\b", + "FullTextStoplist" => @"\bCREATE\s+FULLTEXT\s+STOPLIST\b", + "SearchPropertyList" => @"\bCREATE\s+SEARCH\s+PROPERTY\s+LIST\b", + _ => null + }; + + if (prefixPattern is null) + { + return false; + } + + var prefixMatch = Regex.Match( + script, + prefixPattern, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!prefixMatch.Success) + { + return false; + } + + var identifierMatch = SqlIdentifierRegex.Match(script, prefixMatch.Index + prefixMatch.Length); + if (!identifierMatch.Success) + { + return false; + } + + name = UnquoteSqlIdentifier(identifierMatch.Groups["identifier"].Value); + return name.Length > 0; + } + + private static string UnquoteSqlIdentifier(string value) + { + if (value.Length >= 2 && value[0] == '[' && value[^1] == ']') + { + return value[1..^1].Replace("]]", "]", StringComparison.Ordinal); + } + + if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') + { + return value[1..^1].Replace("\"\"", "\"", StringComparison.Ordinal); + } + + return value; + } + private static bool IsHexDigit(char value) => (value >= '0' && value <= '9') || (value >= 'A' && value <= 'F') @@ -1739,7 +1953,7 @@ internal static IReadOnlyList ComputeChangesForComparison( continue; } - if (!ScriptsEqualForComparison(sourceObject.Script, targetObject.Script)) + if (!ScriptsEqualForComparison(sourceObject.ObjectType, sourceObject.Script, targetObject.Script)) { results.Add(new ComparableChange(sourceObject, "changed")); } @@ -1748,8 +1962,42 @@ internal static IReadOnlyList ComputeChangesForComparison( return results; } - private static bool ScriptsEqualForComparison(string left, string right) - => string.Equals(NormalizeForComparison(left), NormalizeForComparison(right), StringComparison.Ordinal); + private static bool ScriptsEqualForComparison(InternalObject left, InternalObject right) + => string.Equals( + NormalizeForComparison( + left.Script, + left.ObjectType, + GetCompatibleOmittedTextImageOnDataSpaceName(left, right)), + NormalizeForComparison( + right.Script, + right.ObjectType, + GetCompatibleOmittedTextImageOnDataSpaceName(left, right)), + StringComparison.Ordinal); + + private static bool ScriptsEqualForComparison(string? objectType, string left, string right) + => string.Equals(NormalizeForComparison(left, objectType), NormalizeForComparison(right, objectType), StringComparison.Ordinal); + + private static string? GetCompatibleOmittedTextImageOnDataSpaceName(InternalObject? left, InternalObject? right) + { + var objectType = left?.ObjectType ?? right?.ObjectType; + if (!string.Equals(objectType, "Table", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var leftValue = left?.CompatibleOmittedTextImageOnDataSpaceName; + var rightValue = right?.CompatibleOmittedTextImageOnDataSpaceName; + if (!string.IsNullOrWhiteSpace(leftValue) && !string.IsNullOrWhiteSpace(rightValue)) + { + return string.Equals(leftValue, rightValue, StringComparison.OrdinalIgnoreCase) + ? leftValue + : null; + } + + return !string.IsNullOrWhiteSpace(leftValue) + ? leftValue + : rightValue; + } private static IReadOnlyList GetCandidateDbObjectTypes(ObjectSelector selector) { @@ -1770,44 +2018,1189 @@ private static IReadOnlyList GetCandidateDbObjectTypes(ObjectSelector se } internal static string NormalizeForComparison(string script) + => NormalizeForComparison(script, null); + + internal static string NormalizeForComparison(string script, string? objectType) + => NormalizeForComparison(script, objectType, null); + + internal static string NormalizeForComparison( + string script, + string? objectType, + string? compatibleOmittedTextImageOnDataSpaceName) { var normalized = script .Replace("\r\n", "\n", StringComparison.Ordinal) .Replace("\r", "\n", StringComparison.Ordinal) .TrimEnd('\n'); - // Strip trailing semicolons from INSERT statement lines so that scripts emitted with - // and without statement terminators compare as compatible. Different SQL tools may or - // may not append a semicolon to each INSERT; this normalization prevents superficial - // terminator differences from surfacing as false positives in status and diff output. - // Early exit for scripts with no INSERT lines (e.g. schema objects) to avoid split/join overhead. - if (!normalized.Contains("INSERT ", StringComparison.OrdinalIgnoreCase)) + var lines = normalized.Split('\n'); + for (var i = 0; i < lines.Length; i++) { - return normalized; + if (string.IsNullOrWhiteSpace(lines[i])) + { + lines[i] = string.Empty; + } } - var lines = normalized.Split('\n'); - for (var i = 0; i < lines.Length; i++) + var isTableData = string.Equals(objectType, TableDataObjectType, StringComparison.OrdinalIgnoreCase); + var joined = string.Join("\n", lines); + joined = NormalizeEmptyGoBatchesForComparison(joined); + if (isTableData) + { + return !joined.Contains("INSERT ", StringComparison.OrdinalIgnoreCase) && + !joined.Contains("SET IDENTITY_INSERT ", StringComparison.OrdinalIgnoreCase) + ? joined + : NormalizeLegacyTableDataScript(joined); + } + + if (string.Equals(objectType, "Queue", StringComparison.OrdinalIgnoreCase)) + { + return NormalizeQueueScriptForComparison(joined); + } + + if (string.Equals(objectType, "Role", StringComparison.OrdinalIgnoreCase)) + { + return NormalizeRoleScriptForComparison(joined); + } + + if (string.Equals(objectType, "MessageType", StringComparison.OrdinalIgnoreCase)) + { + return NormalizeServiceBrokerScriptForComparison(joined, NormalizeMessageTypeBaseBlockForComparison); + } + + if (string.Equals(objectType, "Contract", StringComparison.OrdinalIgnoreCase)) + { + return NormalizeServiceBrokerScriptForComparison(joined, NormalizeContractBaseBlockForComparison); + } + + if (string.Equals(objectType, "Service", StringComparison.OrdinalIgnoreCase)) { - var line = lines[i]; + return NormalizeServiceBrokerScriptForComparison(joined, NormalizeServiceBaseBlockForComparison); + } + + if (string.Equals(objectType, "Function", StringComparison.OrdinalIgnoreCase)) + { + joined = NormalizeClrTableValuedFunctionScriptForComparison(joined); + } + + if (joined.Contains("sp_addextendedproperty", StringComparison.OrdinalIgnoreCase)) + { + joined = NormalizeExtendedPropertyBlocksForComparison(joined); + } + + if (string.Equals(objectType, "Table", StringComparison.OrdinalIgnoreCase)) + { + joined = NormalizeCompatibleOmittedTextImageOnForComparison( + joined, + compatibleOmittedTextImageOnDataSpaceName); + joined = NormalizeTableScriptForComparison(joined); + } + + if (!joined.Contains("INSERT ", StringComparison.OrdinalIgnoreCase)) + { + return joined; + } + + var joinedLines = joined.Split('\n'); + for (var i = 0; i < joinedLines.Length; i++) + { + var line = joinedLines[i]; if (line.EndsWith(';') && LineStartsWithInsert(line)) { - lines[i] = line[..^1]; + line = line[..^1]; + } + + joinedLines[i] = line; + } + + return string.Join("\n", joinedLines); + } + + private static string NormalizeEmptyGoBatchesForComparison(string script) + { + var lines = script.Split('\n'); + var normalizedLines = new List(lines.Length); + var pendingBatchLines = new List(); + var batchHasContent = false; + + foreach (var line in lines) + { + if (string.Equals(line.Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + { + if (batchHasContent) + { + normalizedLines.AddRange(pendingBatchLines); + normalizedLines.Add("GO"); + } + + pendingBatchLines.Clear(); + batchHasContent = false; + continue; + } + + pendingBatchLines.Add(line); + if (!IsIgnorableNoOpBatchLine(line)) + { + batchHasContent = true; } } + normalizedLines.AddRange(pendingBatchLines); + return string.Join("\n", normalizedLines); + } + + private static string NormalizeCompatibleOmittedTextImageOnForComparison( + string script, + string? compatibleOmittedTextImageOnDataSpaceName) + { + if (string.IsNullOrWhiteSpace(compatibleOmittedTextImageOnDataSpaceName)) + { + return script; + } + + var lines = script.Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var match = CompatibleTextImageOnRegex.Match(lines[i]); + if (!match.Success) + { + continue; + } + + var dataSpaceName = UnquoteSqlIdentifier(match.Groups["dataSpace"].Value); + if (!string.Equals( + dataSpaceName, + compatibleOmittedTextImageOnDataSpaceName, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + lines[i] = match.Groups["prefix"].Value + match.Groups["suffix"].Value; + } + return string.Join("\n", lines); } - // Checks whether a line begins with "INSERT " (ignoring leading whitespace) without - // allocating a trimmed string. - private static bool LineStartsWithInsert(string line) + private static bool IsIgnorableNoOpBatchLine(string line) { - var pos = 0; - while (pos < line.Length && line[pos] is ' ' or '\t') pos++; - const int insertPrefixLength = 7; // "INSERT ".Length - return line.Length - pos >= insertPrefixLength && - line.AsSpan(pos, insertPrefixLength).Equals("INSERT ", StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(line)) + { + return true; + } + + var trimmed = line.Trim(); + return trimmed.All(ch => ch == ';'); + } + + private static string NormalizeQueueScriptForComparison(string script) + { + var normalized = Regex.Replace( + script, + @"(?im)^\s*ON\s+\[PRIMARY\]\s*$\n?", + string.Empty, + RegexOptions.CultureInvariant); + normalized = Regex.Replace(normalized, @"\s*=\s*", "=", RegexOptions.CultureInvariant); + normalized = Regex.Replace(normalized, @"\s*,\s*", ",", RegexOptions.CultureInvariant); + normalized = Regex.Replace(normalized, @"\s+\(", "(", RegexOptions.CultureInvariant); + normalized = Regex.Replace(normalized, @"\(\s*", "(", RegexOptions.CultureInvariant); + normalized = Regex.Replace(normalized, @"\s*\)", ")", RegexOptions.CultureInvariant); + normalized = Regex.Replace(normalized, @"\s+", " ", RegexOptions.CultureInvariant).Trim(); + normalized = Regex.Replace( + normalized, + @"\s+ON \[PRIMARY\](?=\s+GO\b|$)", + string.Empty, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + normalized = Regex.Replace( + normalized, + @",ACTIVATION\(STATUS=OFF,EXECUTE AS (?:'dbo'|\[dbo\])\)", + string.Empty, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return normalized; + } + + private static string NormalizeRoleScriptForComparison(string script) + { + var lines = script.Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + lines[i] = NormalizeRoleMembershipLineForComparison(lines[i]); + } + + return string.Join("\n", lines); + } + + private static string NormalizeRoleMembershipLineForComparison(string line) + { + var legacyMatch = RoleMembershipLegacySyntaxRegex.Match(line); + if (legacyMatch.Success) + { + var roleName = UnescapeSqlStringLiteral(legacyMatch.Groups["role"].Value); + var memberName = UnescapeSqlStringLiteral(legacyMatch.Groups["member"].Value); + return $"ALTER ROLE {QuoteIdentifierForComparison(roleName)} ADD MEMBER {QuoteIdentifierForComparison(memberName)}"; + } + + var alterRoleMatch = RoleMembershipAlterRoleSyntaxRegex.Match(line); + if (alterRoleMatch.Success) + { + var roleName = UnquoteSqlIdentifier(alterRoleMatch.Groups["role"].Value); + var memberName = UnquoteSqlIdentifier(alterRoleMatch.Groups["member"].Value); + return $"ALTER ROLE {QuoteIdentifierForComparison(roleName)} ADD MEMBER {QuoteIdentifierForComparison(memberName)}"; + } + + return line; + } + + private static string UnescapeSqlStringLiteral(string value) + => value.Replace("''", "'", StringComparison.Ordinal); + + private static string QuoteIdentifierForComparison(string value) + => $"[{value.Replace("]", "]]", StringComparison.Ordinal)}]"; + + private static string NormalizeServiceBrokerScriptForComparison( + string script, + Func normalizeBaseBlock) + { + var lines = script.Split('\n'); + var goIndex = Array.FindIndex( + lines, + line => string.Equals(line.Trim(), "GO", StringComparison.OrdinalIgnoreCase)); + + if (goIndex < 0) + { + return normalizeBaseBlock(script); + } + + var baseBlock = string.Join("\n", lines.Take(goIndex)); + var normalizedBaseBlock = normalizeBaseBlock(baseBlock); + var remainder = string.Join("\n", lines.Skip(goIndex)); + return string.IsNullOrEmpty(normalizedBaseBlock) + ? remainder + : normalizedBaseBlock + "\n" + remainder; + } + + private static string NormalizeMessageTypeBaseBlockForComparison(string baseBlock) + { + var normalized = CollapseServiceBrokerWhitespace(baseBlock); + normalized = Regex.Replace( + normalized, + @"(?i)\bVALIDATION\s*=\s*XML\b", + "VALIDATION=WELL_FORMED_XML", + RegexOptions.CultureInvariant); + normalized = Regex.Replace( + normalized, + @"(?i)\bVALIDATION\s*=\s*WELL_FORMED_XML\b", + "VALIDATION=WELL_FORMED_XML", + RegexOptions.CultureInvariant); + normalized = Regex.Replace( + normalized, + @"(?i)\bVALIDATION\s*=\s*VALID_XML\s+WITH\s+SCHEMA\s+COLLECTION\s+", + "VALIDATION=VALID_XML WITH SCHEMA COLLECTION ", + RegexOptions.CultureInvariant); + normalized = Regex.Replace( + normalized, + @"(?i)\bVALIDATION\s*=\s*NONE\b", + "VALIDATION=NONE", + RegexOptions.CultureInvariant); + normalized = Regex.Replace( + normalized, + @"(?i)\bVALIDATION\s*=\s*EMPTY\b", + "VALIDATION=EMPTY", + RegexOptions.CultureInvariant); + return normalized; + } + + private static string NormalizeContractBaseBlockForComparison(string baseBlock) + { + var normalized = CollapseServiceBrokerWhitespace(baseBlock); + var openParenIndex = normalized.IndexOf('('); + var closeParenIndex = normalized.LastIndexOf(')'); + if (openParenIndex < 0 || closeParenIndex < openParenIndex) + { + return normalized; + } + + var prefix = normalized[..openParenIndex].TrimEnd(); + var suffix = normalized[(closeParenIndex + 1)..].Trim(); + var body = normalized[(openParenIndex + 1)..closeParenIndex]; + var items = SplitNormalizedServiceBrokerList(body); + + var rebuilt = prefix + "(" + string.Join(",", items) + ")"; + return string.IsNullOrWhiteSpace(suffix) + ? rebuilt + : rebuilt + " " + suffix; + } + + private static string NormalizeServiceBaseBlockForComparison(string baseBlock) + { + var normalized = CollapseServiceBrokerWhitespace(baseBlock); + if (!normalized.Contains("ON QUEUE", StringComparison.OrdinalIgnoreCase)) + { + return normalized; + } + + var openParenIndex = normalized.IndexOf('('); + var closeParenIndex = normalized.LastIndexOf(')'); + if (openParenIndex < 0 || closeParenIndex < openParenIndex) + { + return normalized; + } + + var prefix = normalized[..openParenIndex].TrimEnd(); + var suffix = normalized[(closeParenIndex + 1)..].Trim(); + var body = normalized[(openParenIndex + 1)..closeParenIndex]; + var items = SplitNormalizedServiceBrokerList(body); + + var rebuilt = prefix + "(" + string.Join(",", items) + ")"; + return string.IsNullOrWhiteSpace(suffix) + ? rebuilt + : rebuilt + " " + suffix; + } + + private static string CollapseServiceBrokerWhitespace(string text) + => Regex.Replace(text, @"\s+", " ", RegexOptions.CultureInvariant).Trim(); + + private static IReadOnlyList SplitNormalizedServiceBrokerList(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return Array.Empty(); + } + + return body + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(item => Regex.Replace(item, @"\s+", " ", RegexOptions.CultureInvariant).Trim()) + .OrderBy(item => item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static string NormalizeClrTableValuedFunctionScriptForComparison(string script) + { + if (!script.Contains("EXTERNAL NAME", StringComparison.OrdinalIgnoreCase) || + !script.Contains("RETURNS TABLE", StringComparison.OrdinalIgnoreCase)) + { + return script; + } + + var inputLines = script.Split('\n'); + var outputLines = new List(inputLines.Length); + for (var i = 0; i < inputLines.Length; i++) + { + var line = inputLines[i]; + var splitMatch = ClrTableValuedFunctionReturnColumnNullWithCloseParenRegex.Match(line); + if (splitMatch.Success) + { + outputLines.Add(splitMatch.Groups["prefix"].Value); + outputLines.Add(")"); + continue; + } + + var match = ClrTableValuedFunctionReturnColumnNullRegex.Match(line); + outputLines.Add(match.Success + ? match.Groups["prefix"].Value + match.Groups["suffix"].Value + : line); + } + + return string.Join("\n", outputLines); + } + + // Checks whether a line begins with "INSERT " (ignoring leading whitespace) without + // allocating a trimmed string. + private static bool LineStartsWithInsert(string line) + { + var pos = 0; + while (pos < line.Length && line[pos] is ' ' or '\t') pos++; + const int insertPrefixLength = 7; // "INSERT ".Length + return line.Length - pos >= insertPrefixLength && + line.AsSpan(pos, insertPrefixLength).Equals("INSERT ", StringComparison.OrdinalIgnoreCase); + } + + private static bool LineStartsWithIdentityInsert(string line) + { + var pos = 0; + while (pos < line.Length && line[pos] is ' ' or '\t') pos++; + const string prefix = "SET IDENTITY_INSERT "; + return line.Length - pos >= prefix.Length && + line.AsSpan(pos, prefix.Length).Equals(prefix, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeLegacyTableDataScript(string script) + { + var normalizedSegments = new List(); + var bufferedInsertStatements = new List(); + + static void FlushBufferedInsertStatements(List segments, List inserts) + { + if (inserts.Count == 0) + { + return; + } + + foreach (var statement in inserts.OrderBy(item => item, StringComparer.Ordinal)) + { + segments.Add(statement); + } + + inserts.Clear(); + } + + var position = 0; + while (position < script.Length) + { + var lineEnd = script.IndexOf('\n', position); + var segmentEnd = lineEnd >= 0 ? lineEnd : script.Length; + var line = script.Substring(position, segmentEnd - position); + + if (LineStartsWithIdentityInsert(line)) + { + FlushBufferedInsertStatements(normalizedSegments, bufferedInsertStatements); + normalizedSegments.Add(StripTrailingSemicolon(line)); + position = lineEnd >= 0 ? lineEnd + 1 : script.Length; + continue; + } + + if (LineStartsWithInsert(line)) + { + var insertRange = TryFindInsertValuesStatementRange(script, position); + if (insertRange.HasValue) + { + var statement = script.Substring( + position, + insertRange.Value.StatementEndExclusive - position); + bufferedInsertStatements.Add(NormalizeLegacyTableDataInsertStatement(statement)); + position = insertRange.Value.ConsumedEndExclusive; + continue; + } + } + + FlushBufferedInsertStatements(normalizedSegments, bufferedInsertStatements); + normalizedSegments.Add(line); + position = lineEnd >= 0 ? lineEnd + 1 : script.Length; + } + + FlushBufferedInsertStatements(normalizedSegments, bufferedInsertStatements); + return string.Join("\n", normalizedSegments); + } + + private static string NormalizeExtendedPropertyBlocksForComparison(string script) + { + var lines = script.Split('\n'); + var normalizedLines = new List(lines.Length); + + 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)) + { + var statements = new List(); + while (i < lines.Length && + IsExtendedPropertyStatementLine(lines[i]) && + i + 1 < lines.Length && + string.Equals(lines[i + 1].Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + { + statements.Add(NormalizeExtendedPropertyStatementForComparison(lines[i])); + i += 2; + } + + 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 NormalizeTableScriptForComparison(string script) + { + var blocks = SplitGoDelimitedBlocks(script); + if (blocks.Count == 0) + { + return script; + } + + var createTableIndex = blocks.FindIndex(BlockContainsCreateTable); + if (createTableIndex < 0 || createTableIndex >= blocks.Count - 1) + { + return script; + } + + var normalizedBlocks = new List(blocks.Count); + normalizedBlocks.AddRange(blocks.Take(createTableIndex + 1).Select(JoinBlockLines)); + + var postCreatePackages = BuildTablePostCreatePackages(blocks, createTableIndex + 1); + foreach (var package in postCreatePackages.OrderBy(NormalizeTablePostCreatePackageKey, StringComparer.Ordinal)) + { + normalizedBlocks.Add(package); + } + + return string.Join("\n", normalizedBlocks); + } + + private static List SplitGoDelimitedBlocks(string script) + { + var lines = script.Split('\n'); + var blocks = new List(); + var current = new List(); + + foreach (var line in lines) + { + current.Add(line); + if (string.Equals(line.Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + { + blocks.Add(current.ToArray()); + current.Clear(); + } + } + + if (current.Count > 0) + { + blocks.Add(current.ToArray()); + } + + return blocks; + } + + private static bool BlockContainsCreateTable(string[] block) + => block.Any(line => line.TrimStart().StartsWith("CREATE TABLE", StringComparison.OrdinalIgnoreCase)); + + private static bool BlockContainsCreateTrigger(string[] block) + => block.Any(line => + line.TrimStart().StartsWith("CREATE TRIGGER", StringComparison.OrdinalIgnoreCase) || + line.TrimStart().StartsWith("ALTER TRIGGER", StringComparison.OrdinalIgnoreCase)); + + private static bool IsSetOnlyBlock(string[] block) + { + var contentLines = block + .TakeWhile(line => !string.Equals(line.Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + .Where(line => line.Trim().Length > 0) + .ToArray(); + + return contentLines.Length == 1 && + contentLines[0].TrimStart().StartsWith("SET ", StringComparison.OrdinalIgnoreCase); + } + + private static List BuildTablePostCreatePackages(IReadOnlyList blocks, int startIndex) + { + var packages = new List(); + for (var i = startIndex; i < blocks.Count; i++) + { + if (IsSetOnlyBlock(blocks[i])) + { + var setStart = i; + while (i < blocks.Count && IsSetOnlyBlock(blocks[i])) + { + i++; + } + + if (i < blocks.Count && BlockContainsCreateTrigger(blocks[i])) + { + var packageLines = new List(); + for (var blockIndex = setStart; blockIndex <= i; blockIndex++) + { + packageLines.AddRange(blocks[blockIndex]); + } + + packages.Add(string.Join("\n", packageLines)); + continue; + } + + for (var blockIndex = setStart; blockIndex < i; blockIndex++) + { + packages.Add(JoinBlockLines(blocks[blockIndex])); + } + + i--; + continue; + } + + packages.Add(JoinBlockLines(blocks[i])); + } + + return packages; + } + + private static string NormalizeTablePostCreatePackageKey(string package) + => Regex.Replace(package, @"\s+", " ", RegexOptions.CultureInvariant).Trim(); + + private static string JoinBlockLines(IEnumerable block) + => string.Join("\n", block); + + private static bool IsExtendedPropertyStatementLine(string line) + => ExtendedPropertyStatementRegex.IsMatch(line); + + private static string NormalizeExtendedPropertyStatementForComparison(string statement) + { + var normalized = NormalizeExtendedPropertyStatementWhitespace(statement); + if (TryNormalizeExtendedPropertyStatementArguments(normalized, out var canonical)) + { + return canonical; + } + + return normalized; + } + + private static string NormalizeExtendedPropertyStatementWhitespace(string statement) + { + var builder = new StringBuilder(statement.Length); + var inSingleQuotedString = false; + var inBracketedIdentifier = false; + var pendingSpace = false; + + for (var i = 0; i < statement.Length; i++) + { + var ch = statement[i]; + if (inSingleQuotedString) + { + builder.Append(ch); + if (ch == '\'') + { + if (i + 1 < statement.Length && statement[i + 1] == '\'') + { + builder.Append(statement[i + 1]); + i++; + } + else + { + inSingleQuotedString = false; + } + } + + continue; + } + + if (inBracketedIdentifier) + { + builder.Append(ch); + if (ch == ']') + { + inBracketedIdentifier = false; + } + + continue; + } + + if (ch == '\'') + { + if (pendingSpace && builder.Length > 0 && builder[^1] is not '(' and not ',') + { + builder.Append(' '); + } + + pendingSpace = false; + inSingleQuotedString = true; + builder.Append(ch); + continue; + } + + if (ch == '[') + { + if (pendingSpace && builder.Length > 0 && builder[^1] is not '(' and not ',') + { + builder.Append(' '); + } + + pendingSpace = false; + inBracketedIdentifier = true; + builder.Append(ch); + continue; + } + + if (char.IsWhiteSpace(ch)) + { + pendingSpace = builder.Length > 0; + continue; + } + + if (ch is ',' or '(' or ')') + { + TrimTrailingSpaces(builder); + builder.Append(ch); + pendingSpace = false; + continue; + } + + if (pendingSpace && builder.Length > 0 && builder[^1] is not '(' and not ',') + { + builder.Append(' '); + } + + pendingSpace = false; + builder.Append(ch); + } + + var normalized = builder.ToString().Trim(); + return Regex.Replace( + normalized, + @"(?i)^EXEC(?:UTE)?\s+SYS\.SP_ADDEXTENDEDPROPERTY\b", + "EXEC sp_addextendedproperty", + RegexOptions.CultureInvariant); + } + + private static bool TryNormalizeExtendedPropertyStatementArguments(string statement, out string canonical) + { + const string parameterOrderList = "name,value,level0type,level0name,level1type,level1name,level2type,level2name"; + var prefixMatch = Regex.Match( + statement, + @"^\s*EXEC(?:UTE)?\s+(?:sys\.)?sp_addextendedproperty\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + if (!prefixMatch.Success) + { + canonical = string.Empty; + return false; + } + + var argumentsText = statement[prefixMatch.Length..].Trim(); + if (argumentsText.Length == 0) + { + canonical = "EXEC sp_addextendedproperty"; + return true; + } + + var parameterOrder = parameterOrderList.Split(','); + var parameterValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + var nextPositionalIndex = 0; + + foreach (var token in SplitSqlArgumentList(argumentsText)) + { + var trimmedToken = token.Trim(); + if (trimmedToken.Length == 0) + { + canonical = string.Empty; + return false; + } + + var equalsIndex = FindTopLevelEquals(trimmedToken); + if (equalsIndex >= 0) + { + var parameterName = trimmedToken[..equalsIndex].Trim().TrimStart('@'); + var parameterValue = trimmedToken[(equalsIndex + 1)..].Trim(); + if (parameterName.Length == 0 || + parameterValue.Length == 0 || + !parameterOrder.Contains(parameterName, StringComparer.OrdinalIgnoreCase)) + { + canonical = string.Empty; + return false; + } + + parameterValues[parameterName] = NormalizeExtendedPropertyArgumentValueForComparison(parameterName, parameterValue); + continue; + } + + while (nextPositionalIndex < parameterOrder.Length && + parameterValues.ContainsKey(parameterOrder[nextPositionalIndex])) + { + nextPositionalIndex++; + } + + if (nextPositionalIndex >= parameterOrder.Length) + { + canonical = string.Empty; + return false; + } + + parameterValues[parameterOrder[nextPositionalIndex]] = + NormalizeExtendedPropertyArgumentValueForComparison(parameterOrder[nextPositionalIndex], trimmedToken); + nextPositionalIndex++; + } + + var canonicalArguments = parameterOrder + .Select(name => parameterValues.TryGetValue(name, out var value) ? value : "NULL") + .ToArray(); + canonical = "EXEC sp_addextendedproperty " + string.Join(", ", canonicalArguments); + return true; + } + + private static IEnumerable SplitSqlArgumentList(string argumentsText) + { + var current = new StringBuilder(argumentsText.Length); + var inSingleQuotedString = false; + var inBracketedIdentifier = false; + var parenthesisDepth = 0; + + for (var i = 0; i < argumentsText.Length; i++) + { + var ch = argumentsText[i]; + + if (inSingleQuotedString) + { + current.Append(ch); + if (ch == '\'') + { + if (i + 1 < argumentsText.Length && argumentsText[i + 1] == '\'') + { + current.Append(argumentsText[i + 1]); + i++; + } + else + { + inSingleQuotedString = false; + } + } + + continue; + } + + if (inBracketedIdentifier) + { + current.Append(ch); + if (ch == ']') + { + inBracketedIdentifier = false; + } + + continue; + } + + switch (ch) + { + case '\'': + inSingleQuotedString = true; + current.Append(ch); + break; + case '[': + inBracketedIdentifier = true; + current.Append(ch); + break; + case '(': + parenthesisDepth++; + current.Append(ch); + break; + case ')': + if (parenthesisDepth > 0) + { + parenthesisDepth--; + } + + current.Append(ch); + break; + case ',' when parenthesisDepth == 0: + yield return current.ToString(); + current.Clear(); + break; + default: + current.Append(ch); + break; + } + } + + if (current.Length > 0) + { + yield return current.ToString(); + } + } + + private static int FindTopLevelEquals(string text) + { + var inSingleQuotedString = false; + var inBracketedIdentifier = false; + + for (var i = 0; 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 (inBracketedIdentifier) + { + if (ch == ']') + { + inBracketedIdentifier = false; + } + + continue; + } + + if (ch == '\'') + { + inSingleQuotedString = true; + continue; + } + + if (ch == '[') + { + inBracketedIdentifier = true; + continue; + } + + if (ch == '=') + { + return i; + } + } + + return -1; + } + + private static string NormalizeExtendedPropertyArgumentValueForComparison(string parameterName, string value) + { + var trimmed = value.Trim(); + if (string.Equals(trimmed, "NULL", StringComparison.OrdinalIgnoreCase)) + { + return "NULL"; + } + + if (!string.Equals(parameterName, "value", StringComparison.OrdinalIgnoreCase) && + trimmed.Length >= 3 && + (trimmed[0] == 'N' || trimmed[0] == 'n') && + trimmed[1] == '\'' && + trimmed[^1] == '\'') + { + return trimmed[1..]; + } + + return trimmed; + } + + private static void TrimTrailingSpaces(StringBuilder builder) + { + while (builder.Length > 0 && builder[^1] == ' ') + { + builder.Length--; + } + } + + private static string StripTrailingSemicolon(string line) + => line.EndsWith(';') ? line[..^1] : line; + + private static (int StatementEndExclusive, int ConsumedEndExclusive)? TryFindInsertValuesStatementRange( + string script, + int start) + { + var valuesKeywordIndex = script.IndexOf("VALUES", start, StringComparison.OrdinalIgnoreCase); + if (valuesKeywordIndex < 0) + { + return null; + } + + var valuesOpenParenIndex = script.IndexOf('(', valuesKeywordIndex); + if (valuesOpenParenIndex < 0) + { + return null; + } + + var inSingleQuotedString = false; + var inBracketedIdentifier = false; + var parenDepth = 0; + for (var i = valuesOpenParenIndex; i < script.Length; i++) + { + var ch = script[i]; + if (inSingleQuotedString) + { + if (ch == '\'') + { + if (i + 1 < script.Length && script[i + 1] == '\'') + { + i++; + } + else + { + inSingleQuotedString = false; + } + } + + continue; + } + + if (inBracketedIdentifier) + { + if (ch == ']') + { + inBracketedIdentifier = false; + } + + continue; + } + + if (ch == '[') + { + inBracketedIdentifier = true; + continue; + } + + if (ch == '\'') + { + inSingleQuotedString = true; + continue; + } + + if (ch == '(') + { + parenDepth++; + continue; + } + + if (ch != ')') + { + continue; + } + + parenDepth--; + if (parenDepth != 0) + { + continue; + } + + var statementEndExclusive = i + 1; + var consumedEndExclusive = statementEndExclusive; + while (consumedEndExclusive < script.Length && script[consumedEndExclusive] is ' ' or '\t') + { + consumedEndExclusive++; + } + + if (consumedEndExclusive < script.Length && script[consumedEndExclusive] == ';') + { + consumedEndExclusive++; + } + else + { + consumedEndExclusive = statementEndExclusive; + } + + if (consumedEndExclusive < script.Length && script[consumedEndExclusive] == '\n') + { + consumedEndExclusive++; + } + + return (statementEndExclusive, consumedEndExclusive); + } + + return null; + } + + private static string NormalizeLegacyTableDataInsertStatement(string line) + { + var valuesKeywordIndex = line.IndexOf("VALUES", StringComparison.OrdinalIgnoreCase); + if (valuesKeywordIndex < 0) + { + return line; + } + + var valuesOpenParenIndex = line.IndexOf('(', valuesKeywordIndex); + if (valuesOpenParenIndex < 0) + { + return line; + } + + var builder = new StringBuilder(line.Length); + var inSingleQuotedString = false; + var inBracketedIdentifier = false; + var parenDepth = 0; + + for (var i = 0; i < line.Length; i++) + { + var ch = line[i]; + if (inSingleQuotedString) + { + builder.Append(ch); + if (ch == '\'') + { + if (i + 1 < line.Length && line[i + 1] == '\'') + { + builder.Append(line[i + 1]); + i++; + } + else + { + inSingleQuotedString = false; + } + } + + continue; + } + + if (inBracketedIdentifier) + { + builder.Append(ch); + if (ch == ']') + { + inBracketedIdentifier = false; + } + + continue; + } + + if (ch == '[') + { + inBracketedIdentifier = true; + builder.Append(ch); + continue; + } + + if (ch == '(') + { + parenDepth++; + builder.Append(ch); + continue; + } + + if (ch == ')') + { + parenDepth--; + builder.Append(ch); + continue; + } + + if (ch == '\'') + { + inSingleQuotedString = true; + builder.Append(ch); + continue; + } + + if ((ch == 'N' || ch == 'n') && + i > valuesOpenParenIndex && + parenDepth == 1 && + i + 1 < line.Length && + line[i + 1] == '\'' && + IsTopLevelInsertStringPrefixBoundary(line, valuesOpenParenIndex, i)) + { + continue; + } + + builder.Append(ch); + } + + return builder.ToString(); + } + + private static bool IsTopLevelInsertStringPrefixBoundary(string line, int valuesOpenParenIndex, int prefixIndex) + { + for (var i = prefixIndex - 1; i > valuesOpenParenIndex; i--) + { + var ch = line[i]; + if (char.IsWhiteSpace(ch)) + { + continue; + } + + return ch is '(' or ','; + } + + return true; } internal static FileContentStyle DetectExistingStyle(string path) @@ -1905,7 +3298,8 @@ private sealed record InternalObject( string ObjectType, string Script, string RelativePath, - string FullPath) + string FullPath, + string? CompatibleOmittedTextImageOnDataSpaceName = null) { public string DisplayName => FormatDisplayName(Schema, Name); diff --git a/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs b/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs index ef3fc67..f2038af 100644 --- a/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs +++ b/tests/SqlChangeTracker.Tests/Sql/SqlServerIntrospectorTests.cs @@ -78,6 +78,56 @@ public void ListObjects_KeepsSchemaForSchemaScopedAdditionalTypes_WhenConfigured } } + [Fact] + public void ListObjects_IncludesClrTableValuedFunctions_WhenPresent() + { + var options = GetOptions(); + if (options == null) + { + return; + } + + var expected = FindFirstClrTableValuedFunction(options); + if (expected == null) + { + return; + } + + var introspector = new SqlServerIntrospector(); + var results = introspector.ListObjects(options); + + Assert.Contains( + results, + item => string.Equals(item.ObjectType, "Function", StringComparison.OrdinalIgnoreCase) && + string.Equals(item.Schema, expected.Value.Schema, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.Name, expected.Value.Name, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ListObjects_IncludesClrStoredProcedures_WhenPresent() + { + var options = GetOptions(); + if (options == null) + { + return; + } + + var expected = FindFirstClrStoredProcedure(options); + if (expected == null) + { + return; + } + + var introspector = new SqlServerIntrospector(); + var results = introspector.ListObjects(options); + + Assert.Contains( + results, + item => string.Equals(item.ObjectType, "StoredProcedure", 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"); @@ -95,4 +145,52 @@ public void ListObjects_KeepsSchemaForSchemaScopedAdditionalTypes_WhenConfigured null, true); } + + private static (string Schema, string Name)? FindFirstClrTableValuedFunction(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 = 'FT' +ORDER BY s.name, o.name; +"""; + + using var reader = command.ExecuteReader(); + if (!reader.Read()) + { + return null; + } + + return (reader.GetString(0), reader.GetString(1)); + } + + private static (string Schema, string Name)? FindFirstClrStoredProcedure(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 = 'PC' +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/SqlServerScripterCompatibilityTests.cs b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs index 167dbb2..c176b0e 100644 --- a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs +++ b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterCompatibilityTests.cs @@ -40,6 +40,213 @@ public void GetModuleFormat_ReadsReferenceSpacing_WhenSetOptionsAreOff() Assert.True(format.HasGoAfterDefinition); } + [Fact] + public void ApplyDefinitionFormatting_PreservesReferenceCommentBeforeCreate() + { + var definition = string.Join(Environment.NewLine, new[] + { + "\t/* =============================================", + string.Empty, + "\tAuthor: example", + "CREATE PROCEDURE [dbo].[Sample]", + "\t@ExecutionID int", + "AS", + "SELECT 1" + }); + + var referenceLines = new[] + { + "SET QUOTED_IDENTIFIER ON", + "GO", + "SET ANSI_NULLS ON", + "GO", + "/* =============================================", + string.Empty, + "\tAuthor: example", + "CREATE PROCEDURE [dbo].[Sample]", + "\t@ExecutionID int", + "AS", + "SELECT 1", + "GO" + }; + + var formatted = SqlServerScripter.ApplyDefinitionFormatting(definition, referenceLines); + var firstLine = formatted.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)[0]; + + Assert.Equal("/* =============================================", firstLine); + } + + [Fact] + public void ApplyDefinitionFormatting_PreservesReferenceCreateLineIdentifierQuoting() + { + var definition = string.Join(Environment.NewLine, new[] + { + "CREATE PROCEDURE Reporting.Sample_Proc", + "\t@ModelConfigID int", + "AS", + "BEGIN", + "\tSELECT @ModelConfigID", + "END" + }); + + var referenceLines = new[] + { + "SET QUOTED_IDENTIFIER ON", + "GO", + "SET ANSI_NULLS ON", + "GO", + string.Empty, + "CREATE PROCEDURE [Reporting].[Sample_Proc]", + "\t@ModelConfigID int", + "AS", + "BEGIN", + "\tSELECT @ModelConfigID", + "END", + "GO" + }; + + var formatted = SqlServerScripter.ApplyDefinitionFormatting(definition, referenceLines); + var createLine = formatted + .Split(new[] { "\r\n", "\n" }, StringSplitOptions.None) + .First(line => line.Length > 0); + + Assert.Equal("CREATE PROCEDURE [Reporting].[Sample_Proc]", createLine); + } + + [Fact] + public void ApplyDefinitionFormatting_PreservesCompatibleClrFunctionReferenceDefinition() + { + var definition = string.Join(Environment.NewLine, new[] + { + "CREATE FUNCTION [dbo].[JoinParts] (@separator [nvarchar] (MAX), @first [nvarchar] (MAX), @second [nvarchar] (MAX))", + "RETURNS [nvarchar] (MAX)", + "WITH EXECUTE AS CALLER", + "EXTERNAL NAME [AppClr].[App.Database.StringFunctions].[JoinParts]" + }); + + var referenceLines = new[] + { + "SET QUOTED_IDENTIFIER OFF", + "GO", + "SET ANSI_NULLS OFF", + "GO", + "CREATE FUNCTION [dbo].[JoinParts] (@separator [nvarchar] (max), @first [nvarchar] (max), @second [nvarchar] (max))", + "RETURNS [nvarchar] (max)", + "WITH EXECUTE AS CALLER", + "EXTERNAL NAME [AppClr].[App.Database.StringFunctions].[JoinParts]", + "GO" + }; + + var formatted = SqlServerScripter.ApplyDefinitionFormatting(definition, referenceLines); + + Assert.Equal( + string.Join(Environment.NewLine, referenceLines.Skip(4).Take(4)), + formatted); + } + + [Fact] + public void ApplyDefinitionFormatting_PreservesCompatibleClrStoredProcedureReferenceDefinition() + { + var definition = string.Join(Environment.NewLine, new[] + { + "CREATE PROCEDURE [dbo].[BuildBuckets] (@executionId [int], @portfolioId [int])", + "WITH EXECUTE AS CALLER", + "AS EXTERNAL NAME [AppClr].[App.Database.StoredProcedures].[BuildBuckets]" + }); + + var referenceLines = new[] + { + "SET QUOTED_IDENTIFIER OFF", + "GO", + "SET ANSI_NULLS OFF", + "GO", + "CREATE PROCEDURE [dbo].[BuildBuckets] (@executionId [int], @portfolioId [int])", + "WITH EXECUTE AS CALLER", + "AS EXTERNAL NAME [AppClr].[App.Database.StoredProcedures].[BuildBuckets]", + "GO" + }; + + var formatted = SqlServerScripter.ApplyDefinitionFormatting(definition, referenceLines); + + Assert.Equal( + string.Join(Environment.NewLine, referenceLines.Skip(4).Take(3)), + formatted); + } + + [Fact] + public void ApplyDefinitionFormatting_PreservesCompatibleClrTableValuedFunctionReferenceDefinition() + { + var definition = string.Join(Environment.NewLine, new[] + { + "CREATE FUNCTION [dbo].[SplitValues] (@input [nvarchar] (MAX))", + "RETURNS TABLE (", + "[Ordinal] [int],", + "[Value] [nvarchar] (MAX)", + ")", + "WITH EXECUTE AS CALLER", + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[SplitValues]" + }); + + var referenceLines = new[] + { + "SET QUOTED_IDENTIFIER OFF", + "GO", + "SET ANSI_NULLS OFF", + "GO", + "CREATE FUNCTION [dbo].[SplitValues] (@input [nvarchar] (max))", + "RETURNS TABLE (", + "[Ordinal] [int],", + "[Value] [nvarchar] (max)", + ")", + "WITH EXECUTE AS CALLER", + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[SplitValues]", + "GO" + }; + + var formatted = SqlServerScripter.ApplyDefinitionFormatting(definition, referenceLines); + + Assert.Equal( + string.Join(Environment.NewLine, referenceLines.Skip(4).Take(7)), + formatted); + } + + [Fact] + public void ApplyDefinitionFormatting_PreservesClrTableValuedFunctionReferenceNullTokensWhenOtherwiseCompatible() + { + var definition = string.Join(Environment.NewLine, new[] + { + "CREATE FUNCTION [dbo].[SplitValues] (@input [nvarchar] (MAX))", + "RETURNS TABLE (", + "[Ordinal] [int],", + "[Value] [nvarchar] (MAX)", + ")", + "WITH EXECUTE AS CALLER", + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[SplitValues]" + }); + + var referenceLines = new[] + { + "SET QUOTED_IDENTIFIER OFF", + "GO", + "SET ANSI_NULLS OFF", + "GO", + "CREATE FUNCTION [dbo].[SplitValues] (@input [nvarchar] (MAX))", + "RETURNS TABLE (", + "[Ordinal] [int] NULL,", + "[Value] [nvarchar] (MAX) NULL", + ")", + "WITH EXECUTE AS CALLER", + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[SplitValues]", + "GO" + }; + + var formatted = SqlServerScripter.ApplyDefinitionFormatting(definition, referenceLines); + + Assert.Equal( + string.Join(Environment.NewLine, referenceLines.Skip(4).Take(7)), + formatted); + } + [Fact] public void BuildReferenceTableColumnTypeMap_ReadsCompatibleTypeTokens() { @@ -193,6 +400,31 @@ public void TryGetCompatibleReferenceCreateTableBlock_PreservesEquivalentCompute Assert.Equal(referenceLines, compatibleBlock); } + [Fact] + public void TryGetCompatibleReferenceCreateTableBlock_PreservesEquivalentComputedColumnArithmeticGrouping() + { + var referenceLines = new[] + { + "CREATE TABLE [Example].[SampleTable]", + "(", + "[AmountDelta] AS (([BaseAmount]-[OffsetAmount])-(([AdjustedBase]-[AdjustedOffset])/[ScaleFactor]))", + ") ON [PRIMARY]" + }; + + var generatedCreateBlock = new List + { + "CREATE TABLE [Example].[SampleTable]", + "(", + "[AmountDelta] AS (([BaseAmount]-[OffsetAmount])-([AdjustedBase]-[AdjustedOffset])/[ScaleFactor])", + ") ON [PRIMARY]" + }; + + var compatibleBlock = SqlServerScripter.TryGetCompatibleReferenceCreateTableBlock(referenceLines, generatedCreateBlock); + + Assert.NotNull(compatibleBlock); + Assert.Equal(referenceLines, compatibleBlock); + } + [Fact] public void ReorderTableKeyAndIndexStatements_UsesCompatibleReferenceOrder() { @@ -236,6 +468,7 @@ public void ReorderTableKeyAndIndexStatements_UsesCompatibleReferenceOrder() referenceLines, keyConstraintLines, nonConstraintIndexLines, + Array.Empty(), Array.Empty()); Assert.Equal(new[] @@ -253,6 +486,55 @@ public void ReorderTableKeyAndIndexStatements_UsesCompatibleReferenceOrder() }, reordered); } + [Fact] + public void ReorderTableKeyAndIndexStatements_PreservesCompatibleStatisticOrder() + { + var referenceLines = new[] + { + "CREATE NONCLUSTERED INDEX [IX_SampleTable_KeyBeta] ON [Example].[SampleTable] ([KeyBeta]) ON [PRIMARY]", + "GO", + "CREATE STATISTICS [STAT_SampleTable_KeyAlpha_KeyBeta] ON [Example].[SampleTable] ([KeyAlpha], [KeyBeta]) WITH NORECOMPUTE", + "GO", + "CREATE XML INDEX [XML_SampleTable_DetailXml] ON [Example].[SampleTable] ([DetailXml]) USING XML INDEX [PXML_SampleTable_DetailXml] FOR PATH", + "GO" + }; + + var nonConstraintIndexLines = new List + { + "CREATE NONCLUSTERED INDEX [IX_SampleTable_KeyBeta] ON [Example].[SampleTable] ([KeyBeta]) ON [PRIMARY]", + "GO" + }; + + var userCreatedStatisticLines = new List + { + "CREATE STATISTICS [STAT_SampleTable_KeyAlpha_KeyBeta] ON [Example].[SampleTable] ([KeyAlpha], [KeyBeta]) WITH NORECOMPUTE", + "GO" + }; + + var xmlIndexLines = new List + { + "CREATE XML INDEX [XML_SampleTable_DetailXml] ON [Example].[SampleTable] ([DetailXml]) USING XML INDEX [PXML_SampleTable_DetailXml] FOR PATH", + "GO" + }; + + var reordered = SqlServerScripter.ReorderTableKeyAndIndexStatements( + referenceLines, + Array.Empty(), + nonConstraintIndexLines, + userCreatedStatisticLines, + xmlIndexLines); + + Assert.Equal(new[] + { + "CREATE NONCLUSTERED INDEX [IX_SampleTable_KeyBeta] ON [Example].[SampleTable] ([KeyBeta]) ON [PRIMARY]", + "GO", + "CREATE STATISTICS [STAT_SampleTable_KeyAlpha_KeyBeta] ON [Example].[SampleTable] ([KeyAlpha], [KeyBeta]) WITH NORECOMPUTE", + "GO", + "CREATE XML INDEX [XML_SampleTable_DetailXml] ON [Example].[SampleTable] ([DetailXml]) USING XML INDEX [PXML_SampleTable_DetailXml] FOR PATH", + "GO" + }, reordered); + } + [Fact] public void BuildTableStorageLine_EmitsPartitionColumnAndTextImageOn_WhenPresent() { @@ -276,4 +558,39 @@ public void BuildIndexOnClause_EmitsPartitionColumn_WhenPresent() Assert.Equal(" ON [ExamplePartitionScheme] ([PartitionKeyId])", clause); } + + [Fact] + public void BuildStatisticsSamplingClause_UsesPersistedSamplePercent_WhenAvailable() + { + var clause = SqlServerScripter.BuildStatisticsSamplingClause( + rowCount: 200, + rowsSampled: 80, + persistedSamplePercent: 25d); + + Assert.Equal("SAMPLE 25 PERCENT", clause); + } + + [Fact] + public void BuildStatisticsSamplingClause_EmitsFullscan_WhenAllRowsWereSampled() + { + var clause = SqlServerScripter.BuildStatisticsSamplingClause( + rowCount: 200, + rowsSampled: 200, + persistedSamplePercent: null); + + Assert.Equal("FULLSCAN", clause); + } + + [Fact] + public void BuildStatisticsWithClause_EmitsStatisticsOptionsInDeterministicOrder() + { + var clause = SqlServerScripter.BuildStatisticsWithClause( + samplingClause: "SAMPLE 25 PERCENT", + persistSamplePercent: true, + noRecompute: true, + incremental: true, + autoDrop: false); + + Assert.Equal(" WITH SAMPLE 25 PERCENT, PERSIST_SAMPLE_PERCENT = ON, NORECOMPUTE, INCREMENTAL=ON, AUTO_DROP = OFF", clause); + } } diff --git a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs index 2ba0804..a198fd9 100644 --- a/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs +++ b/tests/SqlChangeTracker.Tests/Sql/SqlServerScripterTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Data.SqlClient; using SqlChangeTracker.Sql; using Xunit; @@ -218,6 +219,32 @@ public void ScriptTable_EmitsStatisticsIncremental_WhenIncrementalIndexExists() Assert.Contains("STATISTICS_INCREMENTAL=ON", line); } + [Fact] + public void ScriptTable_EmitsUserCreatedStatistics_WhenPresent() + { + var server = Environment.GetEnvironmentVariable("SQLCT_TEST_SERVER"); + if (string.IsNullOrWhiteSpace(server)) + { + return; + } + + var databaseName = $"SqlctUserStats_{Guid.NewGuid():N}"; + try + { + var expectedStatisticsLine = CreateUserStatisticsFixtureDatabase(server, databaseName); + var options = new SqlConnectionOptions(server, databaseName, "integrated", null, null, true); + + var scripter = new SqlServerScripter(); + var script = scripter.ScriptObject(options, new DbObjectInfo("dbo", "SampleTable", "Table")); + + Assert.Contains(expectedStatisticsLine, script); + } + finally + { + DropDatabase(server, databaseName); + } + } + [Fact] public void ScriptView_EmitsIndexedViewIndex_ForAdventureWorksView() { @@ -519,6 +546,50 @@ public void ScriptFunction_EmitsParameterExtendedProperties_WhenCompatibilityRef } } + [Fact] + public void ScriptFunction_EmitsClrTableValuedFunctionDefinition_WhenPresent() + { + var options = GetOptions(); + if (options == null) + { + return; + } + + var objInfo = FindFirstClrTableValuedFunction(options); + if (objInfo == null) + { + return; + } + + var scripter = new SqlServerScripter(); + var script = scripter.ScriptObject(options, objInfo); + + Assert.Contains("RETURNS TABLE (", script); + Assert.Contains("EXTERNAL NAME", script); + } + + [Fact] + public void ScriptProcedure_EmitsClrStoredProcedureDefinition_WhenPresent() + { + var options = GetOptions(); + if (options == null) + { + return; + } + + var objInfo = FindFirstClrStoredProcedure(options); + if (objInfo == null) + { + return; + } + + var scripter = new SqlServerScripter(); + var script = scripter.ScriptObject(options, objInfo); + + Assert.Contains("CREATE PROCEDURE", script); + Assert.Contains("AS EXTERNAL NAME", script); + } + private static SqlConnectionOptions? GetOptions() { var server = Environment.GetEnvironmentVariable("SQLCT_TEST_SERVER"); @@ -886,6 +957,54 @@ AND o.type IN ('FN', 'TF', 'IF') return new DbObjectInfo(reader.GetString(0), reader.GetString(1), "Function"); } + private static DbObjectInfo? FindFirstClrTableValuedFunction(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 = 'FT' +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), "Function"); + } + + private static DbObjectInfo? FindFirstClrStoredProcedure(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 = 'PC' +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), "StoredProcedure"); + } + private static string CreateModuleReferenceWithObjectLevelExtendedProperty(string levelType) { var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.sql"); @@ -907,4 +1026,123 @@ private static string CreateModuleReferenceWithObjectLevelExtendedProperty(strin File.WriteAllLines(path, lines); return path; } + + private static string CreateUserStatisticsFixtureDatabase(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 supportsPersistedSamplePercent = HasSystemObjectColumn(fixtureConnection, "dm_db_stats_properties", "persisted_sample_percent"); + var supportsAutoDrop = HasSystemObjectColumn(fixtureConnection, "stats", "auto_drop"); + var statisticsOptions = new List(); + if (supportsPersistedSamplePercent) + { + statisticsOptions.Add("SAMPLE 25 PERCENT"); + statisticsOptions.Add("PERSIST_SAMPLE_PERCENT = ON"); + } + else + { + statisticsOptions.Add("FULLSCAN"); + } + + statisticsOptions.Add("NORECOMPUTE"); + if (supportsAutoDrop) + { + statisticsOptions.Add("AUTO_DROP = OFF"); + } + + var statisticsStatement = $"CREATE STATISTICS [SampleStats] ON [dbo].[SampleTable] ([KeyAlpha], [KeyBeta], [KeyGamma], [StatusFlag]) WHERE [StatusFlag]=(1) WITH {string.Join(", ", statisticsOptions)};"; + var expectedStatisticsLine = $"CREATE STATISTICS [SampleStats] ON [dbo].[SampleTable] ([KeyAlpha], [KeyBeta], [KeyGamma], [StatusFlag]) WHERE [StatusFlag]=(1) WITH {string.Join(", ", statisticsOptions)}"; + + var setupStatements = new[] + { + """ +CREATE TABLE [dbo].[SampleTable] ( + [KeyAlpha] [int] NOT NULL, + [KeyBeta] [int] NOT NULL, + [KeyGamma] [int] NOT NULL, + [StatusFlag] [bit] NOT NULL, + [DetailText] [nvarchar](50) NULL +); +""", + """ +INSERT INTO [dbo].[SampleTable] ([KeyAlpha], [KeyBeta], [KeyGamma], [StatusFlag], [DetailText]) VALUES +(1, 10, 100, 1, N'A'), +(2, 20, 200, 0, N'B'), +(3, 30, 300, 1, N'C'), +(4, 40, 400, 0, N'D'), +(5, 50, 500, 1, N'E'), +(6, 60, 600, 0, N'F'), +(7, 70, 700, 1, N'G'), +(8, 80, 800, 0, N'H'); +""", + statisticsStatement + }; + + foreach (var statement in setupStatements) + { + using var command = fixtureConnection.CreateCommand(); + command.CommandText = statement; + command.ExecuteNonQuery(); + } + + return expectedStatisticsLine; + } + + private static void DropDatabase(string? server, string databaseName) + { + if (string.IsNullOrWhiteSpace(server)) + { + return; + } + + try + { + using var connection = SqlConnectionFactory.Create(new SqlConnectionOptions(server, "master", "integrated", null, null, true)); + connection.Open(); + + using var command = connection.CreateCommand(); + command.CommandText = $""" +IF DB_ID(N'{databaseName}') IS NOT NULL +BEGIN + ALTER DATABASE [{databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [{databaseName}]; +END; +"""; + command.ExecuteNonQuery(); + } + catch (SqlException) + { + } + } + + private static bool HasSystemObjectColumn(SqlConnection connection, string objectName, string columnName) + { + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM sys.all_columns c + JOIN sys.all_objects o ON o.object_id = c.object_id + JOIN sys.schemas s ON s.schema_id = o.schema_id + WHERE s.name = N'sys' + AND o.name = @objectName + AND c.name = @columnName) +THEN CAST(1 AS bit) +ELSE CAST(0 AS bit) +END;"; + command.Parameters.AddWithValue("@objectName", objectName); + command.Parameters.AddWithValue("@columnName", columnName); + + return command.ExecuteScalar() is bool exists && exists; + } } diff --git a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceSqlTests.cs b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceSqlTests.cs index d8d8e01..447a465 100644 --- a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceSqlTests.cs +++ b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceSqlTests.cs @@ -60,6 +60,11 @@ public void PullAndStatus_SupportAdditionalObjectTypes_ForFixtureDatabase_WhenCo Assert.Contains( $"EXEC sp_addrolemember N'{fixture.FixedRoleName}', N'AppUser'", File.ReadAllText(Path.Combine(projectDir, "Security", "Roles", $"{fixture.FixedRoleName}.sql"))); + var schemaScript = File.ReadAllText(Path.Combine(projectDir, "Security", "Schemas", "Fixtures.sql")); + Assert.Contains("GRANT SELECT ON SCHEMA::[Fixtures] TO [AppUser]", schemaScript); + Assert.Contains( + "EXEC sp_addextendedproperty N'Caption', N'Fixture schema', 'SCHEMA', N'Fixtures', NULL, NULL, NULL, NULL", + schemaScript); Assert.Contains( "CREATE SYNONYM [Fixtures].[TargetSynonym] FOR [Fixtures].[TargetTable]", File.ReadAllText(Path.Combine(projectDir, "Synonyms", "Fixtures.TargetSynonym.sql"))); @@ -188,6 +193,10 @@ public void PullAndStatus_SupportAdditionalObjectTypes_ForFixtureDatabase_WhenCo Assert.True(schemaScopedDiff.Success, schemaScopedDiff.Error?.Detail ?? schemaScopedDiff.Error?.Message); Assert.Equal(string.Empty, schemaScopedDiff.Payload!.Diff); + var schemaDiff = service.RunDiff(projectDir, "db", "Schema:Fixtures"); + Assert.True(schemaDiff.Success, schemaDiff.Error?.Detail ?? schemaDiff.Error?.Message); + Assert.Equal(string.Empty, schemaDiff.Payload!.Diff); + var tableTypeDiff = service.RunDiff(projectDir, "db", "UserDefinedType:Fixtures.RequestList"); Assert.True(tableTypeDiff.Success, tableTypeDiff.Error?.Detail ?? tableTypeDiff.Error?.Message); Assert.Equal(string.Empty, tableTypeDiff.Payload!.Diff); @@ -249,6 +258,59 @@ public void PullAndStatus_SupportAdditionalObjectTypes_ForFixtureDatabase_WhenCo } } + [Fact] + public void PullAndStatus_ScriptBuiltInDboSchemaSubordinateState_WhenPresent() + { + var server = Environment.GetEnvironmentVariable("SQLCT_TEST_SERVER"); + if (string.IsNullOrWhiteSpace(server)) + { + return; + } + + var databaseName = $"SqlctDboSchema_{Guid.NewGuid():N}"; + var projectDir = Path.Combine(Path.GetTempPath(), "sqlct-tests", Guid.NewGuid().ToString("N")); + + try + { + CreateDboSchemaFixtureDatabase(server, databaseName); + CreateProject(server, databaseName, projectDir); + + var service = new SyncCommandService(); + var pull = service.RunPull(projectDir); + + Assert.True(pull.Success, pull.Error?.Detail ?? pull.Error?.Message); + + var schemaPath = Path.Combine(projectDir, "Security", "Schemas", "dbo.sql"); + Assert.True(File.Exists(schemaPath)); + + var schemaScript = File.ReadAllText(schemaPath); + Assert.DoesNotContain("CREATE SCHEMA [dbo]", schemaScript); + Assert.DoesNotContain("AUTHORIZATION [dbo]", schemaScript); + Assert.Contains("GRANT SELECT ON SCHEMA::[dbo] TO [SchemaGrantRole]", schemaScript); + Assert.Contains( + "EXEC sp_addextendedproperty N'Caption', N'Built-in dbo schema', 'SCHEMA', N'dbo', NULL, NULL, NULL, NULL", + schemaScript); + + var diff = service.RunDiff(projectDir, "db", "Schema:dbo"); + Assert.True(diff.Success, diff.Error?.Detail ?? diff.Error?.Message); + Assert.Equal(string.Empty, diff.Payload!.Diff); + + var status = service.RunStatus(projectDir, "db"); + Assert.True(status.Success, status.Error?.Detail ?? status.Error?.Message); + Assert.Equal(0, status.Payload!.Summary.Schema.Added); + Assert.Equal(0, status.Payload.Summary.Schema.Changed); + Assert.Equal(0, status.Payload.Summary.Schema.Deleted); + Assert.Equal(0, status.Payload.Summary.Data.Added); + Assert.Equal(0, status.Payload.Summary.Data.Changed); + Assert.Equal(0, status.Payload.Summary.Data.Deleted); + } + finally + { + TryDeleteProject(projectDir); + DropDatabase(server, databaseName); + } + } + private static void CreateProject(string server, string databaseName, string projectDir) { var seed = new BaselineProjectSeeder().Seed(projectDir); @@ -263,6 +325,33 @@ private static void CreateProject(string server, string databaseName, string pro Assert.True(write.Success, write.Error?.Detail ?? write.Error?.Message); } + private static void CreateDboSchemaFixtureDatabase(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(); + + foreach (var statement in new[] + { + "CREATE ROLE [SchemaGrantRole] AUTHORIZATION [dbo];", + "GRANT SELECT ON SCHEMA::[dbo] TO [SchemaGrantRole];", + "EXEC sp_addextendedproperty N'Caption', N'Built-in dbo schema', 'SCHEMA', N'dbo';" + }) + { + using var command = fixtureConnection.CreateCommand(); + command.CommandText = statement; + command.ExecuteNonQuery(); + } + } + private static FixtureDatabaseInfo CreateFixtureDatabase(string server, string databaseName) { using var connection = SqlConnectionFactory.Create(new SqlConnectionOptions(server, "master", "integrated", null, null, true)); @@ -329,6 +418,8 @@ AND [name] <> N'public' foreach (var statement in new[] { + "GRANT SELECT ON SCHEMA::[Fixtures] TO [AppUser];", + "EXEC sp_addextendedproperty N'Caption', N'Fixture schema', 'SCHEMA', N'Fixtures';", "GRANT REFERENCES ON XML SCHEMA COLLECTION::[Fixtures].[PayloadSchema] TO [AppUser];", "EXEC sp_addextendedproperty N'Caption', N'Fixture XML schema collection', 'SCHEMA', N'Fixtures', 'XML SCHEMA COLLECTION', N'PayloadSchema';", "GRANT REFERENCES ON MESSAGE TYPE::[//Sqlct/Request] TO [AppUser];", diff --git a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs index ae8a865..cdb595f 100644 --- a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs +++ b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs @@ -38,9 +38,58 @@ public void TryParseObjectFileName_SupportsSchemaLessNames(string fileName, bool Assert.Equal(expectedName, name); } + [Theory] + [InlineData( + "MessageType", + "__App_Messaging_Request", + "CREATE MESSAGE TYPE [//App/Messaging/Request]\r\nVALIDATION = NONE\r\nGO", + "__App_Messaging_Request", + true, + "//App/Messaging/Request")] + [InlineData( + "Contract", + "__App_Messaging_Contract", + "CREATE CONTRACT [//App/Messaging/Contract]\r\n(\r\n)\r\nGO", + "__App_Messaging_Contract", + true, + "//App/Messaging/Contract")] + [InlineData( + "MessageType", + "%2F%2FApp%2FMessaging%2FRequest", + "CREATE MESSAGE TYPE [//App/Messaging/Request]\r\nVALIDATION = NONE\r\nGO", + "//App/Messaging/Request", + false, + "")] + [InlineData( + "Role", + "AppReader", + "CREATE ROLE [OtherRole]\r\nGO", + "AppReader", + false, + "")] + public void TryResolveSchemaLessFolderIdentityFromScript_UsesDefinitionOnlyForEscapedLegacyNames( + string objectType, + string fileName, + string script, + string parsedFileName, + bool expected, + string expectedName) + { + var success = SyncCommandService.TryResolveSchemaLessFolderIdentityFromScript( + objectType, + fileName, + script, + parsedFileName, + out var name); + + Assert.Equal(expected, success); + Assert.Equal(expectedName, name); + } + [Theory] [InlineData("dbo.Customer", null, "dbo", "Customer", false)] [InlineData("ServiceUser", null, "", "ServiceUser", true)] + [InlineData("App.Core.Assembly", null, "", "App.Core.Assembly", true)] [InlineData("Role:AppReader", "Role", "", "AppReader", true)] [InlineData("Assembly:AppClr", "Assembly", "", "AppClr", true)] [InlineData("SearchPropertyList:DocumentProperties", "SearchPropertyList", "", "DocumentProperties", true)] @@ -64,6 +113,56 @@ public void ParseObjectSelector_AcceptsSchemaScopedSchemaLessAndTypedSelectors( Assert.Equal(expectedSchemaLess, result.Payload.IsSchemaLess); } + [Fact] + public void RunStatus_MatchesLegacySchemaLessFileNameToScriptObjectName() + { + 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); + + var script = "CREATE MESSAGE TYPE [//App/Messaging/Request]\r\nVALIDATION = NONE\r\nGO\r\n"; + CreateFile(projectDir, Path.Combine("Service Broker", "Message Types", "__App_Messaging_Request.sql"), script); + + var introspector = new TrackingIntrospector + { + AllObjects = [new DbObjectInfo(string.Empty, "//App/Messaging/Request", "MessageType")] + }; + var scripter = new TrackingScripter + { + ScriptObjectHandler = (_, _, _) => script + }; + + var service = new SyncCommandService( + new SqlctConfigReader(), + introspector, + scripter, + new SchemaFolderMapper(SupportedSqlObjectTypes.DefaultFolderMap, dataWriteAllFilesInOneDirectory: true)); + + var result = service.RunStatus(projectDir, "db"); + + Assert.True(result.Success, result.Error?.Detail ?? result.Error?.Message); + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.Empty(result.Payload!.Objects); + Assert.Equal(0, result.Payload.Summary.Schema.Added); + Assert.Equal(0, result.Payload.Summary.Schema.Deleted); + Assert.Equal(0, result.Payload.Summary.Schema.Changed); + } + finally + { + CleanupTempDir(tempDir); + } + } + [Theory] [InlineData("")] [InlineData("dbo.")] @@ -756,6 +855,15 @@ public void NormalizeForComparison_IgnoresLineEndingsAndTrailingNewlines() Assert.Equal(left, right); } + [Fact] + public void NormalizeForComparison_TreatsWhitespaceOnlyLinesAsBlankLines() + { + var blank = SyncCommandService.NormalizeForComparison("SET ANSI_NULLS ON\nGO\n\n/* comment */"); + var whitespaceOnly = SyncCommandService.NormalizeForComparison("SET ANSI_NULLS ON\nGO\n \t\n/* comment */"); + + Assert.Equal(blank, whitespaceOnly); + } + [Fact] public void NormalizeForComparison_NormalizesTrailingSemicolonsOnInsertStatements() { @@ -782,6 +890,726 @@ public void BuildUnifiedDiff_SuppressesTrailingSemicolonOnlyDifferencesInInsertS Assert.Empty(diff); } + [Fact] + public void BuildUnifiedDiff_SuppressesWhitespaceOnlyBlankLineDifferences() + { + var source = "SET ANSI_NULLS ON\nGO\n\n/* comment */\nCREATE PROCEDURE [dbo].[Sample]\nAS\nSELECT 1\nGO"; + var target = "SET ANSI_NULLS ON\nGO\n \t \n/* comment */\nCREATE PROCEDURE [dbo].[Sample]\nAS\nSELECT 1\nGO"; + + var diff = SyncCommandService.BuildUnifiedDiff("StoredProcedure", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Queue_SuppressesDefaultPrimaryAndDisabledActivationDifferences() + { + var source = + "CREATE QUEUE [dbo].[AppInboxQueue]\n" + + "WITH STATUS = ON, RETENTION = OFF, POISON_MESSAGE_HANDLING (STATUS = ON), ACTIVATION (STATUS = OFF, EXECUTE AS 'dbo')\n" + + "GO"; + var target = + "CREATE QUEUE [dbo].[AppInboxQueue]\n" + + "WITH STATUS=ON,\n" + + "RETENTION=OFF,\n" + + "POISON_MESSAGE_HANDLING (STATUS=ON)\n" + + "ON [PRIMARY]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Queue", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Queue_SuppressesEquivalentMultilineActivationFormatting() + { + var source = + "CREATE QUEUE [dbo].[AppWorkQueue]\n" + + "WITH STATUS = ON, RETENTION = OFF, POISON_MESSAGE_HANDLING (STATUS = ON), ACTIVATION (STATUS = ON, PROCEDURE_NAME = [dbo].[ProcessAppMessages], MAX_QUEUE_READERS = 1, EXECUTE AS OWNER)\n" + + "GO"; + var target = + "CREATE QUEUE [dbo].[AppWorkQueue]\n" + + "WITH STATUS=ON,\n" + + "RETENTION=OFF,\n" + + "POISON_MESSAGE_HANDLING (STATUS=ON),\n" + + "ACTIVATION (\n" + + "STATUS=ON,\n" + + "PROCEDURE_NAME=[dbo].[ProcessAppMessages],\n" + + "MAX_QUEUE_READERS=1,\n" + + "EXECUTE AS OWNER\n" + + ")\n" + + "ON [PRIMARY]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Queue", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Role_SuppressesLegacyAndAlterRoleMembershipSyntaxDifferences_ForFixedRole() + { + var source = + "EXEC sp_addrolemember N'db_datareader', N'ReadOnlyUser'\n" + + "GO\n" + + "EXEC sp_addrolemember N'db_datareader', N'ReportUser'\n" + + "GO"; + var target = + "ALTER ROLE [db_datareader] ADD MEMBER [ReadOnlyUser]\n" + + "GO\n" + + "ALTER ROLE [db_datareader] ADD MEMBER [ReportUser]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Role", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Role_SuppressesLegacyAndAlterRoleMembershipSyntaxDifferences_ForUserDefinedRole() + { + var source = + "CREATE ROLE [ReportingRole]\n" + + "AUTHORIZATION [dbo]\n" + + "GO\n" + + "EXEC sp_addrolemember N'ReportingRole', N'ReportUser'\n" + + "GO"; + var target = + "CREATE ROLE [ReportingRole]\n" + + "AUTHORIZATION [dbo]\n" + + "GO\n" + + "ALTER ROLE [ReportingRole] ADD MEMBER [ReportUser]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Role", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Role_PreservesMembershipTargetDifferences() + { + var source = + "EXEC sp_addrolemember N'db_datareader', N'ReadOnlyUser'\n" + + "GO"; + var target = + "ALTER ROLE [db_datareader] ADD MEMBER [ReportUser]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Role", "db", "folder", source, target); + + Assert.Contains("ALTER ROLE [db_datareader] ADD MEMBER [ReadOnlyUser]", diff); + Assert.Contains("ALTER ROLE [db_datareader] ADD MEMBER [ReportUser]", diff); + } + + [Fact] + public void BuildUnifiedDiff_MessageType_SuppressesLegacyValidationXmlSynonymAndSpacing() + { + var source = + "CREATE MESSAGE TYPE [//App/Reply]\n" + + "AUTHORIZATION [dbo]\n" + + "VALIDATION = XML\n" + + "GO"; + var target = + "CREATE MESSAGE TYPE [//App/Reply]\n" + + "AUTHORIZATION [dbo]\n" + + "VALIDATION=WELL_FORMED_XML\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("MessageType", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Contract_SuppressesEquivalentFormattingAndMessageUsageOrderDifferences() + { + var source = + "CREATE CONTRACT [//App/Contract]\n" + + "AUTHORIZATION [dbo]\n" + + "(\n" + + "[//App/Reply] SENT BY TARGET,\n" + + "[//App/Request] SENT BY INITIATOR\n" + + ")\n" + + "GO"; + var target = + "CREATE CONTRACT [//App/Contract]\n" + + "AUTHORIZATION [dbo] (\n" + + "[//App/Request] SENT BY INITIATOR,\n" + + "[//App/Reply] SENT BY TARGET\n" + + ")\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Contract", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Contract_PreservesSentBySemanticDifferences() + { + var source = + "CREATE CONTRACT [//App/Contract]\n" + + "AUTHORIZATION [dbo]\n" + + "(\n" + + "[//App/Reply] SENT BY TARGET,\n" + + "[//App/Request] SENT BY INITIATOR\n" + + ")\n" + + "GO"; + var target = + "CREATE CONTRACT [//App/Contract]\n" + + "AUTHORIZATION [dbo] (\n" + + "[//App/Request] SENT BY ANY,\n" + + "[//App/Reply] SENT BY TARGET\n" + + ")\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Contract", "db", "folder", source, target); + + Assert.Contains("[//App/Request] SENT BY INITIATOR", diff); + Assert.Contains("[//App/Request] SENT BY ANY", diff); + } + + [Fact] + public void BuildUnifiedDiff_Service_SuppressesEquivalentContractListFormatting() + { + var source = + "CREATE SERVICE [AppTargetService]\n" + + "AUTHORIZATION [dbo]\n" + + "ON QUEUE [dbo].[AppTargetQueue] ([//App/Contract])\n" + + "GO"; + var target = + "CREATE SERVICE [AppTargetService]\n" + + "AUTHORIZATION [dbo]\n" + + "ON QUEUE [dbo].[AppTargetQueue]\n" + + "(\n" + + "[//App/Contract]\n" + + ")\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Service", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Service_PreservesContractMembershipDifferences() + { + var source = + "CREATE SERVICE [AppTargetService]\n" + + "AUTHORIZATION [dbo]\n" + + "ON QUEUE [dbo].[AppTargetQueue] ([//App/Contract])\n" + + "GO"; + var target = + "CREATE SERVICE [AppTargetService]\n" + + "AUTHORIZATION [dbo]\n" + + "ON QUEUE [dbo].[AppTargetQueue]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Service", "db", "folder", source, target); + + Assert.Contains("ON QUEUE [dbo].[AppTargetQueue]([//App/Contract])", diff); + } + + [Fact] + public void BuildUnifiedDiff_Function_SuppressesClrTableValuedFunctionNullOnlyDifferences() + { + var source = + "SET QUOTED_IDENTIFIER OFF\n" + + "GO\n" + + "SET ANSI_NULLS OFF\n" + + "GO\n" + + "CREATE FUNCTION [dbo].[SplitValues] (@input [nvarchar] (MAX))\n" + + "RETURNS TABLE (\n" + + "[Ordinal] [int],\n" + + "[Value] [nvarchar] (MAX)\n" + + ")\n" + + "WITH EXECUTE AS CALLER\n" + + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[SplitValues]\n" + + "GO"; + var target = + "SET QUOTED_IDENTIFIER OFF\n" + + "GO\n" + + "SET ANSI_NULLS OFF\n" + + "GO\n" + + "CREATE FUNCTION [dbo].[SplitValues] (@input [nvarchar] (MAX))\n" + + "RETURNS TABLE (\n" + + "[Ordinal] [int] NULL,\n" + + "[Value] [nvarchar] (MAX) NULL\n" + + ")\n" + + "WITH EXECUTE AS CALLER\n" + + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[SplitValues]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Function", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Function_SuppressesClrTableValuedFunctionNullAndCloseParenLineDifferences() + { + var source = + "SET QUOTED_IDENTIFIER OFF\n" + + "GO\n" + + "SET ANSI_NULLS OFF\n" + + "GO\n" + + "CREATE FUNCTION [dbo].[SplitValues] (@input [nvarchar] (MAX))\n" + + "RETURNS TABLE (\n" + + "[Ordinal] [int],\n" + + "[Value] [nvarchar] (MAX)\n" + + ")\n" + + "WITH EXECUTE AS CALLER\n" + + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[SplitValues]\n" + + "GO"; + var target = + "SET QUOTED_IDENTIFIER OFF\n" + + "GO\n" + + "SET ANSI_NULLS OFF\n" + + "GO\n" + + "CREATE FUNCTION [dbo].[SplitValues] (@input [nvarchar] (MAX))\n" + + "RETURNS TABLE (\n" + + "[Ordinal] [int] NULL,\n" + + "[Value] [nvarchar] (MAX) NULL)\n" + + "WITH EXECUTE AS CALLER\n" + + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[SplitValues]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Function", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Function_ReportsOnlyClrTableValuedFunctionExternalNameDifference_WhenLegacyNullCloseParenAlsoDiffers() + { + var source = + "SET QUOTED_IDENTIFIER OFF\n" + + "GO\n" + + "SET ANSI_NULLS OFF\n" + + "GO\n" + + "CREATE FUNCTION [dbo].[RandomVector] (@length [int])\n" + + "RETURNS TABLE (\n" + + "[RndValue] [int]\n" + + ")\n" + + "WITH EXECUTE AS CALLER\n" + + "EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[RandomVector]\n" + + "GO"; + var target = + "SET QUOTED_IDENTIFIER OFF\n" + + "GO\n" + + "SET ANSI_NULLS OFF\n" + + "GO\n" + + "CREATE FUNCTION [dbo].[RandomVector] (@length [int])\n" + + "RETURNS TABLE (\n" + + "[RndValue] [int] NULL)\n" + + "WITH EXECUTE AS CALLER\n" + + "EXTERNAL NAME [AppClrLegacy].[App.Database.TabularFunctions].[RandomVector]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Function", "db", "folder", source, target); + + Assert.Contains("EXTERNAL NAME [AppClr].[App.Database.TabularFunctions].[RandomVector]", diff); + Assert.Contains("EXTERNAL NAME [AppClrLegacy].[App.Database.TabularFunctions].[RandomVector]", diff); + Assert.DoesNotContain("[RndValue] [int] NULL)", diff); + } + + [Fact] + public void NormalizeForComparison_TableData_NormalizesLegacyIdentityInsertAndUnicodeLiteralPrefixes() + { + var canonical = SyncCommandService.NormalizeForComparison( + "SET IDENTITY_INSERT [dbo].[Customer] ON;\n" + + "INSERT INTO [dbo].[Customer] ([CustomerID], [Code], [Description]) VALUES (1, 'A', 'Alpha');\n" + + "SET IDENTITY_INSERT [dbo].[Customer] OFF;", + SyncCommandService.TableDataObjectType); + var legacy = SyncCommandService.NormalizeForComparison( + "SET IDENTITY_INSERT [dbo].[Customer] ON\n" + + "INSERT INTO [dbo].[Customer] ([CustomerID], [Code], [Description]) VALUES (1, N'A', N'Alpha')\n" + + "SET IDENTITY_INSERT [dbo].[Customer] OFF", + SyncCommandService.TableDataObjectType); + + Assert.Equal(canonical, legacy); + } + + [Fact] + public void BuildUnifiedDiff_TableData_SuppressesLegacyIdentityInsertAndUnicodeLiteralPrefixDifferences() + { + var source = + "SET IDENTITY_INSERT [dbo].[Customer] ON;\n" + + "INSERT INTO [dbo].[Customer] ([CustomerID], [Code], [Description]) VALUES (1, 'A', 'Alpha');\n" + + "SET IDENTITY_INSERT [dbo].[Customer] OFF;"; + var target = + "SET IDENTITY_INSERT [dbo].[Customer] ON\n" + + "INSERT INTO [dbo].[Customer] ([CustomerID], [Code], [Description]) VALUES (1, N'A', N'Alpha')\n" + + "SET IDENTITY_INSERT [dbo].[Customer] OFF"; + + var diff = SyncCommandService.BuildUnifiedDiff( + SyncCommandService.TableDataObjectType, + "db", + "folder", + source, + target); + + Assert.Empty(diff); + } + + [Fact] + public void NormalizeForComparison_TableData_NormalizesLegacyPrefixesInMultilineInsertValues() + { + var canonical = SyncCommandService.NormalizeForComparison( + "INSERT INTO [dbo].[Variable] ([Description], [GroupCode], [Name], [Flag]) VALUES ('Line 1\n" + + "Line 2\n" + + "0 - empty', 'ScoreApl', 'EmploymentBasis', 0);", + SyncCommandService.TableDataObjectType); + var legacy = SyncCommandService.NormalizeForComparison( + "INSERT INTO [dbo].[Variable] ([Description], [GroupCode], [Name], [Flag]) VALUES ('Line 1\n" + + "Line 2\n" + + "0 - empty', N'ScoreApl', N'EmploymentBasis', 0)", + SyncCommandService.TableDataObjectType); + + Assert.Equal(canonical, legacy); + } + + [Fact] + public void BuildUnifiedDiff_TableData_SuppressesLegacyPrefixesInMultilineInsertValues() + { + var source = + "INSERT INTO [dbo].[Variable] ([Description], [GroupCode], [Name], [Flag]) VALUES ('Line 1\n" + + "Line 2\n" + + "0 - empty', 'ScoreApl', 'EmploymentBasis', 0);"; + var target = + "INSERT INTO [dbo].[Variable] ([Description], [GroupCode], [Name], [Flag]) VALUES ('Line 1\n" + + "Line 2\n" + + "0 - empty', N'ScoreApl', N'EmploymentBasis', 0)"; + + var diff = SyncCommandService.BuildUnifiedDiff( + SyncCommandService.TableDataObjectType, + "db", + "folder", + source, + target); + + Assert.Empty(diff); + } + + [Fact] + public void NormalizeForComparison_TableData_SortsEquivalentInsertStatementsWithinRun() + { + var canonical = SyncCommandService.NormalizeForComparison( + "SET IDENTITY_INSERT [dbo].[LookupValue] ON;\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (1, 'A');\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (2, 'B');\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (3, 'C');\n" + + "SET IDENTITY_INSERT [dbo].[LookupValue] OFF;", + SyncCommandService.TableDataObjectType); + var reordered = SyncCommandService.NormalizeForComparison( + "SET IDENTITY_INSERT [dbo].[LookupValue] ON\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (3, N'C')\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (1, N'A')\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (2, N'B')\n" + + "SET IDENTITY_INSERT [dbo].[LookupValue] OFF", + SyncCommandService.TableDataObjectType); + + Assert.Equal(canonical, reordered); + } + + [Fact] + public void BuildUnifiedDiff_TableData_SuppressesEquivalentInsertOrderDifferences() + { + var source = + "SET IDENTITY_INSERT [dbo].[LookupValue] ON;\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (1, 'A');\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (2, 'B');\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (3, 'C');\n" + + "SET IDENTITY_INSERT [dbo].[LookupValue] OFF;"; + var target = + "SET IDENTITY_INSERT [dbo].[LookupValue] ON\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (3, 'C')\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (1, 'A')\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (2, 'B')\n" + + "SET IDENTITY_INSERT [dbo].[LookupValue] OFF"; + + var diff = SyncCommandService.BuildUnifiedDiff( + SyncCommandService.TableDataObjectType, + "db", + "folder", + source, + target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_TableData_PreservesValueDifferencesWhenInsertOrderAlsoDiffers() + { + var source = + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (1, 'A');\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (2, 'B');"; + var target = + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (2, 'B')\n" + + "INSERT INTO [dbo].[LookupValue] ([LookupValueID], [LookupCode]) VALUES (1, 'Z')"; + + var diff = SyncCommandService.BuildUnifiedDiff( + SyncCommandService.TableDataObjectType, + "db", + "folder", + source, + target); + + Assert.Contains("VALUES (1, 'A')", diff); + Assert.Contains("VALUES (1, 'Z')", diff); + } + + [Fact] + public void BuildUnifiedDiff_Table_SuppressesEquivalentExtendedPropertyOrderAndSpacingDifferences() + { + var source = + "CREATE TABLE [dbo].[SessionLog]\n" + + "(\n" + + "[SessionLogID] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "EXEC sp_addextendedproperty N'MS_Description', N'System details', 'SCHEMA', N'dbo', 'TABLE', N'SessionLog', 'COLUMN', N'SystemInfo'\n" + + "GO\n" + + "EXEC sp_addextendedproperty N'MS_Description', N'Client software details', 'SCHEMA', N'dbo', 'TABLE', N'SessionLog', 'COLUMN', N'UserAgentInfo'\n" + + "GO\n" + + "ALTER TABLE [dbo].[SessionLog] SET (LOCK_ESCALATION = AUTO)\n" + + "GO"; + var target = + "CREATE TABLE [dbo].[SessionLog]\n" + + "(\n" + + "[SessionLogID] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "EXEC sp_addextendedproperty N'MS_Description', N'Client software details' , 'SCHEMA', N'dbo', 'TABLE', N'SessionLog','COLUMN', N'UserAgentInfo'\n" + + "GO\n" + + "EXEC sp_addextendedproperty N'MS_Description', N'System details', 'SCHEMA', N'dbo', 'TABLE', N'SessionLog', 'COLUMN', N'SystemInfo'\n" + + "GO\n" + + "ALTER TABLE [dbo].[SessionLog] SET (LOCK_ESCALATION = AUTO)\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Table_PreservesExtendedPropertyValueDifferencesWhenOrderAlsoDiffers() + { + var source = + "CREATE TABLE [dbo].[SessionLog]\n" + + "(\n" + + "[SessionLogID] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "EXEC sp_addextendedproperty N'MS_Description', N'System details', 'SCHEMA', N'dbo', 'TABLE', N'SessionLog', 'COLUMN', N'SystemInfo'\n" + + "GO\n" + + "EXEC sp_addextendedproperty N'MS_Description', N'Client software details', 'SCHEMA', N'dbo', 'TABLE', N'SessionLog', 'COLUMN', N'UserAgentInfo'\n" + + "GO"; + var target = + "CREATE TABLE [dbo].[SessionLog]\n" + + "(\n" + + "[SessionLogID] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "EXEC sp_addextendedproperty N'MS_Description', N'Client software details', 'SCHEMA', N'dbo', 'TABLE', N'SessionLog', 'COLUMN', N'UserAgentInfo'\n" + + "GO\n" + + "EXEC sp_addextendedproperty N'MS_Description', N'Updated system details', 'SCHEMA', N'dbo', 'TABLE', N'SessionLog', 'COLUMN', N'SystemInfo'\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Contains("N'System details'", diff); + Assert.Contains("N'Updated system details'", diff); + } + + [Fact] + public void BuildUnifiedDiff_Table_SuppressesRedundantEmptyGoBatchDifferences() + { + var source = + "CREATE TABLE [Accounting].[ExchangeRate]\n" + + "(\n" + + "[RateId] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "EXEC sp_addextendedproperty 'MS_Description', N'Row versioning column', 'SCHEMA', 'Accounting', 'TABLE', 'ExchangeRate', 'COLUMN', 'RateId'\n" + + "GO\n" + + "SET ANSI_NULLS ON\n" + + "GO\n" + + "SET ANSI_PADDING ON\n" + + "GO"; + var target = + "CREATE TABLE [Accounting].[ExchangeRate]\n" + + "(\n" + + "[RateId] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "EXEC sp_addextendedproperty 'MS_Description', N'Row versioning column', 'SCHEMA', 'Accounting', 'TABLE', 'ExchangeRate', 'COLUMN', 'RateId'\n" + + "GO\n" + + "GO\n" + + "SET ANSI_NULLS ON\n" + + "GO\n" + + "SET ANSI_PADDING ON\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Table_SuppressesRedundantSemicolonOnlyGoBatchDifferences() + { + var source = + "CREATE TABLE [Accounting].[ExchangeRate]\n" + + "(\n" + + "[RateId] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "ALTER TABLE [Accounting].[ExchangeRate] SET (LOCK_ESCALATION = AUTO)\n" + + "GO"; + var target = + "CREATE TABLE [Accounting].[ExchangeRate]\n" + + "(\n" + + "[RateId] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + ";\n" + + "GO\n" + + "ALTER TABLE [Accounting].[ExchangeRate] SET (LOCK_ESCALATION = AUTO)\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_View_SuppressesEquivalentExtendedPropertyNamedArgumentDifferences() + { + var source = + "CREATE VIEW [Reporting].[ExchangeRateView]\n" + + "AS\n" + + "SELECT 1 AS [Rate]\n" + + "GO\n" + + "EXEC sp_addextendedproperty N'MS_Description', N'Lightweight exchange-rate view', 'SCHEMA', N'Reporting', 'VIEW', N'ExchangeRateView', NULL, NULL\n" + + "GO"; + var target = + "CREATE VIEW [Reporting].[ExchangeRateView]\n" + + "AS\n" + + "SELECT 1 AS [Rate]\n" + + "GO\n" + + "EXEC sp_addextendedproperty @name=N'MS_Description', @value=N'Lightweight exchange-rate view', @level0type=N'SCHEMA', @level0name=N'Reporting', @level1type=N'VIEW', @level1name=N'ExchangeRateView'\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("View", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Table_SuppressesEquivalentPostCreatePackageOrderDifferences() + { + var source = + "CREATE TABLE [dbo].[ExternalDef]\n" + + "(\n" + + "[ExternalId] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "ALTER TABLE [dbo].[ExternalDef] ADD CONSTRAINT [PK_ExternalDef] PRIMARY KEY CLUSTERED ([ExternalId]) ON [PRIMARY]\n" + + "GO\n" + + "SET ANSI_NULLS ON\n" + + "GO\n" + + "SET QUOTED_IDENTIFIER ON\n" + + "GO\n" + + "CREATE TRIGGER [dbo].[TR_ExternalDef_Audit] ON [dbo].[ExternalDef] AFTER INSERT AS\n" + + "BEGIN\n" + + "SELECT 1\n" + + "END\n" + + "GO\n" + + "CREATE UNIQUE NONCLUSTERED INDEX [IX_ExternalDef_Key] ON [dbo].[ExternalDef] ([ExternalId]) ON [PRIMARY]\n" + + "GO"; + var target = + "CREATE TABLE [dbo].[ExternalDef]\n" + + "(\n" + + "[ExternalId] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "SET ANSI_NULLS ON\n" + + "GO\n" + + "SET QUOTED_IDENTIFIER ON\n" + + "GO\n" + + "CREATE TRIGGER [dbo].[TR_ExternalDef_Audit] ON [dbo].[ExternalDef] AFTER INSERT AS\n" + + "BEGIN\n" + + "SELECT 1\n" + + "END\n" + + "GO\n" + + "ALTER TABLE [dbo].[ExternalDef] ADD CONSTRAINT [PK_ExternalDef] PRIMARY KEY CLUSTERED ([ExternalId]) ON [PRIMARY]\n" + + "GO\n" + + "CREATE UNIQUE NONCLUSTERED INDEX [IX_ExternalDef_Key] ON [dbo].[ExternalDef] ([ExternalId]) ON [PRIMARY]\n" + + "GO"; + + var diff = SyncCommandService.BuildUnifiedDiff("Table", "db", "folder", source, target); + + Assert.Empty(diff); + } + + [Fact] + public void BuildUnifiedDiff_Table_PreservesPostCreatePackageContentDifferencesWhenOrderAlsoDiffers() + { + var source = + "CREATE TABLE [dbo].[ExternalDef]\n" + + "(\n" + + "[ExternalId] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "ALTER TABLE [dbo].[ExternalDef] ADD CONSTRAINT [PK_ExternalDef] PRIMARY KEY CLUSTERED ([ExternalId]) ON [PRIMARY]\n" + + "GO\n" + + "SET ANSI_NULLS ON\n" + + "GO\n" + + "SET QUOTED_IDENTIFIER ON\n" + + "GO\n" + + "CREATE TRIGGER [dbo].[TR_ExternalDef_Audit] ON [dbo].[ExternalDef] AFTER INSERT AS\n" + + "BEGIN\n" + + "SELECT 1\n" + + "END\n" + + "GO"; + var target = + "CREATE TABLE [dbo].[ExternalDef]\n" + + "(\n" + + "[ExternalId] [int] NOT NULL\n" + + ")\n" + + "GO\n" + + "SET ANSI_NULLS ON\n" + + "GO\n" + + "SET QUOTED_IDENTIFIER ON\n" + + "GO\n" + + "CREATE TRIGGER [dbo].[TR_ExternalDef_Audit] ON [dbo].[ExternalDef] AFTER INSERT AS\n" + + "BEGIN\n" + + "SELECT 1\n" + + "END\n" + + "GO\n" + + "ALTER TABLE [dbo].[ExternalDef] ADD CONSTRAINT [PK_ExternalDef] PRIMARY KEY CLUSTERED ([ExternalId]) WITH (DATA_COMPRESSION = PAGE) ON [PRIMARY]\n" + + "GO"; + + 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); + } + + [Fact] + public void NormalizeForComparison_DoesNotNormalizeUnicodeLiteralPrefixesOutsideTableData() + { + var plain = SyncCommandService.NormalizeForComparison( + "INSERT INTO [dbo].[T] ([Name]) VALUES ('Alpha')"); + var prefixed = SyncCommandService.NormalizeForComparison( + "INSERT INTO [dbo].[T] ([Name]) VALUES (N'Alpha')"); + + Assert.NotEqual(plain, prefixed); + } + [Fact] public void DetectExistingStyle_AndApplyStyle_PreservesEncodingAndLineBehavior() { @@ -999,6 +1827,55 @@ public void RunPull_WithObjectSelector_UsesTargetedDatabaseDiscovery() } } + [Fact] + public void RunPull_WithDottedSchemaLessObjectSelector_DeletesMatchingAssemblyFile() + { + 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); + + var assemblyPath = CreateFile( + projectDir, + Path.Combine("Assemblies", "App.Core.Legacy.sql"), + "CREATE ASSEMBLY [App.Core.Legacy]\r\nFROM 0x00\r\nWITH PERMISSION_SET = SAFE\r\nGO\r\n"); + + var introspector = new TrackingIntrospector(); + var service = new SyncCommandService( + new SqlctConfigReader(), + introspector, + new TrackingScripter(), + new SchemaFolderMapper(SupportedSqlObjectTypes.DefaultFolderMap, dataWriteAllFilesInOneDirectory: true)); + + var result = service.RunPull(projectDir, objectSelector: "App.Core.Legacy"); + + Assert.True(result.Success, result.Error?.Detail ?? result.Error?.Message); + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.False(introspector.ListObjectsCalled); + Assert.True(introspector.ListMatchingObjectsCalled); + Assert.Equal(string.Empty, introspector.LastRequestedSchema); + Assert.Equal("App.Core.Legacy", introspector.LastRequestedName); + Assert.Equal(1, result.Payload!.Summary.Schema.Deleted); + Assert.Single(result.Payload.Objects); + Assert.Equal("deleted", result.Payload.Objects[0].Change); + Assert.Equal("App.Core.Legacy", result.Payload.Objects[0].Name); + Assert.False(File.Exists(assemblyPath)); + } + finally + { + CleanupTempDir(tempDir); + } + } + [Fact] public void RunDiff_WithFilterPattern_LimitsDbScriptingToMatchingObjects() { @@ -1049,6 +1926,112 @@ public void RunDiff_WithFilterPattern_LimitsDbScriptingToMatchingObjects() } } + [Fact] + public void RunDiff_Table_SuppressesCompatibleOmittedTextImageOnDifference_WhenDbMetadataAllowsOmission() + { + 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("Tables", "dbo.DocumentStore.sql"), + "CREATE TABLE [dbo].[DocumentStore]\r\n(\r\n[DocumentId] [int] NOT NULL,\r\n[Payload] [varchar] (max) NULL\r\n) ON [PRIMARY]\r\nGO\r\n"); + + var introspector = new TrackingIntrospector + { + MatchingObjects = [new DbObjectInfo("dbo", "DocumentStore", "Table")] + }; + introspector.CompatibleOmittedTextImageOnDataSpaceNames["dbo.DocumentStore"] = "PRIMARY"; + + var scripter = new TrackingScripter + { + ScriptObjectHandler = (_, _, _) => + "CREATE TABLE [dbo].[DocumentStore]\r\n(\r\n[DocumentId] [int] NOT NULL,\r\n[Payload] [varchar] (max) NULL\r\n) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]\r\nGO\r\n" + }; + + var service = new SyncCommandService( + new SqlctConfigReader(), + introspector, + scripter, + new SchemaFolderMapper(SupportedSqlObjectTypes.DefaultFolderMap, dataWriteAllFilesInOneDirectory: true)); + + var result = service.RunDiff(projectDir, "db", "dbo.DocumentStore"); + + Assert.True(result.Success, result.Error?.Detail ?? result.Error?.Message); + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.Equal(string.Empty, result.Payload!.Diff); + } + finally + { + CleanupTempDir(tempDir); + } + } + + [Fact] + public void RunStatus_Table_PreservesTextImageOnDifference_WhenDbMetadataDoesNotAllowOmission() + { + 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("Tables", "dbo.DocumentStore.sql"), + "CREATE TABLE [dbo].[DocumentStore]\r\n(\r\n[DocumentId] [int] NOT NULL,\r\n[Payload] [varchar] (max) NULL\r\n) ON [PRIMARY]\r\nGO\r\n"); + + var introspector = new TrackingIntrospector + { + AllObjects = [new DbObjectInfo("dbo", "DocumentStore", "Table")] + }; + + var scripter = new TrackingScripter + { + ScriptObjectHandler = (_, _, _) => + "CREATE TABLE [dbo].[DocumentStore]\r\n(\r\n[DocumentId] [int] NOT NULL,\r\n[Payload] [varchar] (max) NULL\r\n) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]\r\nGO\r\n" + }; + + var service = new SyncCommandService( + new SqlctConfigReader(), + introspector, + scripter, + new SchemaFolderMapper(SupportedSqlObjectTypes.DefaultFolderMap, dataWriteAllFilesInOneDirectory: true)); + + var result = service.RunStatus(projectDir, "db"); + + Assert.True(result.Success, result.Error?.Detail ?? result.Error?.Message); + Assert.Equal(ExitCodes.DiffExists, result.ExitCode); + Assert.Equal(1, result.Payload!.Summary.Schema.Changed); + Assert.Single(result.Payload.Objects); + Assert.Equal("changed", result.Payload.Objects[0].Change); + Assert.Equal("dbo.DocumentStore", result.Payload.Objects[0].Name); + } + finally + { + CleanupTempDir(tempDir); + } + } + [Fact] public void RunPull_WithFilterPattern_LimitsDbScriptingToMatchingObjects() { @@ -1282,6 +2265,9 @@ private sealed class TrackingIntrospector : SqlServerIntrospector public IReadOnlyList MatchingObjects { get; init; } = []; + public Dictionary CompatibleOmittedTextImageOnDataSpaceNames { get; } = + new(StringComparer.OrdinalIgnoreCase); + public override IReadOnlyList ListObjects(SqlConnectionOptions options, int maxParallelism = 0) { ListObjectsCalled = true; @@ -1303,6 +2289,14 @@ public override IReadOnlyList ListMatchingObjects( LastRequestedName = name; return MatchingObjects; } + + public override string? GetTableCompatibleOmittedTextImageOnDataSpaceName( + SqlConnectionOptions options, + string schema, + string name) + => CompatibleOmittedTextImageOnDataSpaceNames.TryGetValue($"{schema}.{name}", out var value) + ? value + : null; } private sealed class TrackingScripter : SqlServerScripter