1356 words
7 minutes
SQL Voodoo Black Magic

Sometimes you want to be able to dynamically construct table definitions to perform different actions, such as creating a copy of a table on a different database, compare table schema, sync table data where the tables are not guarenteed to have the same schema, generate update, merge, select and insert statements and not have to keep adjusting the code as the table schema drifts.

Below we will go through a few of these scenarios, but first lets define the “black magic” that the rest of the examples will build upon.

Black Magic#

These are the exact same query but produce a #Source and #Destination temp tables that the examples will use.

Select Source Schema#

DECLARE
@TableName SYSNAME = 'MyTable',
@TableSchema SYSNAME = 'dbo'
IF OBJECT_ID('tempdb..#Source') IS NOT NULL
DROP TABLE #Source
SELECT
[ColumnId] = sc.[column_id],
[ColumnName] = sc.[name] COLLATE DATABASE_DEFAULT,
-- adjust timestamp type to varbinary
[Type] = CASE WHEN st.[user_type_id] = 189 THEN 'varbinary' ELSE st.[name] END COLLATE DATABASE_DEFAULT,
-- adjust nvarchar length to half what is reported since it takes 2 bytes to store unicode, and adjust timestamp to the translated varbinary length
[Length] = CASE WHEN sc.[max_length] = -1 THEN -1 WHEN st.[user_type_id] = 231 THEN sc.[max_length] / 2 WHEN st.[user_type_id] = 189 THEN 85 ELSE sc.[max_length] END,
[Precision] = sc.[precision],
[Scale] = sc.[scale],
[IsNullable] = sc.[is_nullable]
INTO #Source
FROM [sys].[objects] so
JOIN [sys].[columns] sc
ON so.[object_id] = sc.[object_id]
JOIN [sys].[types] st
ON sc.[user_type_id] = st.[user_type_id]
JOIN [sys].[schemas] ss
ON so.[schema_id] = ss.[schema_id]
WHERE so.[name] = @TableName AND ss.[name] = @TableSchema

Select Destination Schema#

DECLARE
@TableName SYSNAME = 'MyTable',
@TableSchema SYSNAME = 'dbo'
IF OBJECT_ID('tempdb..#Destination') IS NOT NULL
DROP TABLE #Destination
SELECT
[ColumnId] = sc.[column_id],
[ColumnName] = sc.[name] COLLATE DATABASE_DEFAULT,
-- adjust timestamp type to varbinary
[Type] = CASE WHEN st.[user_type_id] = 189 THEN 'varbinary' ELSE st.[name] END COLLATE DATABASE_DEFAULT,
-- adjust nvarchar length to half what is reported since it takes 2 bytes to store unicode, and adjust timestamp to the translated varbinary length
[Length] = CASE WHEN sc.[max_length] = -1 THEN -1 WHEN st.[user_type_id] = 231 THEN sc.[max_length] / 2 WHEN st.[user_type_id] = 189 THEN 85 ELSE sc.[max_length] END,
[Precision] = sc.[precision],
[Scale] = sc.[scale],
[IsNullable] = sc.[is_nullable]
INTO #Destination
FROM [sys].[objects] so
JOIN [sys].[columns] sc
ON so.[object_id] = sc.[object_id]
JOIN [sys].[types] st
ON sc.[user_type_id] = st.[user_type_id]
JOIN [sys].[schemas] ss
ON so.[schema_id] = ss.[schema_id]
WHERE so.[name] = @TableName AND ss.[name] = @TableSchema

Example — Create Table Copy#

Create a copy of the table using the source schema information possibly injecting extra columns in the copy.

