commit 505a9d52a6ae7db8714c509e1764186c87ccb9dc Author: Johannes Rest Date: Thu Feb 5 07:48:34 2026 +0100 Initial version of collation migration scripts. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c627a02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# SQL Server / tooling +*.bak +*.trn +*.mdf +*.ndf +*.ldf + +# OS / editor +.DS_Store +Thumbs.db +*.swp +*.swo + +# Archives +*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..321f762 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# SQL Server Collation Migration (UTF-8 / UTF-16) – Data Copy + Verification + +This repository contains **two main scripts** to support a SQL Server 2022 migration scenario: + +1. **Data copy (Variant B / Checkpointing)**: copies data from a source database into a target database **table-by-table** with restart capability. +2. **Verification / compare script**: compares source & target databases **without comparing row contents** (rowcounts + schema + programmable objects + principals). + +> Intended usage: build a new target DB (e.g. UTF-8 collation), deploy schema, run copy, then verify. + +## Repository layout + +- `scripts/migrate_copy_checkpoint.sql` + Copy data source → target (restartable, per-table commit, FK-aware order, live progress output) + +- `scripts/compare_source_target.sql` + Compare source vs target (**textual summaries + result sets**) + - Rowcounts per table + - Column schema diffs + - Views/Procs/Functions hash compare + - Trigger hash compare + - Users/Roles + Role memberships + +- `docs/USAGE.md` + How to run the migration copy safely. + +- `docs/VERIFY.md` + How to run and interpret the compare results. + +## Quickstart + +1. Open **SQL Server Management Studio** (SSMS) +2. Ensure target DB exists and schema is deployed +3. Run: + - `scripts/migrate_copy_checkpoint.sql` +4. After successful copy, run: + - `scripts/compare_source_target.sql` + +Both scripts show **live progress output** in SSMS (Messages tab). + +## Configuration + +At the top of each script set: + +- `@SourceDb` (default `sysdb_UTF8`) +- `@TargetDb` (default `sysdb_utf8_jr`) + +## Notes + +- The copy script disables triggers, constraints, and nonclustered indexes in target during load and re-enables them afterwards. +- The compare script uses hashes (SHA2_256) for object definitions to quickly detect differences. diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..5a69750 --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,61 @@ +# Usage – Data Copy (Variant B / Checkpointing) + +## Preconditions + +1. **Target database exists** (e.g. UTF-8 collation) +2. **Target schema is deployed** (tables, constraints, indexes, views, procs, triggers, users/roles etc.) +3. You can connect to the SQL Server instance with sufficient permissions. + +## Run + +Open `scripts/migrate_copy_checkpoint.sql` in SSMS. + +At the top configure: + +- `@SourceDb` (default: `sysdb_UTF8`) +- `@TargetDb` (default: `sysdb_utf8_jr`) + +Optional behavior: + +- `@Mode` + - `RESUME` (default): skip tables already marked `DONE` + - `RESET`: reset state and rerun all tables + +- `@StopOnError` + - `1` (default): stop on first table error + - `0`: continue with next tables; check `dbo.MigrationState` afterwards + +- `@BatchSize` + - used for tables with `IDENTITY (int/bigint)` to copy in chunks + +Execute the script. + +## Monitoring + +The script prints progress like: + +- `... | TABLE_START | dbo.TableX` +- `... | TABLE_DONE | dbo.TableX | Old=... New=...` +- `... | TABLE_FAILED | dbo.TableX | Error ...` + +In target DB you also have: + +- `dbo.MigrationState` (table-level status) +- `dbo.MigrationCopyLog` (append-only log) + +Useful queries: + +```sql +USE sysdb_utf8_jr; +SELECT * FROM dbo.MigrationState ORDER BY Status, TableSchema, TableName; +SELECT TOP 200 * FROM dbo.MigrationCopyLog ORDER BY LogId DESC; +``` + +## Rerun behavior + +Each table is copied as: + +1. `DELETE FROM target.table` +2. `INSERT INTO target.table SELECT ... FROM source.table` + +So reruns are safe. The checkpoint state enables resuming. diff --git a/docs/VERIFY.md b/docs/VERIFY.md new file mode 100644 index 0000000..7632c34 --- /dev/null +++ b/docs/VERIFY.md @@ -0,0 +1,49 @@ +# Verify – Compare Source and Target + +The script `scripts/compare_source_target.sql` compares **structure and high-level consistency** between two databases without comparing row contents. + +## What is compared + +1. **Rowcounts per table** +2. **Columns**: datatype, length, nullable, collation +3. **Programmable objects**: views, stored procedures, functions (hash of definition) +4. **Triggers** (hash of definition) +5. **Users/Roles**: principals existence + role memberships + +## How to run + +1. Open `scripts/compare_source_target.sql` in SSMS +2. Set: + - `@SourceDb` (default: `sysdb_UTF8`) + - `@TargetDb` (default: `sysdb_utf8_jr`) +3. Execute. + +## Output style (quick to read) + +### Live messages (SSMS Messages tab) + +You get a human-readable summary per step, e.g.: + +- `ROWCOUNTS | SourceTables=... TargetTables=... TablesWithRowDiff=...` +- `COLUMNS | ColumnDiffs=...` +- `MODULES | Differences=...` +- `TRIGGERS | Differences=...` +- `PRINCIPALS | Differences=...` +- `ROLEMEMBERS | Differences=...` + +At the end: + +`COMPARE_DONE | RowDiffTables=... | ColumnDiffs=... | ...` + +### Result sets (details) + +If differences exist, result sets are shown for: + +- Rowcount diffs +- Column diffs +- Module diffs +- Trigger diffs +- Principal diffs +- Role membership diffs + +If a section has **no differences**, you’ll see an `..._OK` message and no detail list. diff --git a/scripts/compare_source_target.sql b/scripts/compare_source_target.sql new file mode 100644 index 0000000..0b15229 --- /dev/null +++ b/scripts/compare_source_target.sql @@ -0,0 +1,485 @@ +/* ============================================================ + Compare Script (Schema + Rowcounts + Programmable Objects) + SQL Server 2022 + + Compares SOURCE and TARGET databases without comparing row contents: + - Rowcount per table (diff list + summary) + - Tables/columns (datatype/length/nullable/collation) diff list + summary + - Programmable objects (views/procs/functions) via SHA2_256 hash of definition + - Triggers via SHA2_256 hash of definition + - Users/Roles existence + role memberships (name-based) + + Output: + - Textual progress and summary via RAISERROR ... WITH NOWAIT + - Result sets with detailed diffs + + Configure DB names below. + ============================================================ */ + +SET NOCOUNT ON; +SET XACT_ABORT ON; + +DECLARE @SourceDb sysname = N'sysdb_UTF8'; +DECLARE @TargetDb sysname = N'sysdb_utf8_jr'; + +DECLARE @QSource sysname = QUOTENAME(@SourceDb); +DECLARE @QTarget sysname = QUOTENAME(@TargetDb); + +DECLARE @sev int = 10; +DECLARE @Now nvarchar(30); + +DECLARE @msg nvarchar(4000); +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +SET @msg = CONCAT(@Now, N' | COMPARE_START | Source=', @SourceDb, N' | Target=', @TargetDb); +RAISERROR(@msg, @sev, 1) WITH NOWAIT; + +-------------------------------------------------------------------------------- +-- 1) Rowcounts per table +-------------------------------------------------------------------------------- +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +RAISERROR(CONCAT(@Now, N' | STEP 1/5 | Rowcounts per table...'), @sev, 1) WITH NOWAIT; + +IF OBJECT_ID('tempdb..#RowCounts') IS NOT NULL DROP TABLE #RowCounts; +CREATE TABLE #RowCounts( + DbName sysname NOT NULL, + SchemaName sysname NOT NULL, + TableName sysname NOT NULL, + RowCount bigint NOT NULL +); + +DECLARE @sql nvarchar(max); + +SET @sql = N' +INSERT INTO #RowCounts(DbName, SchemaName, TableName, RowCount) +SELECT N''' + REPLACE(@SourceDb,'''','''''') + N''', s.name, t.name, SUM(p.rows) +FROM ' + @QSource + N'.sys.tables t +JOIN ' + @QSource + N'.sys.schemas s ON s.schema_id = t.schema_id +JOIN ' + @QSource + N'.sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0,1) +WHERE t.is_ms_shipped=0 +GROUP BY s.name, t.name;'; +EXEC sp_executesql @sql; + +SET @sql = N' +INSERT INTO #RowCounts(DbName, SchemaName, TableName, RowCount) +SELECT N''' + REPLACE(@TargetDb,'''','''''') + N''', s.name, t.name, SUM(p.rows) +FROM ' + @QTarget + N'.sys.tables t +JOIN ' + @QTarget + N'.sys.schemas s ON s.schema_id = t.schema_id +JOIN ' + @QTarget + N'.sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0,1) +WHERE t.is_ms_shipped=0 +GROUP BY s.name, t.name;'; +EXEC sp_executesql @sql; + +;WITH s AS ( + SELECT SchemaName, TableName, RowCount FROM #RowCounts WHERE DbName=@SourceDb +), +t AS ( + SELECT SchemaName, TableName, RowCount FROM #RowCounts WHERE DbName=@TargetDb +), +d AS ( + SELECT + COALESCE(s.SchemaName, t.SchemaName) AS SchemaName, + COALESCE(s.TableName, t.TableName) AS TableName, + s.RowCount AS SourceRows, + t.RowCount AS TargetRows, + (ISNULL(t.RowCount,0) - ISNULL(s.RowCount,0)) AS Diff + FROM s + FULL OUTER JOIN t + ON t.SchemaName = s.SchemaName + AND t.TableName = s.TableName +) +SELECT * +INTO #RowDiff +FROM d +WHERE ISNULL(SourceRows,-1) <> ISNULL(TargetRows,-1); + +DECLARE @RowDiffCount int = (SELECT COUNT(*) FROM #RowDiff); +DECLARE @RowTotalSource int = (SELECT COUNT(*) FROM (SELECT DISTINCT SchemaName, TableName FROM #RowCounts WHERE DbName=@SourceDb) x); +DECLARE @RowTotalTarget int = (SELECT COUNT(*) FROM (SELECT DISTINCT SchemaName, TableName FROM #RowCounts WHERE DbName=@TargetDb) x); + +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +RAISERROR(CONCAT(@Now, N' | ROWCOUNTS | SourceTables=', @RowTotalSource, N' TargetTables=', @RowTotalTarget, N' TablesWithRowDiff=', @RowDiffCount), @sev, 1) WITH NOWAIT; + +IF @RowDiffCount = 0 + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | ROWCOUNTS_OK | All table rowcounts match.'), @sev, 1) WITH NOWAIT; +ELSE +BEGIN + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | ROWCOUNTS_DIFF | Showing tables with rowcount differences...'), @sev, 1) WITH NOWAIT; + SELECT * FROM #RowDiff ORDER BY ABS(Diff) DESC, SchemaName, TableName; +END + +-------------------------------------------------------------------------------- +-- 2) Column/schema diffs +-------------------------------------------------------------------------------- +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +RAISERROR(CONCAT(@Now, N' | STEP 2/5 | Table/column schema diffs...'), @sev, 1) WITH NOWAIT; + +IF OBJECT_ID('tempdb..#Cols') IS NOT NULL DROP TABLE #Cols; +CREATE TABLE #Cols( + DbName sysname, + SchemaName sysname, + TableName sysname, + ColumnName sysname, + TypeName sysname, + MaxLen int, + PrecisionVal int, + ScaleVal int, + IsNullable bit, + CollationName sysname NULL +); + +SET @sql = N' +INSERT #Cols +SELECT N''' + REPLACE(@SourceDb,'''','''''') + N''', + s.name, t.name, c.name, + ty.name, + c.max_length, + c.precision, + c.scale, + c.is_nullable, + c.collation_name +FROM ' + @QSource + N'.sys.columns c +JOIN ' + @QSource + N'.sys.tables t ON t.object_id=c.object_id +JOIN ' + @QSource + N'.sys.schemas s ON s.schema_id=t.schema_id +JOIN ' + @QSource + N'.sys.types ty ON ty.user_type_id=c.user_type_id +WHERE t.is_ms_shipped=0;'; +EXEC sp_executesql @sql; + +SET @sql = N' +INSERT #Cols +SELECT N''' + REPLACE(@TargetDb,'''','''''') + N''', + s.name, t.name, c.name, + ty.name, + c.max_length, + c.precision, + c.scale, + c.is_nullable, + c.collation_name +FROM ' + @QTarget + N'.sys.columns c +JOIN ' + @QTarget + N'.sys.tables t ON t.object_id=c.object_id +JOIN ' + @QTarget + N'.sys.schemas s ON s.schema_id=t.schema_id +JOIN ' + @QTarget + N'.sys.types ty ON ty.user_type_id=c.user_type_id +WHERE t.is_ms_shipped=0;'; +EXEC sp_executesql @sql; + +;WITH s AS (SELECT * FROM #Cols WHERE DbName=@SourceDb), + t AS (SELECT * FROM #Cols WHERE DbName=@TargetDb), + d AS ( + SELECT + COALESCE(s.SchemaName, t.SchemaName) AS SchemaName, + COALESCE(s.TableName, t.TableName) AS TableName, + COALESCE(s.ColumnName, t.ColumnName) AS ColumnName, + CONCAT(ISNULL(s.TypeName,N''), N'(', ISNULL(CONVERT(nvarchar(20),s.MaxLen),N''), N')') AS SourceType, + CONCAT(ISNULL(t.TypeName,N''), N'(', ISNULL(CONVERT(nvarchar(20),t.MaxLen),N''), N')') AS TargetType, + s.IsNullable AS SourceNullable, + t.IsNullable AS TargetNullable, + s.CollationName AS SourceCollation, + t.CollationName AS TargetCollation + FROM s + FULL OUTER JOIN t + ON t.SchemaName = s.SchemaName + AND t.TableName = s.TableName + AND t.ColumnName = s.ColumnName + ) +SELECT * +INTO #ColDiff +FROM d +WHERE + ISNULL(SourceType,N'') <> ISNULL(TargetType,N'') + OR ISNULL(SourceNullable,2) <> ISNULL(TargetNullable,2) + OR ISNULL(SourceCollation,N'') <> ISNULL(TargetCollation,N''); + +DECLARE @ColDiffCount int = (SELECT COUNT(*) FROM #ColDiff); +RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | COLUMNS | ColumnDiffs=', @ColDiffCount), @sev, 1) WITH NOWAIT; + +IF @ColDiffCount = 0 + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | COLUMNS_OK | No column definition diffs detected.'), @sev, 1) WITH NOWAIT; +ELSE +BEGIN + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | COLUMNS_DIFF | Showing column diffs...'), @sev, 1) WITH NOWAIT; + SELECT * FROM #ColDiff ORDER BY SchemaName, TableName, ColumnName; +END + +-------------------------------------------------------------------------------- +-- 3) Programmable objects +-------------------------------------------------------------------------------- +RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | STEP 3/5 | Programmable objects (hash compare)...'), @sev, 1) WITH NOWAIT; + +IF OBJECT_ID('tempdb..#Modules') IS NOT NULL DROP TABLE #Modules; +CREATE TABLE #Modules( + DbName sysname, + ObjectType nvarchar(60), + SchemaName sysname, + ObjectName sysname, + DefinitionHash varbinary(32) NULL +); + +SET @sql = N' +INSERT #Modules +SELECT + N''' + REPLACE(@SourceDb,'''','''''') + N''', + o.type_desc, + s.name, + o.name, + HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), m.definition)) +FROM ' + @QSource + N'.sys.objects o +JOIN ' + @QSource + N'.sys.schemas s ON s.schema_id=o.schema_id +JOIN ' + @QSource + N'.sys.sql_modules m ON m.object_id=o.object_id +WHERE o.is_ms_shipped=0 + AND o.type IN (''V'',''P'',''FN'',''IF'',''TF'');'; +EXEC sp_executesql @sql; + +SET @sql = N' +INSERT #Modules +SELECT + N''' + REPLACE(@TargetDb,'''','''''') + N''', + o.type_desc, + s.name, + o.name, + HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), m.definition)) +FROM ' + @QTarget + N'.sys.objects o +JOIN ' + @QTarget + N'.sys.schemas s ON s.schema_id=o.schema_id +JOIN ' + @QTarget + N'.sys.sql_modules m ON m.object_id=o.object_id +WHERE o.is_ms_shipped=0 + AND o.type IN (''V'',''P'',''FN'',''IF'',''TF'');'; +EXEC sp_executesql @sql; + +;WITH s AS (SELECT * FROM #Modules WHERE DbName=@SourceDb), + t AS (SELECT * FROM #Modules WHERE DbName=@TargetDb), + d AS ( + SELECT + COALESCE(s.ObjectType, t.ObjectType) AS ObjectType, + COALESCE(s.SchemaName, t.SchemaName) AS SchemaName, + COALESCE(s.ObjectName, t.ObjectName) AS ObjectName, + CASE WHEN s.ObjectName IS NULL THEN 'MISSING_IN_SOURCE' + WHEN t.ObjectName IS NULL THEN 'MISSING_IN_TARGET' + WHEN s.DefinitionHash <> t.DefinitionHash THEN 'DIFFERENT_DEFINITION' + ELSE 'OK' END AS Status + FROM s + FULL OUTER JOIN t + ON t.ObjectType = s.ObjectType + AND t.SchemaName = s.SchemaName + AND t.ObjectName = s.ObjectName + ) +SELECT * +INTO #ModuleDiff +FROM d +WHERE Status <> 'OK'; + +DECLARE @ModuleDiffCount int = (SELECT COUNT(*) FROM #ModuleDiff); +RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | MODULES | Differences=', @ModuleDiffCount), @sev, 1) WITH NOWAIT; + +IF @ModuleDiffCount = 0 + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | MODULES_OK | No module definition diffs detected.'), @sev, 1) WITH NOWAIT; +ELSE +BEGIN + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | MODULES_DIFF | Showing module diffs...'), @sev, 1) WITH NOWAIT; + SELECT * FROM #ModuleDiff ORDER BY ObjectType, SchemaName, ObjectName; +END + +-------------------------------------------------------------------------------- +-- 4) Triggers +-------------------------------------------------------------------------------- +RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | STEP 4/5 | Triggers (hash compare)...'), @sev, 1) WITH NOWAIT; + +IF OBJECT_ID('tempdb..#Triggers') IS NOT NULL DROP TABLE #Triggers; +CREATE TABLE #Triggers( + DbName sysname, + TriggerSchema sysname, + TriggerName sysname, + ParentSchema sysname, + ParentName sysname, + DefinitionHash varbinary(32) NULL +); + +SET @sql = N' +INSERT #Triggers +SELECT + N''' + REPLACE(@SourceDb,'''','''''') + N''', + ss.name, + tr.name, + ps.name, + pt.name, + HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), m.definition)) +FROM ' + @QSource + N'.sys.triggers tr +JOIN ' + @QSource + N'.sys.tables pt ON pt.object_id = tr.parent_id +JOIN ' + @QSource + N'.sys.schemas ps ON ps.schema_id = pt.schema_id +JOIN ' + @QSource + N'.sys.schemas ss ON ss.schema_id = tr.schema_id +JOIN ' + @QSource + N'.sys.sql_modules m ON m.object_id = tr.object_id +WHERE tr.is_ms_shipped=0;'; +EXEC sp_executesql @sql; + +SET @sql = N' +INSERT #Triggers +SELECT + N''' + REPLACE(@TargetDb,'''','''''') + N''', + ss.name, + tr.name, + ps.name, + pt.name, + HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), m.definition)) +FROM ' + @QTarget + N'.sys.triggers tr +JOIN ' + @QTarget + N'.sys.tables pt ON pt.object_id = tr.parent_id +JOIN ' + @QTarget + N'.sys.schemas ps ON ps.schema_id = pt.schema_id +JOIN ' + @QTarget + N'.sys.schemas ss ON ss.schema_id = tr.schema_id +JOIN ' + @QTarget + N'.sys.sql_modules m ON m.object_id = tr.object_id +WHERE tr.is_ms_shipped=0;'; +EXEC sp_executesql @sql; + +;WITH s AS (SELECT * FROM #Triggers WHERE DbName=@SourceDb), + t AS (SELECT * FROM #Triggers WHERE DbName=@TargetDb), + d AS ( + SELECT + COALESCE(s.TriggerSchema, t.TriggerSchema) AS TriggerSchema, + COALESCE(s.TriggerName, t.TriggerName) AS TriggerName, + COALESCE(s.ParentSchema, t.ParentSchema) AS ParentSchema, + COALESCE(s.ParentName, t.ParentName) AS ParentTable, + CASE WHEN s.TriggerName IS NULL THEN 'MISSING_IN_SOURCE' + WHEN t.TriggerName IS NULL THEN 'MISSING_IN_TARGET' + WHEN s.DefinitionHash <> t.DefinitionHash THEN 'DIFFERENT_DEFINITION' + ELSE 'OK' END AS Status + FROM s + FULL OUTER JOIN t + ON t.TriggerSchema = s.TriggerSchema + AND t.TriggerName = s.TriggerName + AND t.ParentSchema = s.ParentSchema + AND t.ParentName = s.ParentName + ) +SELECT * +INTO #TriggerDiff +FROM d +WHERE Status <> 'OK'; + +DECLARE @TriggerDiffCount int = (SELECT COUNT(*) FROM #TriggerDiff); +RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | TRIGGERS | Differences=', @TriggerDiffCount), @sev, 1) WITH NOWAIT; + +IF @TriggerDiffCount = 0 + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | TRIGGERS_OK | No trigger definition diffs detected.'), @sev, 1) WITH NOWAIT; +ELSE +BEGIN + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | TRIGGERS_DIFF | Showing trigger diffs...'), @sev, 1) WITH NOWAIT; + SELECT * FROM #TriggerDiff ORDER BY TriggerSchema, TriggerName, ParentSchema, ParentTable; +END + +-------------------------------------------------------------------------------- +-- 5) Users/Roles +-------------------------------------------------------------------------------- +RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | STEP 5/5 | Users/Roles (existence + memberships)...'), @sev, 1) WITH NOWAIT; + +IF OBJECT_ID('tempdb..#Principals') IS NOT NULL DROP TABLE #Principals; +CREATE TABLE #Principals( + DbName sysname, + Name sysname, + TypeDesc nvarchar(60) +); + +SET @sql = N' +INSERT #Principals +SELECT N''' + REPLACE(@SourceDb,'''','''''') + N''', name, type_desc +FROM ' + @QSource + N'.sys.database_principals +WHERE type IN (''S'',''U'',''G'',''R'') + AND name NOT IN (''dbo'',''guest'',''INFORMATION_SCHEMA'',''sys'');'; +EXEC sp_executesql @sql; + +SET @sql = N' +INSERT #Principals +SELECT N''' + REPLACE(@TargetDb,'''','''''') + N''', name, type_desc +FROM ' + @QTarget + N'.sys.database_principals +WHERE type IN (''S'',''U'',''G'',''R'') + AND name NOT IN (''dbo'',''guest'',''INFORMATION_SCHEMA'',''sys'');'; +EXEC sp_executesql @sql; + +;WITH s AS (SELECT * FROM #Principals WHERE DbName=@SourceDb), + t AS (SELECT * FROM #Principals WHERE DbName=@TargetDb), + d AS ( + SELECT + COALESCE(s.Name, t.Name) AS PrincipalName, + s.TypeDesc AS SourceType, + t.TypeDesc AS TargetType, + CASE WHEN s.Name IS NULL THEN 'MISSING_IN_SOURCE' + WHEN t.Name IS NULL THEN 'MISSING_IN_TARGET' + WHEN s.TypeDesc <> t.TypeDesc THEN 'TYPE_DIFF' + ELSE 'OK' END AS Status + FROM s + FULL OUTER JOIN t ON t.Name = s.Name + ) +SELECT * +INTO #PrincipalDiff +FROM d +WHERE Status <> 'OK'; + +DECLARE @PrincipalDiffCount int = (SELECT COUNT(*) FROM #PrincipalDiff); +RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | PRINCIPALS | Differences=', @PrincipalDiffCount), @sev, 1) WITH NOWAIT; + +IF @PrincipalDiffCount > 0 +BEGIN + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | PRINCIPALS_DIFF | Showing principal diffs...'), @sev, 1) WITH NOWAIT; + SELECT * FROM #PrincipalDiff ORDER BY PrincipalName; +END +ELSE + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | PRINCIPALS_OK | No principal diffs detected.'), @sev, 1) WITH NOWAIT; + +IF OBJECT_ID('tempdb..#RoleMembers') IS NOT NULL DROP TABLE #RoleMembers; +CREATE TABLE #RoleMembers( + DbName sysname, + RoleName sysname, + MemberName sysname +); + +SET @sql = N' +INSERT #RoleMembers +SELECT N''' + REPLACE(@SourceDb,'''','''''') + N''', + r.name AS RoleName, m.name AS MemberName +FROM ' + @QSource + N'.sys.database_role_members rm +JOIN ' + @QSource + N'.sys.database_principals r ON r.principal_id = rm.role_principal_id +JOIN ' + @QSource + N'.sys.database_principals m ON m.principal_id = rm.member_principal_id +WHERE r.name NOT IN (''public'');'; +EXEC sp_executesql @sql; + +SET @sql = N' +INSERT #RoleMembers +SELECT N''' + REPLACE(@TargetDb,'''','''''') + N''', + r.name AS RoleName, m.name AS MemberName +FROM ' + @QTarget + N'.sys.database_role_members rm +JOIN ' + @QTarget + N'.sys.database_principals r ON r.principal_id = rm.role_principal_id +JOIN ' + @QTarget + N'.sys.database_principals m ON m.principal_id = rm.member_principal_id +WHERE r.name NOT IN (''public'');'; +EXEC sp_executesql @sql; + +;WITH s AS (SELECT RoleName, MemberName FROM #RoleMembers WHERE DbName=@SourceDb), + t AS (SELECT RoleName, MemberName FROM #RoleMembers WHERE DbName=@TargetDb), + d AS ( + SELECT + COALESCE(s.RoleName, t.RoleName) AS RoleName, + COALESCE(s.MemberName, t.MemberName) AS MemberName, + CASE WHEN s.RoleName IS NULL THEN 'MISSING_IN_SOURCE' + WHEN t.RoleName IS NULL THEN 'MISSING_IN_TARGET' + ELSE 'OK' END AS Status + FROM s + FULL OUTER JOIN t + ON t.RoleName = s.RoleName AND t.MemberName = s.MemberName + ) +SELECT * +INTO #RoleMemberDiff +FROM d +WHERE Status <> 'OK'; + +DECLARE @RoleMemberDiffCount int = (SELECT COUNT(*) FROM #RoleMemberDiff); +RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | ROLEMEMBERS | Differences=', @RoleMemberDiffCount), @sev, 1) WITH NOWAIT; + +IF @RoleMemberDiffCount > 0 +BEGIN + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | ROLEMEMBERS_DIFF | Showing role membership diffs...'), @sev, 1) WITH NOWAIT; + SELECT * FROM #RoleMemberDiff ORDER BY RoleName, MemberName; +END +ELSE + RAISERROR(CONCAT(CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | ROLEMEMBERS_OK | No role membership diffs detected.'), @sev, 1) WITH NOWAIT; + +RAISERROR(CONCAT( + CONVERT(nvarchar(30), SYSDATETIME(), 121), N' | COMPARE_DONE | ', + N'RowDiffTables=', @RowDiffCount, + N' | ColumnDiffs=', @ColDiffCount, + N' | ModuleDiffs=', @ModuleDiffCount, + N' | TriggerDiffs=', @TriggerDiffCount, + N' | PrincipalDiffs=', @PrincipalDiffCount, + N' | RoleMemberDiffs=', @RoleMemberDiffCount +), @sev, 1) WITH NOWAIT; diff --git a/scripts/migrate_copy_checkpoint.sql b/scripts/migrate_copy_checkpoint.sql new file mode 100644 index 0000000..018b26f --- /dev/null +++ b/scripts/migrate_copy_checkpoint.sql @@ -0,0 +1,679 @@ +/* ============================================================ + Migration Copy Script (Variant B - Checkpointing / Restartable) + SQL Server 2022 + + Source DB: sysdb_UTF8 + Target DB: sysdb_utf8_jr + + Features: + - FK-aware table order (parent -> child) with cycle fallback + - Per-table transaction + checkpointing in dbo.MigrationState + - Idempotent per table: DELETE target table then INSERT + - Disables triggers + constraints + nonclustered indexes before load + - Rebuilds indexes, re-enables constraints (WITH CHECK), enables triggers after load + - Collation conflict resistant (explicit COLLATE DATABASE_DEFAULT in comparisons) + - Visible progress in SSMS using RAISERROR ... WITH NOWAIT + + Controls: + @Mode: 'RESUME' or 'RESET' + @StopOnError: 1 stop at first failure, 0 continue + @BatchSize: range size for batching identity int/bigint + + Notes: + - This script copies DATA only. Schema must already exist in target. + ============================================================ */ + +SET NOCOUNT ON; +SET XACT_ABORT ON; + +DECLARE @SourceDb sysname = N'sysdb_UTF8'; +DECLARE @TargetDb sysname = N'sysdb_utf8_jr'; + +DECLARE @Mode varchar(10) = 'RESUME'; -- 'RESUME' or 'RESET' +DECLARE @StopOnError bit = 1; -- 1 stop, 0 continue +DECLARE @BatchSize int = 50000; -- identity range size for batching +DECLARE @Verbose bit = 1; -- 1 prints progress + +DECLARE @QSourceDb sysname = QUOTENAME(@SourceDb); +DECLARE @QTargetDb sysname = QUOTENAME(@TargetDb); + +DECLARE @RunId uniqueidentifier = NEWID(); + +-------------------------------------------------------------------------------- +-- Helper: print progress immediately in SSMS +-------------------------------------------------------------------------------- +DECLARE @msg nvarchar(4000); +DECLARE @sev int = 10; +DECLARE @Now nvarchar(30); + +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +SET @msg = CONCAT(@Now, N' | START | Source=', @SourceDb, N' -> Target=', @TargetDb, N' | Mode=', @Mode); +RAISERROR(@msg, @sev, 1) WITH NOWAIT; + +-------------------------------------------------------------------------------- +-- 0) Ensure we are in target DB context for DATABASE_DEFAULT collation usage +-------------------------------------------------------------------------------- +DECLARE @UseTargetSql nvarchar(max) = N'USE ' + @QTargetDb + N';'; +EXEC sp_executesql @UseTargetSql; + +-------------------------------------------------------------------------------- +-- 1) Setup state/log tables in TARGET +-------------------------------------------------------------------------------- +DECLARE @SetupSql nvarchar(max) = N' +USE ' + @QTargetDb + N'; + +IF OBJECT_ID(''dbo.MigrationState'',''U'') IS NULL +BEGIN + CREATE TABLE dbo.MigrationState( + TableSchema sysname NOT NULL, + TableName sysname NOT NULL, + Status varchar(20) NOT NULL, -- PENDING/RUNNING/DONE/FAILED + StartedAt datetime2 NULL, + FinishedAt datetime2 NULL, + RowsCopied bigint NULL, + LastError nvarchar(max) NULL, + LastRunId uniqueidentifier NULL, + CONSTRAINT PK_MigrationState PRIMARY KEY (TableSchema, TableName) + ); +END; + +IF OBJECT_ID(''dbo.MigrationCopyLog'',''U'') IS NULL +BEGIN + CREATE TABLE dbo.MigrationCopyLog( + LogId int IDENTITY(1,1) PRIMARY KEY, + LogTime datetime2 NOT NULL DEFAULT SYSUTCDATETIME(), + RunId uniqueidentifier NULL, + Step nvarchar(200) NOT NULL, + Details nvarchar(max) NULL + ); +END; + +INSERT dbo.MigrationCopyLog(RunId, Step, Details) +VALUES +( + @RunId, + N''RUN_START'', + CONCAT( + N''SourceDb='', @SourceDb, + N''; TargetDb='', DB_NAME(), + N''; Mode='', @Mode, + N''; StopOnError='', @StopOnError, + N''; BatchSize='', @BatchSize + ) +); +'; +EXEC sp_executesql + @SetupSql, + N'@RunId uniqueidentifier, @SourceDb sysname, @Mode varchar(10), @StopOnError bit, @BatchSize int', + @RunId=@RunId, @SourceDb=@SourceDb, @Mode=@Mode, @StopOnError=@StopOnError, @BatchSize=@BatchSize; + +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +RAISERROR(CONCAT(@Now, N' | SETUP | State+Log ready in ', @TargetDb), @sev, 1) WITH NOWAIT; + +-------------------------------------------------------------------------------- +-- 2) Collect SOURCE tables into #Tables (names stored in TARGET collation) +-------------------------------------------------------------------------------- +IF OBJECT_ID('tempdb..#Tables') IS NOT NULL DROP TABLE #Tables; +CREATE TABLE #Tables +( + SchemaName nvarchar(128) COLLATE DATABASE_DEFAULT NOT NULL, + TableName nvarchar(128) COLLATE DATABASE_DEFAULT NOT NULL, + FullName nvarchar(300) COLLATE DATABASE_DEFAULT NOT NULL, + NodeId int IDENTITY(1,1) PRIMARY KEY, + InDegree int NOT NULL DEFAULT(0), + ProcessOrder int NULL +); + +DECLARE @SqlTables nvarchar(max) = N' +SELECT s.name AS SchemaName, + t.name AS TableName, + QUOTENAME(s.name) + ''.'' + QUOTENAME(t.name) AS FullName +FROM ' + @QSourceDb + N'.sys.tables t +JOIN ' + @QSourceDb + N'.sys.schemas s ON s.schema_id = t.schema_id +WHERE t.is_ms_shipped = 0 + AND t.name NOT LIKE ''dt%'';'; + +INSERT INTO #Tables(SchemaName, TableName, FullName) +EXEC sp_executesql @SqlTables; + +IF NOT EXISTS (SELECT 1 FROM #Tables) + THROW 50001, 'No user tables found in source.', 1; + +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +RAISERROR(CONCAT(@Now, N' | DISCOVER | Found tables: ', (SELECT COUNT(*) FROM #Tables)), @sev, 1) WITH NOWAIT; + +-------------------------------------------------------------------------------- +-- 3) Collect FK edges (Parent -> Child) from SOURCE +-------------------------------------------------------------------------------- +IF OBJECT_ID('tempdb..#Edges') IS NOT NULL DROP TABLE #Edges; +CREATE TABLE #Edges +( + ParentSchema nvarchar(128) COLLATE DATABASE_DEFAULT NOT NULL, + ParentTable nvarchar(128) COLLATE DATABASE_DEFAULT NOT NULL, + ChildSchema nvarchar(128) COLLATE DATABASE_DEFAULT NOT NULL, + ChildTable nvarchar(128) COLLATE DATABASE_DEFAULT NOT NULL +); + +DECLARE @SqlEdges nvarchar(max) = N' +SELECT + sp.name AS ParentSchema, + tp.name AS ParentTable, + sc.name AS ChildSchema, + tc.name AS ChildTable +FROM ' + @QSourceDb + N'.sys.foreign_keys fk +JOIN ' + @QSourceDb + N'.sys.tables tc ON tc.object_id = fk.parent_object_id +JOIN ' + @QSourceDb + N'.sys.schemas sc ON sc.schema_id = tc.schema_id +JOIN ' + @QSourceDb + N'.sys.tables tp ON tp.object_id = fk.referenced_object_id +JOIN ' + @QSourceDb + N'.sys.schemas sp ON sp.schema_id = tp.schema_id +WHERE tc.is_ms_shipped = 0 AND tp.is_ms_shipped = 0;'; + +INSERT INTO #Edges(ParentSchema, ParentTable, ChildSchema, ChildTable) +EXEC sp_executesql @SqlEdges; + +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +RAISERROR(CONCAT(@Now, N' | DISCOVER | Found FKs: ', (SELECT COUNT(*) FROM #Edges)), @sev, 1) WITH NOWAIT; + +-------------------------------------------------------------------------------- +-- 4) Compute indegrees and order tables (Kahn). Cycle fallback appended by name. +-------------------------------------------------------------------------------- +UPDATE t +SET InDegree = x.Cnt +FROM #Tables t +JOIN ( + SELECT ChildSchema, ChildTable, COUNT(*) AS Cnt + FROM #Edges + GROUP BY ChildSchema, ChildTable +) x + ON x.ChildSchema = t.SchemaName + AND x.ChildTable = t.TableName; + +IF OBJECT_ID('tempdb..#Queue') IS NOT NULL DROP TABLE #Queue; +CREATE TABLE #Queue(NodeId int PRIMARY KEY); + +INSERT INTO #Queue(NodeId) +SELECT NodeId FROM #Tables WHERE InDegree = 0; + +DECLARE @order int = 1; + +WHILE EXISTS (SELECT 1 FROM #Queue) +BEGIN + DECLARE @nid int; + SELECT TOP(1) @nid = NodeId FROM #Queue ORDER BY NodeId; + DELETE FROM #Queue WHERE NodeId=@nid; + + UPDATE #Tables SET ProcessOrder=@order WHERE NodeId=@nid; + SET @order += 1; + + DECLARE @ps nvarchar(128), @pt nvarchar(128); + SELECT @ps=SchemaName, @pt=TableName FROM #Tables WHERE NodeId=@nid; + + ;WITH children AS ( + SELECT c.NodeId + FROM #Edges e + JOIN #Tables c + ON c.SchemaName = e.ChildSchema + AND c.TableName = e.ChildTable + WHERE e.ParentSchema = @ps + AND e.ParentTable = @pt + ) + UPDATE t + SET InDegree = InDegree - 1 + FROM #Tables t + JOIN children ch ON ch.NodeId=t.NodeId; + + INSERT INTO #Queue(NodeId) + SELECT t.NodeId + FROM #Tables t + WHERE t.ProcessOrder IS NULL AND t.InDegree = 0 + AND NOT EXISTS (SELECT 1 FROM #Queue q WHERE q.NodeId=t.NodeId); +END + +-- Cycle fallback +UPDATE t +SET ProcessOrder = @order + rn.rn - 1 +FROM #Tables t +JOIN ( + SELECT NodeId, ROW_NUMBER() OVER (ORDER BY SchemaName, TableName) AS rn + FROM #Tables + WHERE ProcessOrder IS NULL +) rn ON rn.NodeId=t.NodeId; + +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +RAISERROR(CONCAT(@Now, N' | ORDER | Table order ready.'), @sev, 1) WITH NOWAIT; + +-------------------------------------------------------------------------------- +-- 5) Initialize/Reset MigrationState based on #Tables (Collation-safe comparisons) +-------------------------------------------------------------------------------- +DECLARE @InitStateSql nvarchar(max) = N' +USE ' + @QTargetDb + N'; + +-- Insert missing state rows +INSERT INTO dbo.MigrationState(TableSchema, TableName, Status) +SELECT t.SchemaName, t.TableName, ''PENDING'' +FROM #Tables t +WHERE NOT EXISTS +( + SELECT 1 + FROM dbo.MigrationState s + WHERE s.TableSchema COLLATE DATABASE_DEFAULT = t.SchemaName COLLATE DATABASE_DEFAULT + AND s.TableName COLLATE DATABASE_DEFAULT = t.TableName COLLATE DATABASE_DEFAULT +); + +-- Reset mode +IF (@Mode = ''RESET'') +BEGIN + UPDATE s + SET Status=''PENDING'', + StartedAt=NULL, + FinishedAt=NULL, + RowsCopied=NULL, + LastError=NULL, + LastRunId=NULL + FROM dbo.MigrationState s + WHERE EXISTS + ( + SELECT 1 + FROM #Tables t + WHERE t.SchemaName COLLATE DATABASE_DEFAULT = s.TableSchema COLLATE DATABASE_DEFAULT + AND t.TableName COLLATE DATABASE_DEFAULT = s.TableName COLLATE DATABASE_DEFAULT + ); +END; + +INSERT dbo.MigrationCopyLog(RunId, Step, Details) +VALUES (@RunId, N''STATE_READY'', NULL); +'; + +EXEC sp_executesql + @InitStateSql, + N'@Mode varchar(10), @RunId uniqueidentifier', + @Mode=@Mode, @RunId=@RunId; + +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +RAISERROR(CONCAT(@Now, N' | STATE | MigrationState initialized (Mode=', @Mode, N')'), @sev, 1) WITH NOWAIT; + +-------------------------------------------------------------------------------- +-- 6) Prepare target for load: disable triggers, nocheck constraints, disable NC indexes +-------------------------------------------------------------------------------- +DECLARE @PrepSql nvarchar(max) = N' +USE ' + @QTargetDb + N'; +DECLARE @cmd nvarchar(max); + +-- disable triggers +SET @cmd = N''''; +SELECT @cmd = @cmd + N''DISABLE TRIGGER ALL ON '' + QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name) + N'';'' + CHAR(13)+CHAR(10) +FROM sys.tables t JOIN sys.schemas s ON s.schema_id=t.schema_id +WHERE t.is_ms_shipped=0; +EXEC sp_executesql @cmd; + +-- nocheck constraints +SET @cmd = N''''; +SELECT @cmd = @cmd + N''ALTER TABLE '' + QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name) + N'' NOCHECK CONSTRAINT ALL;'' + CHAR(13)+CHAR(10) +FROM sys.tables t JOIN sys.schemas s ON s.schema_id=t.schema_id +WHERE t.is_ms_shipped=0; +EXEC sp_executesql @cmd; + +-- disable nonclustered indexes (skip PK/unique constraint) +SET @cmd = N''''; +SELECT @cmd = @cmd + N''ALTER INDEX '' + QUOTENAME(i.name) + N'' ON '' + QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name) + N'' DISABLE;'' + CHAR(13)+CHAR(10) +FROM sys.indexes i +JOIN sys.tables t ON t.object_id=i.object_id +JOIN sys.schemas s ON s.schema_id=t.schema_id +WHERE t.is_ms_shipped=0 + AND i.type_desc=''NONCLUSTERED'' + AND i.is_primary_key=0 AND i.is_unique_constraint=0 + AND i.name IS NOT NULL; +EXEC sp_executesql @cmd; + +INSERT dbo.MigrationCopyLog(RunId, Step, Details) +VALUES (@RunId, N''TARGET_PREPARED'', NULL); +'; + +EXEC sp_executesql @PrepSql, N'@RunId uniqueidentifier', @RunId=@RunId; + +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +RAISERROR(CONCAT(@Now, N' | PREP | Disabled triggers, constraints (nocheck), NC indexes in target.'), @sev, 1) WITH NOWAIT; + +-------------------------------------------------------------------------------- +-- 7) Process tables (per table transaction + NOWAIT progress) +-------------------------------------------------------------------------------- +DECLARE @Schema nvarchar(128), @Table nvarchar(128); +DECLARE @Continue bit = 1; + +DECLARE cur CURSOR LOCAL FAST_FORWARD FOR + SELECT SchemaName, TableName + FROM #Tables + ORDER BY ProcessOrder; + +OPEN cur; +FETCH NEXT FROM cur INTO @Schema, @Table; + +WHILE @@FETCH_STATUS = 0 AND @Continue = 1 +BEGIN + -- Get current status (collation-safe) + DECLARE @Status varchar(20); + + DECLARE @GetStatusSql nvarchar(max) = N' +USE ' + @QTargetDb + N'; +SELECT @Status = Status +FROM dbo.MigrationState +WHERE TableSchema COLLATE DATABASE_DEFAULT = @Schema COLLATE DATABASE_DEFAULT + AND TableName COLLATE DATABASE_DEFAULT = @Table COLLATE DATABASE_DEFAULT; +'; + EXEC sp_executesql + @GetStatusSql, + N'@Schema sysname, @Table sysname, @Status varchar(20) OUTPUT', + @Schema=@Schema, @Table=@Table, @Status=@Status OUTPUT; + + IF @Mode = 'RESUME' AND @Status = 'DONE' + BEGIN + IF @Verbose = 1 + BEGIN + SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); + RAISERROR(CONCAT(@Now, N' | SKIP | ', @Schema, N'.', @Table, N' already DONE'), @sev, 1) WITH NOWAIT; + END + FETCH NEXT FROM cur INTO @Schema, @Table; + CONTINUE; + END + + SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); + RAISERROR(CONCAT(@Now, N' | TABLE_START | ', @Schema, N'.', @Table, N' (prev=', COALESCE(@Status,'?'), N')'), @sev, 1) WITH NOWAIT; + + BEGIN TRY + -- Start per-table transaction + EXEC (N'USE ' + @QTargetDb + N'; BEGIN TRAN;'); + + -- Mark RUNNING + DECLARE @MarkRunning nvarchar(max) = N' +USE ' + @QTargetDb + N'; +UPDATE dbo.MigrationState +SET Status=''RUNNING'', + StartedAt=SYSUTCDATETIME(), + FinishedAt=NULL, + RowsCopied=NULL, + LastError=NULL, + LastRunId=@RunId +WHERE TableSchema COLLATE DATABASE_DEFAULT = @Schema COLLATE DATABASE_DEFAULT + AND TableName COLLATE DATABASE_DEFAULT = @Table COLLATE DATABASE_DEFAULT; + +INSERT dbo.MigrationCopyLog(RunId, Step, Details) +VALUES (@RunId, N''TABLE_START'', CONCAT(@Schema, N''.'', @Table)); +'; + EXEC sp_executesql + @MarkRunning, + N'@RunId uniqueidentifier, @Schema sysname, @Table sysname', + @RunId=@RunId, @Schema=@Schema, @Table=@Table; + + -------------------------------------------------------------------- + -- Copy logic inside target context; returns OldRows/NewRows + -------------------------------------------------------------------- + DECLARE @CopySql nvarchar(max) = N' +USE ' + @QTargetDb + N'; + +DECLARE @SchemaName sysname = @Schema; +DECLARE @TableName sysname = @Table; +DECLARE @Tgt nvarchar(400) = QUOTENAME(@SchemaName) + N''.'' + QUOTENAME(@TableName); + +DECLARE @HasIdentity bit = 0; +DECLARE @IdentCol sysname = NULL; +DECLARE @IdentType sysname = NULL; + +DECLARE @ColList nvarchar(max); +DECLARE @SelList nvarchar(max); + +DECLARE @DelSql nvarchar(max); +DECLARE @ReseedSql nvarchar(max); +DECLARE @InsSql nvarchar(max); + +DECLARE @cntOld bigint, @cntNew bigint; + +-- Source rowcount (source DB) +SELECT @cntOld = COUNT_BIG(*) +FROM ' + @QSourceDb + N'.' + QUOTENAME(@Schema) + N'.' + QUOTENAME(@Table) + N'; + +-- Identity column in target (if any) +SELECT TOP(1) + @HasIdentity = 1, + @IdentCol = c.name, + @IdentType = ty.name +FROM sys.columns c +JOIN sys.tables t ON t.object_id=c.object_id +JOIN sys.schemas s ON s.schema_id=t.schema_id +JOIN sys.types ty ON ty.user_type_id=c.user_type_id +WHERE s.name=@SchemaName AND t.name=@TableName AND c.is_identity=1 +ORDER BY c.column_id; + +-- Build column lists based on TARGET types (so we know varchar/char) +;WITH tgt AS ( + SELECT c.name, c.column_id, c.is_computed, ty.name AS type_name + FROM sys.columns c + JOIN sys.tables t ON t.object_id=c.object_id + JOIN sys.schemas s ON s.schema_id=t.schema_id + JOIN sys.types ty ON ty.user_type_id=c.user_type_id + WHERE s.name=@SchemaName AND t.name=@TableName + AND c.is_computed=0 + AND ty.name NOT IN (''timestamp'',''rowversion'') +), +src AS ( + SELECT c.name + FROM ' + @QSourceDb + N'.sys.columns c + JOIN ' + @QSourceDb + N'.sys.tables t ON t.object_id=c.object_id + JOIN ' + @QSourceDb + N'.sys.schemas s ON s.schema_id=t.schema_id + WHERE s.name=@SchemaName AND t.name=@TableName +), +cols AS ( + SELECT t.name, t.column_id, t.type_name + FROM tgt t + JOIN src s + ON s.name COLLATE DATABASE_DEFAULT = t.name COLLATE DATABASE_DEFAULT +) +SELECT + @ColList = STRING_AGG(QUOTENAME(name), N'','') WITHIN GROUP (ORDER BY column_id), + @SelList = STRING_AGG( + CASE WHEN type_name IN (''varchar'',''char'') + THEN QUOTENAME(name) + N'' COLLATE DATABASE_DEFAULT'' + ELSE QUOTENAME(name) END, + N'','' + ) WITHIN GROUP (ORDER BY column_id) +FROM cols; + +IF @ColList IS NULL OR @SelList IS NULL + THROW 50010, ''No common insertable columns between source and target for this table.'', 1; + +-- DELETE target (rerunnable) +SET @DelSql = N''DELETE FROM '' + @Tgt + N'';''; +EXEC sp_executesql @DelSql; + +-- Reseed identity (if any) +IF @HasIdentity = 1 +BEGIN + SET @ReseedSql = N''DBCC CHECKIDENT ('' + @Tgt + N'', RESEED, 0) WITH NO_INFOMSGS;''; + EXEC sp_executesql @ReseedSql; +END + +-- Batched insert if identity int/bigint +IF @HasIdentity = 1 AND @IdentType IN (''int'',''bigint'') +BEGIN + DECLARE @min bigint, @max bigint, @start bigint, @end bigint; + DECLARE @MM nvarchar(max); + + SET @MM = N''SELECT @min = MIN(CONVERT(bigint,'' + QUOTENAME(@IdentCol) + N'')), + @max = MAX(CONVERT(bigint,'' + QUOTENAME(@IdentCol) + N'')) + FROM ' + @QSourceDb + N'.'' + QUOTENAME(@SchemaName) + N''.'' + QUOTENAME(@TableName) + N'';''; + EXEC sp_executesql @MM, N''@min bigint OUTPUT, @max bigint OUTPUT'', @min=@min OUTPUT, @max=@max OUTPUT; + + IF @min IS NOT NULL AND @max IS NOT NULL + BEGIN + SET @start = @min; + WHILE @start <= @max + BEGIN + SET @end = @start + @BatchSize - 1; + + SET @InsSql = N''''; + IF @HasIdentity = 1 + SET @InsSql = @InsSql + N''SET IDENTITY_INSERT '' + @Tgt + N'' ON;'' + CHAR(13)+CHAR(10); + + SET @InsSql = @InsSql + + N''INSERT INTO '' + @Tgt + N'' ('' + @ColList + N'')'' + CHAR(13)+CHAR(10) + + N''SELECT '' + @SelList + CHAR(13)+CHAR(10) + + N''FROM ' + @QSourceDb + N'.'' + QUOTENAME(@SchemaName) + N''.'' + QUOTENAME(@TableName) + CHAR(13)+CHAR(10) + + N''WHERE '' + QUOTENAME(@IdentCol) + N'' >= '' + CAST(@start AS nvarchar(30)) + + N'' AND '' + QUOTENAME(@IdentCol) + N'' <= '' + CAST(@end AS nvarchar(30)) + N'';'' + CHAR(13)+CHAR(10); + + IF @HasIdentity = 1 + SET @InsSql = @InsSql + N''SET IDENTITY_INSERT '' + @Tgt + N'' OFF;'' + CHAR(13)+CHAR(10); + + EXEC sp_executesql @InsSql; + + SET @start = @end + 1; + END + END +END +ELSE +BEGIN + -- Non-batched insert + SET @InsSql = N''''; + IF @HasIdentity = 1 + SET @InsSql = @InsSql + N''SET IDENTITY_INSERT '' + @Tgt + N'' ON;'' + CHAR(13)+CHAR(10); + + SET @InsSql = @InsSql + + N''INSERT INTO '' + @Tgt + N'' ('' + @ColList + N'')'' + CHAR(13)+CHAR(10) + + N''SELECT '' + @SelList + CHAR(13)+CHAR(10) + + N''FROM ' + @QSourceDb + N'.'' + QUOTENAME(@SchemaName) + N''.'' + QUOTENAME(@TableName) + N'';'' + CHAR(13)+CHAR(10); + + IF @HasIdentity = 1 + SET @InsSql = @InsSql + N''SET IDENTITY_INSERT '' + @Tgt + N'' OFF;'' + CHAR(13)+CHAR(10); + + EXEC sp_executesql @InsSql; +END + +-- Target rowcount +SELECT @cntNew = COUNT_BIG(*) FROM ' + QUOTENAME(@Schema) + N'.' + QUOTENAME(@Table) + N'; + +SELECT @cntOld AS OldRows, @cntNew AS NewRows; +'; + + IF OBJECT_ID('tempdb..#Cnt') IS NOT NULL DROP TABLE #Cnt; + CREATE TABLE #Cnt(OldRows bigint, NewRows bigint); + + INSERT INTO #Cnt(OldRows, NewRows) + EXEC sp_executesql @CopySql, + N'@Schema sysname, @Table sysname, @BatchSize int', + @Schema=@Schema, @Table=@Table, @BatchSize=@BatchSize; + + DECLARE @OldRows bigint, @NewRows bigint; + SELECT TOP(1) @OldRows=OldRows, @NewRows=NewRows FROM #Cnt; + + -- Mark DONE + DECLARE @MarkDone nvarchar(max) = N' +USE ' + @QTargetDb + N'; +UPDATE dbo.MigrationState +SET Status=''DONE'', + FinishedAt=SYSUTCDATETIME(), + RowsCopied=@Rows, + LastError=NULL, + LastRunId=@RunId +WHERE TableSchema COLLATE DATABASE_DEFAULT = @Schema COLLATE DATABASE_DEFAULT + AND TableName COLLATE DATABASE_DEFAULT = @Table COLLATE DATABASE_DEFAULT; + +INSERT dbo.MigrationCopyLog(RunId, Step, Details) +VALUES (@RunId, N''TABLE_DONE'', CONCAT(@Schema, N''.'', @Table, N''; OldRows='', @Old, N''; NewRows='', @New)); +'; + EXEC sp_executesql + @MarkDone, + N'@RunId uniqueidentifier, @Schema sysname, @Table sysname, @Rows bigint, @Old bigint, @New bigint', + @RunId=@RunId, @Schema=@Schema, @Table=@Table, @Rows=@NewRows, @Old=@OldRows, @New=@NewRows; + + -- Commit this table + EXEC (N'USE ' + @QTargetDb + N'; COMMIT;'); + + SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); + RAISERROR(CONCAT(@Now, N' | TABLE_DONE | ', @Schema, N'.', @Table, N' | Old=', @OldRows, N' New=', @NewRows), @sev, 1) WITH NOWAIT; + + END TRY + BEGIN CATCH + DECLARE @Err nvarchar(max) = + CONCAT(N'Error ', ERROR_NUMBER(), N': ', ERROR_MESSAGE(), N' (Line ', ERROR_LINE(), N')'); + + -- Rollback if open + EXEC (N'USE ' + @QTargetDb + N'; IF XACT_STATE() <> 0 ROLLBACK;'); + + -- Mark FAILED + DECLARE @MarkFail nvarchar(max) = N' +USE ' + @QTargetDb + N'; +UPDATE dbo.MigrationState +SET Status=''FAILED'', + FinishedAt=SYSUTCDATETIME(), + LastError=@Err, + LastRunId=@RunId +WHERE TableSchema COLLATE DATABASE_DEFAULT = @Schema COLLATE DATABASE_DEFAULT + AND TableName COLLATE DATABASE_DEFAULT = @Table COLLATE DATABASE_DEFAULT; + +INSERT dbo.MigrationCopyLog(RunId, Step, Details) +VALUES (@RunId, N''TABLE_FAILED'', CONCAT(@Schema, N''.'', @Table, N''; '', @Err)); +'; + EXEC sp_executesql + @MarkFail, + N'@RunId uniqueidentifier, @Schema sysname, @Table sysname, @Err nvarchar(max)', + @RunId=@RunId, @Schema=@Schema, @Table=@Table, @Err=@Err; + + SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); + RAISERROR(CONCAT(@Now, N' | TABLE_FAILED | ', @Schema, N'.', @Table, N' | ', @Err), @sev, 1) WITH NOWAIT; + + IF @StopOnError = 1 + SET @Continue = 0; + END CATCH; + + FETCH NEXT FROM cur INTO @Schema, @Table; +END + +CLOSE cur; +DEALLOCATE cur; + +IF @Continue = 0 +BEGIN + SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); + RAISERROR(CONCAT(@Now, N' | ABORT | Stopped due to StopOnError=1. See dbo.MigrationState/MigrationCopyLog.'), @sev, 1) WITH NOWAIT; + THROW 50050, 'Run aborted due to table error (see dbo.MigrationState / dbo.MigrationCopyLog).', 1; +END + +-------------------------------------------------------------------------------- +-- 8) Finalize target: rebuild NC indexes, enable constraints WITH CHECK, enable triggers +-------------------------------------------------------------------------------- +DECLARE @FinalizeSql nvarchar(max) = N' +USE ' + @QTargetDb + N'; +DECLARE @cmd nvarchar(max); + +-- rebuild nonclustered indexes +SET @cmd = N''''; +SELECT @cmd = @cmd + N''ALTER INDEX '' + QUOTENAME(i.name) + N'' ON '' + QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name) + N'' REBUILD;'' + CHAR(13)+CHAR(10) +FROM sys.indexes i +JOIN sys.tables t ON t.object_id=i.object_id +JOIN sys.schemas s ON s.schema_id=t.schema_id +WHERE t.is_ms_shipped=0 + AND i.type_desc=''NONCLUSTERED'' + AND i.is_primary_key=0 AND i.is_unique_constraint=0 + AND i.name IS NOT NULL; +EXEC sp_executesql @cmd; + +-- enable constraints with validation +SET @cmd = N''''; +SELECT @cmd = @cmd + N''ALTER TABLE '' + QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name) + N'' WITH CHECK CHECK CONSTRAINT ALL;'' + CHAR(13)+CHAR(10) +FROM sys.tables t JOIN sys.schemas s ON s.schema_id=t.schema_id +WHERE t.is_ms_shipped=0; +EXEC sp_executesql @cmd; + +-- enable triggers +SET @cmd = N''''; +SELECT @cmd = @cmd + N''ENABLE TRIGGER ALL ON '' + QUOTENAME(s.name) + N''.'' + QUOTENAME(t.name) + N'';'' + CHAR(13)+CHAR(10) +FROM sys.tables t JOIN sys.schemas s ON s.schema_id=t.schema_id +WHERE t.is_ms_shipped=0; +EXEC sp_executesql @cmd; + +INSERT dbo.MigrationCopyLog(RunId, Step, Details) +VALUES (@RunId, N''RUN_DONE'', NULL); +'; +EXEC sp_executesql @FinalizeSql, N'@RunId uniqueidentifier', @RunId=@RunId; + +SET @Now = CONVERT(nvarchar(30), SYSDATETIME(), 121); +RAISERROR(CONCAT(@Now, N' | DONE | Migration finished successfully. Check ', @TargetDb, N'.dbo.MigrationState / MigrationCopyLog'), @sev, 1) WITH NOWAIT; +GO