DECLARE
@TableName SYSNAME = 'MyTable',
@TableSchema SYSNAME = 'dbo',
@IsDebug BIT = 0
DECLARE
@DataType NVARCHAR(200),
@Length INT,
@Precision INT,
@Scale INT,
@IsNullable INT,
@ColumnName NVARCHAR(200),
@Statement NVARCHAR(MAX) = ''
DECLARE x CURSOR FAST_FORWARD FOR
SELECT [ColumnName], [Type], [Length], [Precision], [Scale], [IsNullable]
FROM #Source
ORDER BY [ColumnId]
OPEN x
FETCH NEXT FROM x INTO @ColumnName, @DataType, @Length, @Precision, @Scale, @IsNullable
WHILE @@FETCH_STATUS = 0
BEGIN
DECLARE @NullClause NVARCHAR(10) = ' NULL'
IF @IsNullable = 0
BEGIN
SELECT @NullClause = ' NOT NULL'
END
IF @DataType = 'timestamp'
BEGIN
SELECT @DataType = 'varbinary'
SELECT @Length = 85
END
IF @DataType IN('char','varchar','binary','varbinary','nvarchar','nchar')
BEGIN
SELECT @Statement = @Statement + '[' + @ColumnName + '] ' + @DataType + '(' + ISNULL(NULLIF(CONVERT(NVARCHAR,@Length), '-1'), 'MAX') + ')' + @NullClause + ','
END
ELSE IF @DataType IN('decimal','numeric')
BEGIN
SELECT @Statement = @Statement + '[' + @ColumnName + '] ' + @DataType + '(' + CONVERT(NVARCHAR,@Precision) + ',' + CONVERT(NVARCHAR,@Scale) + ')' + @NullClause + ','
END
ELSE
BEGIN
SELECT @Statement = @Statement + '[' + @ColumnName + '] ' + @DataType + @NullClause + ','
END
FETCH NEXT FROM x INTO @ColumnName, @DataType, @Length, @Precision, @Scale, @IsNullable
END
CLOSE x
DEALLOCATE x
SELECT @Statement = SUBSTRING(@Statement, 1, LEN(@Statement) - 1)
DECLARE @Sql NVARCHAR(MAX) = ''
SELECT @Sql = '
CREATE TABLE [{0}].[{1}](
{2}
)'
SELECT @Sql = REPLACE(@Sql, '{0}', @TableSchema)
SELECT @Sql = REPLACE(@Sql, '{1}', @TableName)
SELECT @Sql = REPLACE(@Sql, '{2}', @Statement)
IF @IsDebug = 1
PRINT (@Sql)
IF @IsDebug = 0
EXEC (@Sql)

Example — Schema Diff#

Below are a few ways you can use this information to find;

  • columns that have changed
  • columns that are missing from the destination
  • columns that have been removed from the source and not the destination
  • columns that both tables have in common
IF OBJECT_ID('tempdb..#CHANGED_COLUMNS') IS NOT NULL
DROP TABLE #CHANGED_COLUMNS
SELECT
[ColumnName],
[Type],
[Length],
[Precision],
[Scale]
INTO #CHANGED_COLUMNS
FROM #Source
EXCEPT
SELECT
[ColumnName],
[Type],
[Length],
[Precision],
[Scale]
FROM #Destination
IF OBJECT_ID('tempdb..#MISSING_COLUMNS') IS NOT NULL
DROP TABLE #MISSING_COLUMNS
SELECT
[ColumnName]
INTO #MISSING_COLUMNS
FROM #Source
EXCEPT
SELECT
[ColumnName]
FROM #Destination
IF OBJECT_ID('tempdb..#DELETED_COLUMNS') IS NOT NULL
DROP TABLE #DELETED_COLUMNS
SELECT
[ColumnName]
INTO #DELETED_COLUMNS
FROM #Destination
EXCEPT
SELECT
[ColumnName]
FROM #SOURCE
IF OBJECT_ID('tempdb..#SHARED_COLUMNS') IS NOT NULL
DROP TABLE #SHARED_COLUMNS
SELECT
[ColumnName]
INTO #SHARED_COLUMNS
FROM #Source
INTERSECT
SELECT
[ColumnName]
FROM #Destination

Example — Sync Destination Schema with Source#

Here we will cursor over the columns that are missing and either create a series of ALTER TABLE statements to bring the destination table in sync with the source.

DECLARE
@TableName SYSNAME = 'MyTable',
@TableSchema SYSNAME = 'dbo',
@IsDebug BIT = 0
IF OBJECT_ID('tempdb..#CHANGED_COLUMNS') IS NOT NULL
DROP TABLE #CHANGED_COLUMNS
SELECT
[ColumnName],
[Type],
[Length],
[Precision],
[Scale]
INTO #CHANGED_COLUMNS
FROM #Source
EXCEPT
SELECT
[ColumnName],
[Type],
[Length],
[Precision],
[Scale]
FROM #Destination
DECLARE
@Statement NVARCHAR(MAX) = '',
@AlterColumnStatement NVARCHAR(MAX) = '',
@ColumnName NVARCHAR(200),
@Sql NVARCHAR(MAX) = ''
IF EXISTS (SELECT 1 FROM #CHANGED_COLUMNS)
BEGIN
DECLARE
@DataType NVARCHAR(200),
@Length INT,
@Precision INT,
@Scale INT
DECLARE
@HasExistingColumns INT = 0,
@HasNewColumns INT = 0
DECLARE x CURSOR FOR
SELECT [ColumnName], [Type], [Length], [Precision], [Scale]
FROM #COLUMNS
ORDER BY 1
OPEN x
FETCH NEXT FROM x INTO @ColumnName, @DataType, @Length, @Precision, @Scale
WHILE @@FETCH_STATUS = 0
BEGIN
DECLARE @Part NVARCHAR(MAX)
DECLARE @AlterColumnStub NVARCHAR(MAX) = '
ALTER TABLE [{2}].[{0}]
ALTER COLUMN {1}'
DECLARE @ColumnExists INT = 0
IF EXISTS(SELECT 1 FROM #Destination WHERE [ColumnName] = @ColumnName)
BEGIN
SELECT @ColumnExists = 1
SELECT @HasExistingColumns = 1
END
ELSE
BEGIN
SELECT @HasNewColumns = 1
END
IF @DataType = 'timestamp'
BEGIN
SELECT @DataType = 'varbinary'
SELECT @Length = 85
END
IF @DataType IN('char','varchar','binary','varbinary','nvarchar','nchar')
BEGIN
SELECT @Part = '[' + @ColumnName + '] ' + @DataType + '(' + ISNULL(NULLIF(CONVERT(NVARCHAR,@Length), '-1'), 'MAX') + ')'
IF(@ColumnExists = 1)
BEGIN
SELECT @AlterColumnStatement = @AlterColumnStatement + REPLACE(@AlterColumnStub, '{1}', @Part)
END
ELSE
BEGIN
SELECT @Statement = @Statement + @Part + ','
END
END
ELSE IF @DataType IN('decimal','numeric')
BEGIN
SELECT @Part = '[' + @ColumnName + '] ' + @DataType + '(' + CONVERT(NVARCHAR,@Precision) + ',' + CONVERT(NVARCHAR,@Scale) + ')'
IF(@ColumnExists = 1)
BEGIN
SELECT @AlterColumnStatement = @AlterColumnStatement + REPLACE(@AlterColumnStub, '{1}', @Part)
END
ELSE
BEGIN
SELECT @Statement = @Statement + @Part + ','
END
END
ELSE
BEGIN
SELECT @Part = '[' + @ColumnName + '] ' + @DataType
IF(@ColumnExists = 1)
BEGIN
SELECT @AlterColumnStatement = @AlterColumnStatement + REPLACE(@AlterColumnStub, '{1}', @Part)
END
ELSE
BEGIN
SELECT @Statement = @Statement + @Part + ','
END
END
FETCH NEXT FROM x INTO @ColumnName, @DataType, @Length, @Precision, @Scale
END
CLOSE x
DEALLOCATE x
IF (LEN(@Statement) > 1)
BEGIN
SELECT @Statement = SUBSTRING(@Statement, 1, LEN(@Statement) - 1)
END
-- determine if is alter or create statement
IF OBJECT_ID(@EntityName) IS NOT NULL
BEGIN
IF (@HasExistingColumns = 1)
BEGIN
SELECT @Sql += @AlterColumnStatement
END
IF (@HasNewColumns = 1)
BEGIN
SELECT @Sql += '
ALTER TABLE [{2}].[{0}]
ADD {1}'
END
END
SELECT @Sql = REPLACE(@Sql, '{0}', @TableName)
SELECT @Sql = REPLACE(@Sql, '{1}', @Statement)
SELECT @Sql = REPLACE(@Sql, '{2}', @TableSchema)
IF @IsDebug = 1
PRINT (@Sql)
IF @IsDebug = 0
EXEC (@Sql)
END

Example — Generate Statement Layouts#

Here we generate layouts that can be used in SELECT, INSERT and UPDATE statements assuming the source table is aliased as [source].

IF OBJECT_ID('tempdb..#SHARED_COLUMNS') IS NOT NULL
DROP TABLE #SHARED_COLUMNS
SELECT
[ColumnName]
INTO #SHARED_COLUMNS
FROM #Source
INTERSECT
SELECT
[ColumnName]
FROM #Destination
-- generate layout --
DECLARE
@ColumnFormat NVARCHAR(MAX) = '',
@UpdateFormat NVARCHAR(MAX) = '',
@ValuesFormat NVARCHAR(MAX) = ''
SELECT
@ColumnFormat = STUFF((SELECT ', [' + [ColumnName] + ']' FROM #SHARED_COLUMNS ORDER BY [ColumnName] FOR XML PATH('')), 1, 2, ''),
@UpdateFormat = STUFF((SELECT ', [' + [ColumnName] + '] = [source].[' + [ColumnName] + ']' FROM #SHARED_COLUMNS ORDER BY [ColumnName] FOR XML PATH('')), 1, 2, ''),
@ValuesFormat = STUFF((SELECT ', [source].[' + [ColumnName] + ']' FROM #SHARED_COLUMNS ORDER BY [ColumnName] FOR XML PATH('')), 1, 2, '')
PRINT @ColumnFormat
PRINT @UpdateFormat
PRINT @ValuesFormat

~ SK

SQL Voodoo Black Magic
https://www.kichka.dev/posts/sql-voodoo-blackmagic/
Author
Stephen Kichka
Published at
2025-12-27
License
CC BY-NC-SA 4.0