fix(sqlserver): complete schema diff DDL support
This commit is contained in:
1 parent
2b7d353313
commit
a4bf8442d7
13 files changed
+3301
-282
No files matched your search
+2191
-211
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,547 @@
|
||||
//! Runtime scripting for SQL Server column dependencies.
|
||||
//!
|
||||
//! SQL Server refuses `ALTER COLUMN` while a number of catalog objects depend
|
||||
//! on the column. The schema diff model intentionally omits unchanged
|
||||
//! objects, so the only complete source for those dependencies is the target
|
||||
//! database itself. This helper snapshots the supported definitions before
|
||||
//! dropping anything, performs the caller-provided alteration, and restores
|
||||
//! the objects in dependency order.
|
||||
|
||||
const DEPENDENCY_CAPTURE_AND_DROP_SQL: &str = r#"
|
||||
DECLARE @dbx_qualified_table nvarchar(517) =
|
||||
QUOTENAME(OBJECT_SCHEMA_NAME(@dbx_object_id)) + N'.' + QUOTENAME(OBJECT_NAME(@dbx_object_id));
|
||||
|
||||
DECLARE @dbx_unsupported_index sysname;
|
||||
SELECT TOP (1) @dbx_unsupported_index = idx.name
|
||||
FROM sys.indexes AS idx
|
||||
WHERE idx.object_id = @dbx_object_id
|
||||
AND idx.index_id > 0
|
||||
AND idx.name IS NOT NULL
|
||||
AND idx.is_hypothetical = 0
|
||||
AND idx.type NOT IN (1, 2)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS ic
|
||||
WHERE ic.object_id = idx.object_id
|
||||
AND ic.index_id = idx.index_id
|
||||
AND ic.column_id = @dbx_column_id
|
||||
);
|
||||
IF @dbx_unsupported_index IS NOT NULL
|
||||
BEGIN
|
||||
DECLARE @dbx_unsupported_index_message nvarchar(2048) =
|
||||
N'DBX cannot safely preserve affected SQL Server index ' + QUOTENAME(@dbx_unsupported_index) +
|
||||
N' because its index type is not a supported rowstore type.';
|
||||
;THROW 50001, @dbx_unsupported_index_message, 1;
|
||||
END;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes AS idx
|
||||
JOIN sys.data_spaces AS data_space ON data_space.data_space_id = idx.data_space_id
|
||||
WHERE idx.object_id = @dbx_object_id
|
||||
AND idx.type IN (1, 2)
|
||||
AND idx.is_hypothetical = 0
|
||||
AND data_space.type = N'PS'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS affected_ic
|
||||
WHERE affected_ic.object_id = idx.object_id
|
||||
AND affected_ic.index_id = idx.index_id
|
||||
AND affected_ic.column_id = @dbx_column_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS partition_ic
|
||||
WHERE partition_ic.object_id = idx.object_id
|
||||
AND partition_ic.index_id = idx.index_id
|
||||
AND partition_ic.partition_ordinal > 0
|
||||
)
|
||||
)
|
||||
BEGIN
|
||||
;THROW 50002, 'DBX could not resolve the partition column for an affected SQL Server index.', 1;
|
||||
END;
|
||||
|
||||
DECLARE @dbx_started_transaction bit = 0;
|
||||
DECLARE @dbx_has_savepoint bit = 0;
|
||||
|
||||
BEGIN TRY
|
||||
IF @@TRANCOUNT = 0
|
||||
BEGIN
|
||||
BEGIN TRANSACTION;
|
||||
SET @dbx_started_transaction = 1;
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
SAVE TRANSACTION dbx_alter_column_dependencies;
|
||||
SET @dbx_has_savepoint = 1;
|
||||
END;
|
||||
|
||||
DECLARE @dbx_dependencies TABLE (
|
||||
dependency_id int IDENTITY(1, 1) NOT NULL,
|
||||
drop_order tinyint NOT NULL,
|
||||
create_order tinyint NOT NULL,
|
||||
drop_sql nvarchar(max) NOT NULL,
|
||||
create_sql nvarchar(max) NOT NULL
|
||||
);
|
||||
|
||||
-- Foreign keys are captured first and dropped before key constraints. The
|
||||
-- predicate covers outgoing, inbound, composite, and self-referencing FKs.
|
||||
INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql)
|
||||
SELECT
|
||||
10,
|
||||
50,
|
||||
N'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(fk.parent_object_id)) + N'.' +
|
||||
QUOTENAME(OBJECT_NAME(fk.parent_object_id)) + N' DROP CONSTRAINT ' + QUOTENAME(fk.name) + N';',
|
||||
N'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(fk.parent_object_id)) + N'.' +
|
||||
QUOTENAME(OBJECT_NAME(fk.parent_object_id)) +
|
||||
CASE WHEN fk.is_not_trusted = 1 THEN N' WITH NOCHECK' ELSE N' WITH CHECK' END +
|
||||
N' ADD CONSTRAINT ' + QUOTENAME(fk.name) + N' FOREIGN KEY (' +
|
||||
STUFF((
|
||||
SELECT N', ' + QUOTENAME(parent_column.name)
|
||||
FROM sys.foreign_key_columns AS fkc
|
||||
JOIN sys.columns AS parent_column
|
||||
ON parent_column.object_id = fkc.parent_object_id
|
||||
AND parent_column.column_id = fkc.parent_column_id
|
||||
WHERE fkc.constraint_object_id = fk.object_id
|
||||
ORDER BY fkc.constraint_column_id
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N') REFERENCES ' +
|
||||
QUOTENAME(OBJECT_SCHEMA_NAME(fk.referenced_object_id)) + N'.' +
|
||||
QUOTENAME(OBJECT_NAME(fk.referenced_object_id)) + N' (' +
|
||||
STUFF((
|
||||
SELECT N', ' + QUOTENAME(referenced_column.name)
|
||||
FROM sys.foreign_key_columns AS fkc
|
||||
JOIN sys.columns AS referenced_column
|
||||
ON referenced_column.object_id = fkc.referenced_object_id
|
||||
AND referenced_column.column_id = fkc.referenced_column_id
|
||||
WHERE fkc.constraint_object_id = fk.object_id
|
||||
ORDER BY fkc.constraint_column_id
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' +
|
||||
CASE WHEN fk.delete_referential_action_desc = N'NO_ACTION' THEN N''
|
||||
ELSE N' ON DELETE ' + REPLACE(fk.delete_referential_action_desc, N'_', N' ') END +
|
||||
CASE WHEN fk.update_referential_action_desc = N'NO_ACTION' THEN N''
|
||||
ELSE N' ON UPDATE ' + REPLACE(fk.update_referential_action_desc, N'_', N' ') END +
|
||||
CASE WHEN fk.is_not_for_replication = 1 THEN N' NOT FOR REPLICATION' ELSE N'' END + N'; ' +
|
||||
N'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(fk.parent_object_id)) + N'.' +
|
||||
QUOTENAME(OBJECT_NAME(fk.parent_object_id)) +
|
||||
CASE WHEN fk.is_disabled = 1 THEN N' NOCHECK' ELSE N' CHECK' END +
|
||||
N' CONSTRAINT ' + QUOTENAME(fk.name) + N';'
|
||||
FROM sys.foreign_keys AS fk
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.foreign_key_columns AS fkc
|
||||
WHERE fkc.constraint_object_id = fk.object_id
|
||||
AND ((fkc.parent_object_id = @dbx_object_id AND fkc.parent_column_id = @dbx_column_id)
|
||||
OR (fkc.referenced_object_id = @dbx_object_id AND fkc.referenced_column_id = @dbx_column_id))
|
||||
);
|
||||
|
||||
-- parent_column_id identifies column-level checks. Table-level checks use
|
||||
-- expression dependencies to avoid dropping unrelated constraints.
|
||||
INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql)
|
||||
SELECT
|
||||
20,
|
||||
40,
|
||||
N'ALTER TABLE ' + @dbx_qualified_table + N' DROP CONSTRAINT ' + QUOTENAME(cc.name) + N';',
|
||||
N'ALTER TABLE ' + @dbx_qualified_table +
|
||||
CASE WHEN cc.is_not_trusted = 1 THEN N' WITH NOCHECK' ELSE N' WITH CHECK' END +
|
||||
N' ADD CONSTRAINT ' + QUOTENAME(cc.name) + N' CHECK ' +
|
||||
CASE WHEN cc.is_not_for_replication = 1 THEN N'NOT FOR REPLICATION ' ELSE N'' END +
|
||||
cc.definition + N'; ALTER TABLE ' + @dbx_qualified_table +
|
||||
CASE WHEN cc.is_disabled = 1 THEN N' NOCHECK' ELSE N' CHECK' END +
|
||||
N' CONSTRAINT ' + QUOTENAME(cc.name) + N';'
|
||||
FROM sys.check_constraints AS cc
|
||||
WHERE cc.parent_object_id = @dbx_object_id
|
||||
AND (cc.parent_column_id = @dbx_column_id OR EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.sql_expression_dependencies AS sed
|
||||
WHERE sed.referencing_id = cc.object_id
|
||||
AND sed.referenced_id = @dbx_object_id
|
||||
AND sed.referenced_minor_id = @dbx_column_id
|
||||
));
|
||||
|
||||
-- Index-owned statistics disappear with their index. Only independent,
|
||||
-- user-created statistics need an explicit drop and recreation.
|
||||
INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql)
|
||||
SELECT
|
||||
30,
|
||||
30,
|
||||
N'DROP STATISTICS ' + @dbx_qualified_table + N'.' + QUOTENAME(stats.name) + N';',
|
||||
N'CREATE STATISTICS ' + QUOTENAME(stats.name) + N' ON ' + @dbx_qualified_table + N' (' +
|
||||
STUFF((
|
||||
SELECT N', ' + QUOTENAME(stats_column.name)
|
||||
FROM sys.stats_columns AS sc
|
||||
JOIN sys.columns AS stats_column
|
||||
ON stats_column.object_id = sc.object_id
|
||||
AND stats_column.column_id = sc.column_id
|
||||
WHERE sc.object_id = stats.object_id
|
||||
AND sc.stats_id = stats.stats_id
|
||||
ORDER BY sc.stats_column_id
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' +
|
||||
CASE WHEN stats.has_filter = 1 THEN N' WHERE ' + stats.filter_definition ELSE N'' END +
|
||||
CASE WHEN stats.no_recompute = 1 THEN N' WITH NORECOMPUTE' ELSE N'' END + N';'
|
||||
FROM sys.stats AS stats
|
||||
WHERE stats.object_id = @dbx_object_id
|
||||
AND stats.user_created = 1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.stats_columns AS sc
|
||||
WHERE sc.object_id = stats.object_id
|
||||
AND sc.stats_id = stats.stats_id
|
||||
AND sc.column_id = @dbx_column_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes AS matching_index
|
||||
WHERE matching_index.object_id = stats.object_id
|
||||
AND matching_index.index_id = stats.stats_id
|
||||
);
|
||||
|
||||
-- Ordinary clustered/nonclustered rowstore indexes. Explicit key order,
|
||||
-- INCLUDE columns, filters, durable catalog options, data space, and the
|
||||
-- disabled state are reconstructed from the live target catalog.
|
||||
INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql)
|
||||
SELECT
|
||||
40,
|
||||
20,
|
||||
N'DROP INDEX ' + QUOTENAME(idx.name) + N' ON ' + @dbx_qualified_table + N';',
|
||||
N'CREATE ' + CASE WHEN idx.is_unique = 1 THEN N'UNIQUE ' ELSE N'' END + idx.type_desc +
|
||||
N' INDEX ' + QUOTENAME(idx.name) + N' ON ' + @dbx_qualified_table + N' (' +
|
||||
STUFF((
|
||||
SELECT N', ' + QUOTENAME(index_column.name) +
|
||||
CASE WHEN ic.is_descending_key = 1 THEN N' DESC' ELSE N' ASC' END
|
||||
FROM sys.index_columns AS ic
|
||||
JOIN sys.columns AS index_column
|
||||
ON index_column.object_id = ic.object_id
|
||||
AND index_column.column_id = ic.column_id
|
||||
WHERE ic.object_id = idx.object_id
|
||||
AND ic.index_id = idx.index_id
|
||||
AND ic.key_ordinal > 0
|
||||
ORDER BY ic.key_ordinal
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' +
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS include_check
|
||||
WHERE include_check.object_id = idx.object_id
|
||||
AND include_check.index_id = idx.index_id
|
||||
AND include_check.is_included_column = 1
|
||||
) THEN N' INCLUDE (' + STUFF((
|
||||
SELECT N', ' + QUOTENAME(include_column.name)
|
||||
FROM sys.index_columns AS include_ic
|
||||
JOIN sys.columns AS include_column
|
||||
ON include_column.object_id = include_ic.object_id
|
||||
AND include_column.column_id = include_ic.column_id
|
||||
WHERE include_ic.object_id = idx.object_id
|
||||
AND include_ic.index_id = idx.index_id
|
||||
AND include_ic.is_included_column = 1
|
||||
ORDER BY include_ic.index_column_id
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' ELSE N'' END +
|
||||
CASE WHEN idx.has_filter = 1 THEN N' WHERE ' + idx.filter_definition ELSE N'' END +
|
||||
N' WITH (PAD_INDEX = ' + CASE WHEN idx.is_padded = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
CASE WHEN idx.fill_factor > 0
|
||||
THEN N', FILLFACTOR = ' + CONVERT(nvarchar(3), idx.fill_factor)
|
||||
ELSE N'' END +
|
||||
N', IGNORE_DUP_KEY = ' + CASE WHEN idx.ignore_dup_key = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', STATISTICS_NORECOMPUTE = ' + CASE WHEN index_stats.no_recompute = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', ALLOW_ROW_LOCKS = ' + CASE WHEN idx.allow_row_locks = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', ALLOW_PAGE_LOCKS = ' + CASE WHEN idx.allow_page_locks = 1 THEN N'ON' ELSE N'OFF' END + N')' +
|
||||
CASE WHEN data_space.type = N'FG' THEN N' ON ' + QUOTENAME(data_space.name)
|
||||
WHEN data_space.type = N'PS' THEN N' ON ' + QUOTENAME(data_space.name) + N' (' +
|
||||
QUOTENAME((
|
||||
SELECT TOP (1) partition_column.name
|
||||
FROM sys.index_columns AS partition_ic
|
||||
JOIN sys.columns AS partition_column
|
||||
ON partition_column.object_id = partition_ic.object_id
|
||||
AND partition_column.column_id = partition_ic.column_id
|
||||
WHERE partition_ic.object_id = idx.object_id
|
||||
AND partition_ic.index_id = idx.index_id
|
||||
AND partition_ic.partition_ordinal > 0
|
||||
ORDER BY partition_ic.partition_ordinal
|
||||
)) + N')'
|
||||
ELSE N'' END + N';' +
|
||||
CASE WHEN idx.is_disabled = 1 THEN N' ALTER INDEX ' + QUOTENAME(idx.name) +
|
||||
N' ON ' + @dbx_qualified_table + N' DISABLE;' ELSE N'' END
|
||||
FROM sys.indexes AS idx
|
||||
LEFT JOIN sys.stats AS index_stats
|
||||
ON index_stats.object_id = idx.object_id
|
||||
AND index_stats.stats_id = idx.index_id
|
||||
LEFT JOIN sys.data_spaces AS data_space ON data_space.data_space_id = idx.data_space_id
|
||||
WHERE idx.object_id = @dbx_object_id
|
||||
AND idx.index_id > 0
|
||||
AND idx.name IS NOT NULL
|
||||
AND idx.type IN (1, 2)
|
||||
AND idx.is_hypothetical = 0
|
||||
AND idx.is_primary_key = 0
|
||||
AND idx.is_unique_constraint = 0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS affected_ic
|
||||
WHERE affected_ic.object_id = idx.object_id
|
||||
AND affected_ic.index_id = idx.index_id
|
||||
AND affected_ic.column_id = @dbx_column_id
|
||||
);
|
||||
|
||||
-- PRIMARY KEY and UNIQUE constraints must be dropped/created as constraints,
|
||||
-- never as ordinary unique indexes.
|
||||
INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql)
|
||||
SELECT
|
||||
50,
|
||||
10,
|
||||
N'ALTER TABLE ' + @dbx_qualified_table + N' DROP CONSTRAINT ' + QUOTENAME(key_constraint.name) + N';',
|
||||
N'ALTER TABLE ' + @dbx_qualified_table + N' ADD CONSTRAINT ' + QUOTENAME(key_constraint.name) + N' ' +
|
||||
CASE WHEN key_constraint.type = N'PK' THEN N'PRIMARY KEY ' ELSE N'UNIQUE ' END +
|
||||
idx.type_desc + N' (' +
|
||||
STUFF((
|
||||
SELECT N', ' + QUOTENAME(key_column.name) +
|
||||
CASE WHEN ic.is_descending_key = 1 THEN N' DESC' ELSE N' ASC' END
|
||||
FROM sys.index_columns AS ic
|
||||
JOIN sys.columns AS key_column
|
||||
ON key_column.object_id = ic.object_id
|
||||
AND key_column.column_id = ic.column_id
|
||||
WHERE ic.object_id = idx.object_id
|
||||
AND ic.index_id = idx.index_id
|
||||
AND ic.key_ordinal > 0
|
||||
ORDER BY ic.key_ordinal
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' +
|
||||
N' WITH (PAD_INDEX = ' + CASE WHEN idx.is_padded = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
CASE WHEN idx.fill_factor > 0
|
||||
THEN N', FILLFACTOR = ' + CONVERT(nvarchar(3), idx.fill_factor)
|
||||
ELSE N'' END +
|
||||
N', IGNORE_DUP_KEY = ' + CASE WHEN idx.ignore_dup_key = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', STATISTICS_NORECOMPUTE = ' + CASE WHEN key_stats.no_recompute = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', ALLOW_ROW_LOCKS = ' + CASE WHEN idx.allow_row_locks = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', ALLOW_PAGE_LOCKS = ' + CASE WHEN idx.allow_page_locks = 1 THEN N'ON' ELSE N'OFF' END + N')' +
|
||||
CASE WHEN data_space.type = N'FG' THEN N' ON ' + QUOTENAME(data_space.name)
|
||||
WHEN data_space.type = N'PS' THEN N' ON ' + QUOTENAME(data_space.name) + N' (' +
|
||||
QUOTENAME((
|
||||
SELECT TOP (1) partition_column.name
|
||||
FROM sys.index_columns AS partition_ic
|
||||
JOIN sys.columns AS partition_column
|
||||
ON partition_column.object_id = partition_ic.object_id
|
||||
AND partition_column.column_id = partition_ic.column_id
|
||||
WHERE partition_ic.object_id = idx.object_id
|
||||
AND partition_ic.index_id = idx.index_id
|
||||
AND partition_ic.partition_ordinal > 0
|
||||
ORDER BY partition_ic.partition_ordinal
|
||||
)) + N')'
|
||||
ELSE N'' END + N';' +
|
||||
CASE WHEN idx.is_disabled = 1 THEN N' ALTER INDEX ' + QUOTENAME(idx.name) +
|
||||
N' ON ' + @dbx_qualified_table + N' DISABLE;' ELSE N'' END
|
||||
FROM sys.key_constraints AS key_constraint
|
||||
JOIN sys.indexes AS idx
|
||||
ON idx.object_id = key_constraint.parent_object_id
|
||||
AND idx.index_id = key_constraint.unique_index_id
|
||||
LEFT JOIN sys.stats AS key_stats
|
||||
ON key_stats.object_id = idx.object_id
|
||||
AND key_stats.stats_id = idx.index_id
|
||||
LEFT JOIN sys.data_spaces AS data_space ON data_space.data_space_id = idx.data_space_id
|
||||
WHERE key_constraint.parent_object_id = @dbx_object_id
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS affected_ic
|
||||
WHERE affected_ic.object_id = idx.object_id
|
||||
AND affected_ic.index_id = idx.index_id
|
||||
AND affected_ic.column_id = @dbx_column_id
|
||||
);
|
||||
|
||||
DECLARE @dbx_dependency_sql nvarchar(max);
|
||||
DECLARE dbx_dependency_drop_cursor CURSOR LOCAL FAST_FORWARD FOR
|
||||
SELECT drop_sql
|
||||
FROM @dbx_dependencies
|
||||
ORDER BY drop_order, dependency_id;
|
||||
OPEN dbx_dependency_drop_cursor;
|
||||
FETCH NEXT FROM dbx_dependency_drop_cursor INTO @dbx_dependency_sql;
|
||||
WHILE @@FETCH_STATUS = 0
|
||||
BEGIN
|
||||
EXEC sys.sp_executesql @dbx_dependency_sql;
|
||||
FETCH NEXT FROM dbx_dependency_drop_cursor INTO @dbx_dependency_sql;
|
||||
END;
|
||||
CLOSE dbx_dependency_drop_cursor;
|
||||
DEALLOCATE dbx_dependency_drop_cursor;
|
||||
"#;
|
||||
|
||||
const DEPENDENCY_RECREATE_AND_FINISH_SQL: &str = r#"
|
||||
DECLARE dbx_dependency_create_cursor CURSOR LOCAL FAST_FORWARD FOR
|
||||
SELECT create_sql
|
||||
FROM @dbx_dependencies
|
||||
ORDER BY create_order, dependency_id;
|
||||
OPEN dbx_dependency_create_cursor;
|
||||
FETCH NEXT FROM dbx_dependency_create_cursor INTO @dbx_dependency_sql;
|
||||
WHILE @@FETCH_STATUS = 0
|
||||
BEGIN
|
||||
EXEC sys.sp_executesql @dbx_dependency_sql;
|
||||
FETCH NEXT FROM dbx_dependency_create_cursor INTO @dbx_dependency_sql;
|
||||
END;
|
||||
CLOSE dbx_dependency_create_cursor;
|
||||
DEALLOCATE dbx_dependency_create_cursor;
|
||||
|
||||
IF @dbx_started_transaction = 1
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF CURSOR_STATUS('local', 'dbx_dependency_drop_cursor') >= 0
|
||||
CLOSE dbx_dependency_drop_cursor;
|
||||
IF CURSOR_STATUS('local', 'dbx_dependency_drop_cursor') >= -1
|
||||
DEALLOCATE dbx_dependency_drop_cursor;
|
||||
IF CURSOR_STATUS('local', 'dbx_dependency_create_cursor') >= 0
|
||||
CLOSE dbx_dependency_create_cursor;
|
||||
IF CURSOR_STATUS('local', 'dbx_dependency_create_cursor') >= -1
|
||||
DEALLOCATE dbx_dependency_create_cursor;
|
||||
|
||||
IF @dbx_started_transaction = 1 AND XACT_STATE() <> 0
|
||||
ROLLBACK TRANSACTION;
|
||||
ELSE IF @dbx_has_savepoint = 1 AND XACT_STATE() = 1
|
||||
ROLLBACK TRANSACTION dbx_alter_column_dependencies;
|
||||
|
||||
THROW;
|
||||
END CATCH;
|
||||
"#;
|
||||
|
||||
fn escape_tsql_string_literal(value: &str) -> String {
|
||||
value.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// Build one executable T-SQL batch that preserves live dependencies around an
|
||||
/// `ALTER COLUMN` operation.
|
||||
///
|
||||
/// `table` is the already-quoted target table name used by generated DDL (for
|
||||
/// example `[dbo].[orders]`). `column_name` is the unquoted catalog name.
|
||||
/// `alter_batch` may contain more than one statement, such as the existing
|
||||
/// default-constraint preservation batch followed by `ALTER COLUMN`.
|
||||
pub(super) fn build_dependency_aware_alter_column_batch(table: &str, column_name: &str, alter_batch: &str) -> String {
|
||||
let table_literal = escape_tsql_string_literal(table);
|
||||
let column_literal = escape_tsql_string_literal(column_name);
|
||||
let alter_batch = alter_batch.trim();
|
||||
|
||||
let mut batch = String::with_capacity(
|
||||
DEPENDENCY_CAPTURE_AND_DROP_SQL.len() + DEPENDENCY_RECREATE_AND_FINISH_SQL.len() + alter_batch.len() + 512,
|
||||
);
|
||||
batch.push_str(
|
||||
"SET ANSI_NULLS ON;\n\
|
||||
SET QUOTED_IDENTIFIER ON;\n\
|
||||
SET ANSI_PADDING ON;\n\
|
||||
SET ANSI_WARNINGS ON;\n\
|
||||
SET CONCAT_NULL_YIELDS_NULL ON;\n\
|
||||
SET ARITHABORT ON;\n\
|
||||
SET NUMERIC_ROUNDABORT OFF;\n\
|
||||
DECLARE @dbx_object_id int = OBJECT_ID(N'",
|
||||
);
|
||||
batch.push_str(&table_literal);
|
||||
batch.push_str("');\nDECLARE @dbx_column_id int = COLUMNPROPERTY(@dbx_object_id, N'");
|
||||
batch.push_str(&column_literal);
|
||||
batch.push_str("', 'ColumnId');\nIF @dbx_object_id IS NULL OR @dbx_column_id IS NULL\n");
|
||||
batch.push_str(" THROW 50000, 'DBX could not resolve the SQL Server column before ALTER COLUMN.', 1;\n");
|
||||
batch.push_str(DEPENDENCY_CAPTURE_AND_DROP_SQL);
|
||||
batch.push('\n');
|
||||
batch.push_str(" ");
|
||||
batch.push_str(alter_batch);
|
||||
if !alter_batch.ends_with(';') {
|
||||
batch.push(';');
|
||||
}
|
||||
batch.push('\n');
|
||||
batch.push_str(DEPENDENCY_RECREATE_AND_FINISH_SQL);
|
||||
batch
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dependency_batch_escapes_catalog_literals_and_keeps_alter_sql() {
|
||||
let sql = build_dependency_aware_alter_column_batch(
|
||||
"[odd].[O'Brien]",
|
||||
"amount's value",
|
||||
"ALTER TABLE [odd].[O'Brien] ALTER COLUMN [amount's value] bigint NULL;",
|
||||
);
|
||||
|
||||
assert!(sql.contains("OBJECT_ID(N'[odd].[O''Brien]')"), "{sql}");
|
||||
assert!(sql.contains("COLUMNPROPERTY(@dbx_object_id, N'amount''s value', 'ColumnId')"), "{sql}");
|
||||
assert!(sql.contains("ALTER TABLE [odd].[O'Brien] ALTER COLUMN [amount's value] bigint NULL;"), "{sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_batch_covers_every_supported_dependency_kind() {
|
||||
let sql = build_dependency_aware_alter_column_batch(
|
||||
"[dbo].[orders]",
|
||||
"amount",
|
||||
"ALTER TABLE [dbo].[orders] ALTER COLUMN [amount] bigint NOT NULL;",
|
||||
);
|
||||
|
||||
for catalog in [
|
||||
"sys.foreign_keys AS fk",
|
||||
"sys.foreign_key_columns AS fkc",
|
||||
"sys.check_constraints AS cc",
|
||||
"sys.sql_expression_dependencies AS sed",
|
||||
"sys.stats_columns AS sc",
|
||||
"sys.indexes AS idx",
|
||||
"sys.key_constraints AS key_constraint",
|
||||
] {
|
||||
assert!(sql.contains(catalog), "missing {catalog}: {sql}");
|
||||
}
|
||||
|
||||
assert!(sql.contains("fkc.referenced_object_id = @dbx_object_id"), "inbound FK predicate: {sql}");
|
||||
assert!(sql.contains("ORDER BY fkc.constraint_column_id"), "composite FK order: {sql}");
|
||||
assert!(sql.contains("fk.delete_referential_action_desc"), "delete action: {sql}");
|
||||
assert!(sql.contains("fk.update_referential_action_desc"), "update action: {sql}");
|
||||
assert!(sql.contains("fk.is_not_for_replication"), "NFR state: {sql}");
|
||||
assert!(sql.contains("fk.is_not_trusted"), "trust state: {sql}");
|
||||
assert!(sql.contains("fk.is_disabled"), "enabled state: {sql}");
|
||||
assert!(sql.contains("stats.user_created = 1"), "independent statistics: {sql}");
|
||||
assert!(sql.contains("stats.no_recompute"), "statistics options: {sql}");
|
||||
assert!(sql.contains("ic.is_descending_key"), "index key order: {sql}");
|
||||
assert!(sql.contains("include_ic.is_included_column = 1"), "included columns: {sql}");
|
||||
assert!(sql.contains("idx.filter_definition"), "filtered index: {sql}");
|
||||
assert!(sql.contains("idx.allow_row_locks"), "index options: {sql}");
|
||||
assert!(sql.contains("data_space.type = N'PS'"), "partition data space: {sql}");
|
||||
assert!(sql.contains("idx.is_disabled = 1"), "disabled index state: {sql}");
|
||||
assert!(sql.contains("key_constraint.type = N'PK'"), "primary key form: {sql}");
|
||||
assert!(sql.contains("THEN N'PRIMARY KEY ' ELSE N'UNIQUE ' END"), "PK/UQ distinction: {sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_batch_rejects_unsupported_indexes_before_dropping_anything() {
|
||||
let sql = build_dependency_aware_alter_column_batch(
|
||||
"[dbo].[events]",
|
||||
"payload",
|
||||
"ALTER TABLE [dbo].[events] ALTER COLUMN [payload] varbinary(max) NULL;",
|
||||
);
|
||||
|
||||
let guard = sql.find("idx.type NOT IN (1, 2)").expect("unsupported-index guard");
|
||||
let rejection = sql.find("THROW 50001").expect("unsupported-index THROW");
|
||||
let drop_cursor = sql.find("dbx_dependency_drop_cursor CURSOR").expect("drop cursor");
|
||||
assert!(guard < rejection && rejection < drop_cursor, "guard must run before every dependency drop: {sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_batch_is_atomic_and_recreates_after_the_alter() {
|
||||
let alter = "ALTER TABLE [dbo].[metrics] ALTER COLUMN [value] decimal(18,2) NOT NULL;";
|
||||
let sql = build_dependency_aware_alter_column_batch("[dbo].[metrics]", "value", alter);
|
||||
|
||||
let drop_cursor = sql.find("dbx_dependency_drop_cursor CURSOR").expect("drop cursor");
|
||||
let alter_position = sql.find(alter).expect("alter statement");
|
||||
let create_cursor = sql.find("dbx_dependency_create_cursor CURSOR").expect("create cursor");
|
||||
assert!(drop_cursor < alter_position && alter_position < create_cursor, "drop/alter/create order: {sql}");
|
||||
assert!(sql.contains("BEGIN TRY"), "{sql}");
|
||||
assert!(sql.starts_with("SET ANSI_NULLS ON;\nSET QUOTED_IDENTIFIER ON;"), "{sql}");
|
||||
assert!(sql.contains("SAVE TRANSACTION dbx_alter_column_dependencies"), "{sql}");
|
||||
assert!(sql.contains("ROLLBACK TRANSACTION dbx_alter_column_dependencies"), "{sql}");
|
||||
assert!(sql.contains("IF @dbx_started_transaction = 1\n COMMIT TRANSACTION"), "{sql}");
|
||||
assert!(sql.contains("THROW;\nEND CATCH"), "{sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_batch_terminates_an_unterminated_alter_batch_once() {
|
||||
let sql = build_dependency_aware_alter_column_batch(
|
||||
"[dbo].[metrics]",
|
||||
"value",
|
||||
"ALTER TABLE [dbo].[metrics] ALTER COLUMN [value] bigint NULL",
|
||||
);
|
||||
assert!(sql.contains("ALTER TABLE [dbo].[metrics] ALTER COLUMN [value] bigint NULL;\n"), "{sql}");
|
||||
assert!(!sql.contains("bigint NULL;;"), "{sql}");
|
||||
}
|
||||
}
|
||||
@@ -381,19 +381,45 @@ fn upper_first_word(s: &str) -> &str {
|
||||
trimmed.split_whitespace().next().unwrap_or("")
|
||||
}
|
||||
|
||||
fn sqlserver_single_statement_batch(batch: &str) -> String {
|
||||
format!("EXEC sys.sp_executesql N'{}';", batch.trim().trim_end_matches(';').replace('\'', "''"))
|
||||
}
|
||||
|
||||
/// `CREATE TABLE` variants that still create a table object and must keep
|
||||
/// idempotent wrapping: SQLite `CREATE TEMP TABLE`, PostgreSQL
|
||||
/// `GLOBAL/LOCAL TEMPORARY` and `UNLOGGED` forms, MySQL/MariaDB
|
||||
/// `TEMPORARY`. None of the entries is a prefix of another.
|
||||
const CREATE_TABLE_VARIANTS: &[&str] = &[
|
||||
"CREATE GLOBAL TEMPORARY TABLE ",
|
||||
"CREATE LOCAL TEMPORARY TABLE ",
|
||||
"CREATE TEMPORARY TABLE ",
|
||||
"CREATE TEMP TABLE ",
|
||||
"CREATE UNLOGGED TABLE ",
|
||||
"CREATE TABLE ",
|
||||
];
|
||||
|
||||
fn create_table_variant_prefix(upper: &str) -> Option<&'static str> {
|
||||
CREATE_TABLE_VARIANTS.iter().copied().find(|prefix| upper.starts_with(prefix))
|
||||
}
|
||||
|
||||
fn wrap_if_not_exists(sql: &str, db_type: DatabaseType) -> String {
|
||||
use crate::sql_dialect::ddl_profile::profile_for;
|
||||
let profile = profile_for(db_type);
|
||||
let upper = sql.trim_start().to_uppercase();
|
||||
|
||||
if upper.starts_with("CREATE TABLE") {
|
||||
if let Some(variant) = create_table_variant_prefix(&upper) {
|
||||
if !profile.create_table_if_not_exists {
|
||||
return sql.to_string();
|
||||
}
|
||||
if upper.contains("IF NOT EXISTS") {
|
||||
return sql.to_string();
|
||||
}
|
||||
let idx = sql.find("CREATE TABLE").unwrap_or(0) + "CREATE TABLE".len();
|
||||
let keyword = variant.trim_end();
|
||||
let Some(idx) = sql.find(keyword).map(|pos| pos + keyword.len()) else {
|
||||
// Case-mismatched generated SQL: leave it unwrapped rather than
|
||||
// emitting IF NOT EXISTS at an invalid position.
|
||||
return sql.to_string();
|
||||
};
|
||||
let (prefix, suffix) = sql.split_at(idx);
|
||||
format!("{prefix} IF NOT EXISTS{suffix}")
|
||||
} else if upper.starts_with("CREATE UNIQUE INDEX") || upper.starts_with("CREATE INDEX") {
|
||||
@@ -474,7 +500,7 @@ fn wrap_conditional_check(sql: &str, db_type: DatabaseType) -> String {
|
||||
let upper = trimmed.to_uppercase();
|
||||
let first_word = upper_first_word(trimmed);
|
||||
|
||||
if first_word == "CREATE" && upper.contains("TABLE") {
|
||||
if first_word == "CREATE" && create_table_variant_prefix(&upper).is_some() {
|
||||
// Prefer native IF NOT EXISTS — never append bare DDL after a SELECT CASE
|
||||
// (SELECT does not execute the string payload).
|
||||
if profile.create_table_if_not_exists {
|
||||
@@ -513,12 +539,25 @@ fn wrap_conditional_check(sql: &str, db_type: DatabaseType) -> String {
|
||||
"BEGIN\n EXECUTE IMMEDIATE '{ddl_literal}';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -955 THEN RAISE; END IF;\nEND;"
|
||||
)
|
||||
}
|
||||
DatabaseType::SqlServer => sqlserver_single_statement_batch(&format!(
|
||||
"IF OBJECT_ID(N'{}', N'U') IS NULL BEGIN {trimmed}; END;",
|
||||
table_identifier.replace('\'', "''")
|
||||
)),
|
||||
_ => {
|
||||
format!("-- ConditionalCheck: run only if table {table_name} does not exist\n{trimmed};")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if db_type == DatabaseType::SqlServer && first_word == "CREATE" && upper.contains(" INDEX ") {
|
||||
let index_name = unquote_sqlserver_identifier(extract_object_identifier(trimmed, "INDEX")).replace('\'', "''");
|
||||
let table_identifier = extract_object_identifier(trimmed, "ON");
|
||||
let table_literal = table_identifier.replace('\'', "''");
|
||||
return sqlserver_single_statement_batch(&format!(
|
||||
"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'{table_literal}') AND name = N'{index_name}') BEGIN {trimmed}; END;"
|
||||
));
|
||||
}
|
||||
|
||||
if first_word == "DROP" {
|
||||
let rebuilt = format!("{trimmed};");
|
||||
let guarded = wrap_if_not_exists(&rebuilt, db_type);
|
||||
@@ -552,11 +591,30 @@ fn extract_object_identifier<'a>(sql: &'a str, keyword: &str) -> &'a str {
|
||||
|
||||
let mut quote = None;
|
||||
let mut end = after.len();
|
||||
for (index, ch) in after.char_indices() {
|
||||
let mut chars = after.char_indices().peekable();
|
||||
while let Some((index, ch)) = chars.next() {
|
||||
match quote {
|
||||
Some('"') if ch == '"' => quote = None,
|
||||
Some('`') if ch == '`' => quote = None,
|
||||
Some(']') if ch == ']' => quote = None,
|
||||
Some('"') if ch == '"' => {
|
||||
if chars.peek().is_some_and(|(_, next)| *next == '"') {
|
||||
chars.next();
|
||||
} else {
|
||||
quote = None;
|
||||
}
|
||||
}
|
||||
Some('`') if ch == '`' => {
|
||||
if chars.peek().is_some_and(|(_, next)| *next == '`') {
|
||||
chars.next();
|
||||
} else {
|
||||
quote = None;
|
||||
}
|
||||
}
|
||||
Some(']') if ch == ']' => {
|
||||
if chars.peek().is_some_and(|(_, next)| *next == ']') {
|
||||
chars.next();
|
||||
} else {
|
||||
quote = None;
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None if ch == '"' || ch == '`' => quote = Some(ch),
|
||||
None if ch == '[' => quote = Some(']'),
|
||||
@@ -577,6 +635,17 @@ fn extract_table_name<'a>(sql: &'a str, keyword: &str) -> &'a str {
|
||||
extract_object_identifier(sql, keyword).trim_matches('"').trim_matches('`').trim_matches('[').trim_matches(']')
|
||||
}
|
||||
|
||||
fn unquote_sqlserver_identifier(identifier: &str) -> String {
|
||||
let trimmed = identifier.trim();
|
||||
if let Some(inner) = trimmed.strip_prefix('[').and_then(|value| value.strip_suffix(']')) {
|
||||
inner.replace("]]", "]")
|
||||
} else if let Some(inner) = trimmed.strip_prefix('"').and_then(|value| value.strip_suffix('"')) {
|
||||
inner.replace("\"\"", "\"")
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn postgres_table_identity(identifier: &str) -> (Option<String>, String) {
|
||||
let mut quote = false;
|
||||
let mut separator = None;
|
||||
@@ -1476,6 +1545,15 @@ mod tests {
|
||||
assert_eq!(strategy, IdempotentStrategy::CreateOrReplace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_selects_catalog_based_conditional_strategy() {
|
||||
let descriptor = DialectCapabilityDescriptor::for_dialect(DialectKind::SqlServer);
|
||||
assert_eq!(
|
||||
select_strategy(Some(DialectKind::SqlServer), Some(&descriptor)),
|
||||
IdempotentStrategy::ConditionalCheck
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_strategy_conditional() {
|
||||
let desc = DialectCapabilityDescriptor { flags: 0, ..Default::default() };
|
||||
@@ -1497,6 +1575,21 @@ mod tests {
|
||||
assert!(result.contains("CREATE TABLE IF NOT EXISTS"), "Got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_create_temp_table_if_not_exists_sqlite() {
|
||||
let sql = "CREATE TEMP TABLE sessions (token TEXT);";
|
||||
let result = apply_idempotent_strategy(sql, DatabaseType::Sqlite, IdempotentStrategy::IfNotExists);
|
||||
assert!(result.contains("CREATE TEMP TABLE IF NOT EXISTS"), "Got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_check_wraps_global_temporary_table_postgres() {
|
||||
let sql = "CREATE GLOBAL TEMPORARY TABLE staging (id INT);";
|
||||
let result = apply_idempotent_strategy(sql, DatabaseType::Postgres, IdempotentStrategy::ConditionalCheck);
|
||||
assert!(result.contains("DO $dbx_idempotent$"), "missing DO wrapper: {result}");
|
||||
assert!(result.contains("CREATE GLOBAL TEMPORARY TABLE staging"), "DDL not embedded: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_create_table_if_not_exists_clickhouse() {
|
||||
let sql = "CREATE TABLE events (ts DateTime);";
|
||||
@@ -1534,6 +1627,38 @@ mod tests {
|
||||
assert!(!result.contains("CREATE IF NOT EXISTS"), "wrong IF NOT EXISTS placement: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_never_emits_create_index_if_not_exists() {
|
||||
let sql = "CREATE UNIQUE NONCLUSTERED INDEX [idx_email] ON [dbo].[users] ([email]);";
|
||||
let direct = apply_idempotent_strategy(sql, DatabaseType::SqlServer, IdempotentStrategy::IfNotExists);
|
||||
assert_eq!(direct, sql);
|
||||
assert!(!direct.contains("INDEX IF NOT EXISTS"));
|
||||
|
||||
let conditional = apply_idempotent_strategy(sql, DatabaseType::SqlServer, IdempotentStrategy::ConditionalCheck);
|
||||
assert!(conditional.starts_with("EXEC sys.sp_executesql N'IF NOT EXISTS"), "Got: {conditional}");
|
||||
assert!(conditional.contains("FROM sys.indexes"), "Got: {conditional}");
|
||||
assert!(conditional.contains("OBJECT_ID(N''[dbo].[users]'')"), "Got: {conditional}");
|
||||
assert!(conditional.contains("name = N''idx_email''"), "Got: {conditional}");
|
||||
assert!(!conditional.contains("INDEX IF NOT EXISTS"), "Got: {conditional}");
|
||||
|
||||
let escaped_name = apply_idempotent_strategy(
|
||||
"CREATE INDEX [ix_owner]] name] ON [dbo].[user]] data] ([id]);",
|
||||
DatabaseType::SqlServer,
|
||||
IdempotentStrategy::ConditionalCheck,
|
||||
);
|
||||
assert!(escaped_name.contains("OBJECT_ID(N''[dbo].[user]] data]'')"), "Got: {escaped_name}");
|
||||
assert!(escaped_name.contains("name = N''ix_owner] name''"), "Got: {escaped_name}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_create_table_conditional_uses_object_id() {
|
||||
let sql = "CREATE TABLE [dbo].[users] ([id] INT NOT NULL);";
|
||||
let result = apply_idempotent_strategy(sql, DatabaseType::SqlServer, IdempotentStrategy::ConditionalCheck);
|
||||
assert!(result.starts_with("EXEC sys.sp_executesql N'IF OBJECT_ID"), "Got: {result}");
|
||||
assert!(result.contains("N''[dbo].[users]'', N''U''"), "Got: {result}");
|
||||
assert!(!result.contains("CREATE TABLE IF NOT EXISTS"), "Got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_drop_sequence_if_exists() {
|
||||
let sql = "DROP SEQUENCE user_seq;";
|
||||
|
||||
@@ -212,6 +212,9 @@ const SEQ_CREATE: &str =
|
||||
const SEQ_ALTER: &str =
|
||||
"ALTER SEQUENCE {name} AS {data_type} START WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};";
|
||||
const SEQ_DROP: &str = "DROP SEQUENCE {name}{cascade};";
|
||||
const SQLSERVER_FN_DROP: &str = "DROP FUNCTION {name};";
|
||||
const SQLSERVER_SEQ_ALTER: &str =
|
||||
"ALTER SEQUENCE {name} RESTART WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};";
|
||||
const RULE_DROP: &str = "DROP RULE IF EXISTS {rule_name} ON {table_name}{cascade};";
|
||||
const OWNER_ALTER: &str = "ALTER {object_type} {name} OWNER TO {owner};";
|
||||
|
||||
@@ -444,7 +447,7 @@ fn oracle_family(db: DatabaseType) -> DdlDialectProfile {
|
||||
fn sqlserver_family(db: DatabaseType) -> DdlDialectProfile {
|
||||
DdlDialectProfile {
|
||||
database_type: db,
|
||||
quote: QuoteStyle::DoubleQuote,
|
||||
quote: QuoteStyle::Brackets,
|
||||
auto_inc: AutoIncSyntax::Suffix(" IDENTITY(1,1)"),
|
||||
supports_display_width: false,
|
||||
max_varchar_len: None,
|
||||
@@ -468,16 +471,16 @@ fn sqlserver_family(db: DatabaseType) -> DdlDialectProfile {
|
||||
alter_uses_modify_column: false,
|
||||
alter_batches_clauses: false,
|
||||
create_table_if_not_exists: false,
|
||||
create_index_if_not_exists: true,
|
||||
create_index_if_not_exists: false,
|
||||
create_function_or_replace: false,
|
||||
supports_function_ddl: true,
|
||||
supports_sequence_ddl: true,
|
||||
supports_rule_ddl: false,
|
||||
supports_owner_ddl: false,
|
||||
function_create_template: Some(FN_CREATE),
|
||||
function_drop_template: Some(FN_DROP),
|
||||
function_drop_template: Some(SQLSERVER_FN_DROP),
|
||||
sequence_create_template: Some(SEQ_CREATE),
|
||||
sequence_alter_template: Some(SEQ_ALTER),
|
||||
sequence_alter_template: Some(SQLSERVER_SEQ_ALTER),
|
||||
sequence_drop_template: Some(SEQ_DROP),
|
||||
rule_drop_template: None,
|
||||
owner_alter_template: None,
|
||||
@@ -727,11 +730,12 @@ mod tests {
|
||||
assert!(matches!(mssql.auto_inc, AutoIncSyntax::Suffix(s) if s.contains("IDENTITY")));
|
||||
assert!(access.lookup_type("VARCHAR").is_some());
|
||||
assert!(mssql.type_map.is_empty());
|
||||
// Access must not inherit SQL Server lock-timeout / idempotent flags.
|
||||
// Access must not inherit SQL Server lock-timeout behavior. Neither
|
||||
// database supports CREATE INDEX IF NOT EXISTS syntax.
|
||||
assert_eq!(access.lock_timeout_sql, None);
|
||||
assert!(mssql.lock_timeout_sql.is_some());
|
||||
assert!(!access.create_index_if_not_exists);
|
||||
assert!(mssql.create_index_if_not_exists);
|
||||
assert!(!mssql.create_index_if_not_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -748,6 +752,7 @@ mod tests {
|
||||
fn quote_styles() {
|
||||
assert_eq!(profile_for(DatabaseType::Mysql).quote_ident("a`b"), "`a``b`");
|
||||
assert_eq!(profile_for(DatabaseType::Access).quote_ident(r#"a"b"#), "\"a\"\"b\"");
|
||||
assert_eq!(profile_for(DatabaseType::SqlServer).quote_ident("a]b"), "[a]]b]");
|
||||
assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("emp"), "EMP");
|
||||
assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("EMP"), "EMP");
|
||||
// Mixed-case must preserve spelling via quotes (not EMPMIXED).
|
||||
|
||||
@@ -68,7 +68,7 @@ impl DialectKind {
|
||||
// DuckDB
|
||||
"duckdb" => Some(DialectKind::DuckDb),
|
||||
// SQL Server family
|
||||
"sqlserver" | "mssql" | "sql server" | "access" => Some(DialectKind::SqlServer),
|
||||
"sqlserver" | "sql_server" | "mssql" | "sql server" | "access" => Some(DialectKind::SqlServer),
|
||||
// Oracle family
|
||||
"oracle" | "dameng" | "oceanbaseoracle" | "oceanbase" | "iris" | "yashandb" | "xugu" => {
|
||||
Some(DialectKind::Oracle)
|
||||
@@ -402,7 +402,6 @@ impl DialectCapabilityDescriptor {
|
||||
| CAP_DROP_SEQUENCE
|
||||
| CAP_ALTER_OWNER
|
||||
| CAP_GRANT_REVOKE
|
||||
| CAP_IF_NOT_EXISTS
|
||||
| CAP_TEMPORARY_TABLE
|
||||
| CAP_TRANSACTIONAL_DDL
|
||||
| CAP_IDENTITY_COLUMNS,
|
||||
@@ -983,6 +982,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialect_kind_accepts_serde_sql_server_label() {
|
||||
assert_eq!(DialectKind::from_label("sql_server"), Some(DialectKind::SqlServer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialect_kind_to_database_type_roundtrip() {
|
||||
for kind in &[
|
||||
|
||||
-1
@@ -202,7 +202,6 @@ expression: all
|
||||
"drop_sequence",
|
||||
"alter_owner",
|
||||
"grant_revoke",
|
||||
"if_not_exists",
|
||||
"temporary_table",
|
||||
"transactional_ddl",
|
||||
"identity_columns"
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: desc
|
||||
---
|
||||
{
|
||||
"dialect": "sql_server",
|
||||
"flags": 3086983151,
|
||||
"flags": 3019874287,
|
||||
"max_identifier_length": 128,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": true,
|
||||
|
||||
@@ -153,11 +153,179 @@ fn tinyint1_as_boolean_key(base: &str, params: Option<&str>) -> Option<&'static
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlserver_decimal_type(params: Option<&str>) -> String {
|
||||
let Some(params) = params else {
|
||||
return "DECIMAL".to_string();
|
||||
};
|
||||
let values = params.split(',').map(str::trim).collect::<Vec<_>>();
|
||||
let Ok(precision) = values.first().copied().unwrap_or_default().parse::<u32>() else {
|
||||
return "DECIMAL".to_string();
|
||||
};
|
||||
let precision = precision.clamp(1, 38);
|
||||
if values.len() == 1 {
|
||||
return format!("DECIMAL({precision})");
|
||||
}
|
||||
let Ok(scale) = values[1].parse::<u32>() else {
|
||||
return format!("DECIMAL({precision})");
|
||||
};
|
||||
format!("DECIMAL({precision},{})", scale.min(precision))
|
||||
}
|
||||
|
||||
fn sqlserver_length_type(target_type: &str, params: Option<&str>, max_length: u32) -> String {
|
||||
let Some(param) = params.map(str::trim).filter(|param| !param.is_empty()) else {
|
||||
return if matches!(target_type, "VARCHAR" | "NVARCHAR" | "VARBINARY") {
|
||||
format!("{target_type}(MAX)")
|
||||
} else {
|
||||
target_type.to_string()
|
||||
};
|
||||
};
|
||||
if param.eq_ignore_ascii_case("MAX") {
|
||||
return match target_type {
|
||||
"NCHAR" => "NVARCHAR(MAX)".to_string(),
|
||||
"CHAR" => "VARCHAR(MAX)".to_string(),
|
||||
"BINARY" => "VARBINARY(MAX)".to_string(),
|
||||
_ => format!("{target_type}(MAX)"),
|
||||
};
|
||||
}
|
||||
match param.parse::<u32>() {
|
||||
Ok(length) if length > max_length => match target_type {
|
||||
"NCHAR" | "NVARCHAR" => "NVARCHAR(MAX)".to_string(),
|
||||
"BINARY" | "VARBINARY" => "VARBINARY(MAX)".to_string(),
|
||||
_ => "VARCHAR(MAX)".to_string(),
|
||||
},
|
||||
Ok(length) => format!("{target_type}({})", length.max(1)),
|
||||
Err(_) => format!("{target_type}(MAX)"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlserver_temporal_type(target_type: &str, params: Option<&str>) -> String {
|
||||
match params.and_then(|param| param.trim().parse::<u32>().ok()) {
|
||||
Some(precision) => format!("{target_type}({})", precision.min(7)),
|
||||
None => target_type.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert common source types to native SQL Server types before the generic
|
||||
/// mapping matrix runs. This is source-dialect aware because names such as
|
||||
/// `timestamp` mean a date/time in MySQL/PostgreSQL but `rowversion` in SQL Server.
|
||||
fn rewrite_cross_dialect_to_sqlserver(source_type: &str, source: DialectKind) -> Option<String> {
|
||||
if source == DialectKind::SqlServer {
|
||||
return None;
|
||||
}
|
||||
|
||||
let source_upper = source_type.trim().to_ascii_uppercase();
|
||||
// MySQL places UNSIGNED/ZEROFILL after an optional parameter list, so it
|
||||
// cannot be recovered from `raw_base` alone (`INT(11) UNSIGNED`). ZEROFILL
|
||||
// also implies UNSIGNED in MySQL.
|
||||
let mysql_unsigned = source_upper.split_whitespace().any(|part| matches!(part, "UNSIGNED" | "ZEROFILL"));
|
||||
let (raw_base, params) = split_type_base_params(source_type);
|
||||
let base = raw_base
|
||||
.split_whitespace()
|
||||
.filter(|part| !part.eq_ignore_ascii_case("UNSIGNED") && !part.eq_ignore_ascii_case("ZEROFILL"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let params = params.as_deref();
|
||||
|
||||
let mapped = match source {
|
||||
DialectKind::Mysql => match base.as_str() {
|
||||
"BOOL" | "BOOLEAN" => "BIT".to_string(),
|
||||
"TINYINT" if params == Some("1") && !mysql_unsigned => "BIT".to_string(),
|
||||
// Preserve the complete MySQL value range. SQL Server TINYINT is
|
||||
// unsigned, while MySQL TINYINT is signed unless explicitly marked.
|
||||
"TINYINT" if mysql_unsigned => "TINYINT".to_string(),
|
||||
"TINYINT" => "SMALLINT".to_string(),
|
||||
"SMALLINT" if mysql_unsigned => "INT".to_string(),
|
||||
"SMALLINT" => "SMALLINT".to_string(),
|
||||
"MEDIUMINT" => "INT".to_string(),
|
||||
"INT" | "INTEGER" if mysql_unsigned => "BIGINT".to_string(),
|
||||
"INT" | "INTEGER" => "INT".to_string(),
|
||||
"BIGINT" if mysql_unsigned => "DECIMAL(20,0)".to_string(),
|
||||
"BIGINT" => "BIGINT".to_string(),
|
||||
"DECIMAL" | "NUMERIC" => sqlserver_decimal_type(params),
|
||||
"FLOAT" | "DOUBLE" | "DOUBLE PRECISION" | "REAL" => "FLOAT".to_string(),
|
||||
"CHAR" | "VARCHAR" => sqlserver_length_type(&base, params, 8000),
|
||||
"NCHAR" | "NVARCHAR" => sqlserver_length_type(&base, params, 4000),
|
||||
"TINYTEXT" | "TEXT" | "MEDIUMTEXT" | "LONGTEXT" | "JSON" | "ENUM" | "SET" => "NVARCHAR(MAX)".to_string(),
|
||||
"BINARY" | "VARBINARY" => sqlserver_length_type(&base, params, 8000),
|
||||
"TINYBLOB" | "BLOB" | "MEDIUMBLOB" | "LONGBLOB" => "VARBINARY(MAX)".to_string(),
|
||||
"DATE" => "DATE".to_string(),
|
||||
"TIME" => sqlserver_temporal_type("TIME", params),
|
||||
"DATETIME" | "TIMESTAMP" => sqlserver_temporal_type("DATETIME2", params),
|
||||
"YEAR" => "SMALLINT".to_string(),
|
||||
"GEOMETRY" => "GEOMETRY".to_string(),
|
||||
_ => return None,
|
||||
},
|
||||
DialectKind::Postgres => match base.as_str() {
|
||||
"BOOL" | "BOOLEAN" => "BIT".to_string(),
|
||||
"SMALLINT" | "INT2" | "SMALLSERIAL" => "SMALLINT".to_string(),
|
||||
"INTEGER" | "INT" | "INT4" | "SERIAL" => "INT".to_string(),
|
||||
"BIGINT" | "INT8" | "BIGSERIAL" => "BIGINT".to_string(),
|
||||
"NUMERIC" | "DECIMAL" => sqlserver_decimal_type(params),
|
||||
"REAL" | "FLOAT4" => "REAL".to_string(),
|
||||
"DOUBLE PRECISION" | "FLOAT8" => "FLOAT".to_string(),
|
||||
"CHAR" | "CHARACTER" | "VARCHAR" | "CHARACTER VARYING" => {
|
||||
sqlserver_length_type("NVARCHAR", params.or(Some("MAX")), 4000)
|
||||
}
|
||||
"TEXT" | "JSON" | "JSONB" | "ARRAY" => "NVARCHAR(MAX)".to_string(),
|
||||
"BIT VARYING" | "VARBIT" => "VARBINARY(MAX)".to_string(),
|
||||
"BYTEA" => "VARBINARY(MAX)".to_string(),
|
||||
"UUID" => "UNIQUEIDENTIFIER".to_string(),
|
||||
"DATE" => "DATE".to_string(),
|
||||
"TIME" if source_upper.contains("WITH TIME ZONE") && !source_upper.contains("WITHOUT TIME ZONE") => {
|
||||
sqlserver_temporal_type("DATETIMEOFFSET", params)
|
||||
}
|
||||
"TIME" | "TIME WITHOUT TIME ZONE" => sqlserver_temporal_type("TIME", params),
|
||||
"TIMETZ" | "TIME WITH TIME ZONE" => sqlserver_temporal_type("DATETIMEOFFSET", params),
|
||||
"TIMESTAMP" if source_upper.contains("WITH TIME ZONE") && !source_upper.contains("WITHOUT TIME ZONE") => {
|
||||
sqlserver_temporal_type("DATETIMEOFFSET", params)
|
||||
}
|
||||
"TIMESTAMP" | "TIMESTAMP WITHOUT TIME ZONE" => sqlserver_temporal_type("DATETIME2", params),
|
||||
"TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" => sqlserver_temporal_type("DATETIMEOFFSET", params),
|
||||
"XML" => "XML".to_string(),
|
||||
"MONEY" => "MONEY".to_string(),
|
||||
"INET" | "CIDR" | "MACADDR" | "MACADDR8" | "INTERVAL" => "NVARCHAR(100)".to_string(),
|
||||
_ if base.ends_with("[]") => "NVARCHAR(MAX)".to_string(),
|
||||
_ => return None,
|
||||
},
|
||||
DialectKind::Sqlite | DialectKind::DuckDb => match base.as_str() {
|
||||
"BOOL" | "BOOLEAN" => "BIT".to_string(),
|
||||
"TINYINT" => "TINYINT".to_string(),
|
||||
"SMALLINT" => "SMALLINT".to_string(),
|
||||
"INT" | "INTEGER" => "BIGINT".to_string(),
|
||||
"REAL" | "DOUBLE" | "DOUBLE PRECISION" | "FLOAT" => "FLOAT".to_string(),
|
||||
"DECIMAL" | "NUMERIC" => sqlserver_decimal_type(params),
|
||||
"CHAR" | "VARCHAR" => sqlserver_length_type("NVARCHAR", params.or(Some("MAX")), 4000),
|
||||
"TEXT" | "JSON" => "NVARCHAR(MAX)".to_string(),
|
||||
"BLOB" | "BYTEA" => "VARBINARY(MAX)".to_string(),
|
||||
"DATE" => "DATE".to_string(),
|
||||
"TIME" => sqlserver_temporal_type("TIME", params),
|
||||
"DATETIME" | "TIMESTAMP" => sqlserver_temporal_type("DATETIME2", params),
|
||||
"UUID" => "UNIQUEIDENTIFIER".to_string(),
|
||||
_ => return None,
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
Some(mapped)
|
||||
}
|
||||
|
||||
/// Rewrite a source column type for `target` database.
|
||||
///
|
||||
/// User field mappings must be applied by the caller *before* this function.
|
||||
pub fn rewrite_column_type(source_type: &str, target: DatabaseType, source_dialect: Option<DialectKind>) -> String {
|
||||
let profile = profile_for(target);
|
||||
if target == DatabaseType::SqlServer {
|
||||
let source = source_dialect.unwrap_or(DialectKind::SqlServer);
|
||||
if source == DialectKind::SqlServer {
|
||||
// Same-dialect metadata already contains a valid native type. The
|
||||
// generic display-width normalizer would otherwise truncate valid
|
||||
// SQL Server parameters such as VARBINARY(MAX), DATETIME2(7), and
|
||||
// DATETIMEOFFSET(7).
|
||||
return source_type.trim().to_string();
|
||||
}
|
||||
if let Some(mapped) = rewrite_cross_dialect_to_sqlserver(source_type, source) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
let (base, params) = split_type_base_params(source_type);
|
||||
|
||||
// Profile type_map (includes synthetic BOOL for TINYINT(1) when mapped)
|
||||
@@ -277,11 +445,64 @@ mod tests {
|
||||
profile.auto_inc,
|
||||
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(s) if s.contains("IDENTITY")
|
||||
));
|
||||
// No Access type map: display width stripped
|
||||
// MySQL display width is not part of the SQL Server type.
|
||||
let t = rewrite_column_type("int(11)", DatabaseType::SqlServer, Some(DialectKind::Mysql));
|
||||
assert_eq!(t, "INT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_rewrites_common_mysql_types() {
|
||||
let target = DatabaseType::SqlServer;
|
||||
let source = Some(DialectKind::Mysql);
|
||||
let cases = [
|
||||
("tinyint(1)", "BIT"),
|
||||
("tinyint", "SMALLINT"),
|
||||
("tinyint unsigned", "TINYINT"),
|
||||
("tinyint(1) unsigned", "TINYINT"),
|
||||
("tinyint(1) zerofill", "TINYINT"),
|
||||
("smallint unsigned", "INT"),
|
||||
("mediumint unsigned", "INT"),
|
||||
("int(11) unsigned", "BIGINT"),
|
||||
("integer zerofill", "BIGINT"),
|
||||
("bigint unsigned", "DECIMAL(20,0)"),
|
||||
("double", "FLOAT"),
|
||||
("decimal(65,30)", "DECIMAL(38,30)"),
|
||||
("varchar(9000)", "VARCHAR(MAX)"),
|
||||
("nvarchar(5000)", "NVARCHAR(MAX)"),
|
||||
("longtext", "NVARCHAR(MAX)"),
|
||||
("json", "NVARCHAR(MAX)"),
|
||||
("longblob", "VARBINARY(MAX)"),
|
||||
("datetime(9)", "DATETIME2(7)"),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(rewrite_column_type(input, target, source), expected, "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_rewrites_common_postgres_types_without_rewriting_its_own_timestamp() {
|
||||
let target = DatabaseType::SqlServer;
|
||||
let postgres = Some(DialectKind::Postgres);
|
||||
let cases = [
|
||||
("serial", "INT"),
|
||||
("boolean", "BIT"),
|
||||
("character varying(64)", "NVARCHAR(64)"),
|
||||
("text", "NVARCHAR(MAX)"),
|
||||
("bytea", "VARBINARY(MAX)"),
|
||||
("uuid", "UNIQUEIDENTIFIER"),
|
||||
("time(4) with time zone", "DATETIMEOFFSET(4)"),
|
||||
("timestamp(6) without time zone", "DATETIME2(6)"),
|
||||
("timestamp(6) with time zone", "DATETIMEOFFSET(6)"),
|
||||
("jsonb", "NVARCHAR(MAX)"),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(rewrite_column_type(input, target, postgres), expected, "{input}");
|
||||
}
|
||||
assert_eq!(rewrite_column_type("timestamp", target, Some(DialectKind::SqlServer)), "timestamp");
|
||||
assert_eq!(rewrite_column_type("varbinary(max)", target, None), "varbinary(max)");
|
||||
assert_eq!(rewrite_column_type("datetime2(7)", target, Some(DialectKind::SqlServer)), "datetime2(7)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_keeps_display_width() {
|
||||
let t = rewrite_column_type("int(11)", DatabaseType::Mysql, None);
|
||||
|
||||
@@ -23,6 +23,12 @@ pub use owner::build_table_owner_change_sql;
|
||||
pub use sqlite_rebuild::{apply_sqlite_table_structure_change, preview_sqlite_table_structure_change};
|
||||
pub use types::*;
|
||||
|
||||
pub(crate) use column_alter::{
|
||||
build_sqlserver_alter_column_preserving_default_sql, build_sqlserver_drop_default_constraint_sql,
|
||||
};
|
||||
pub(crate) use comments::{build_sqlserver_column_comment_sql, build_sqlserver_table_comment_sql};
|
||||
pub(crate) use util::sqlserver_unicode_string_literal;
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
use columns::{build_column_sql, validate_primary_key_change_scope};
|
||||
|
||||
@@ -739,7 +739,12 @@ pub(super) fn build_sqlserver_existing_column_sql(
|
||||
// SQL Server default constraints are separate objects that can block ALTER COLUMN.
|
||||
// Preserve the exact constraint name and expression when the default itself is unchanged.
|
||||
if has_column_definition_change && has_old_default && !has_default_change {
|
||||
statements.push(build_sqlserver_alter_column_preserving_default_sql(table, ¤t_name, column));
|
||||
statements.push(build_sqlserver_alter_column_preserving_default_sql(
|
||||
table,
|
||||
¤t_name,
|
||||
&column_data_type(dialect, column),
|
||||
column.is_nullable,
|
||||
));
|
||||
} else {
|
||||
if has_column_definition_change && has_old_default {
|
||||
statements.push(build_sqlserver_drop_default_constraint_sql(table, ¤t_name));
|
||||
@@ -760,15 +765,10 @@ pub(super) fn build_sqlserver_existing_column_sql(
|
||||
statements.push(build_sqlserver_drop_default_constraint_sql(table, ¤t_name));
|
||||
}
|
||||
if !default_value.is_empty() {
|
||||
let short_table =
|
||||
table.split('.').next_back().unwrap_or(table).trim_matches(|c: char| c == '[' || c == ']');
|
||||
let constraint_name = format!(
|
||||
"DF_{short_table}_{col_name}",
|
||||
short_table = short_table,
|
||||
col_name = current_name.trim_matches(|c: char| c == '[' || c == ']')
|
||||
);
|
||||
let constraint_name = format!("DF_{}_{}", table_name.trim(), current_name.trim());
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ADD CONSTRAINT [{constraint_name}] DEFAULT {} FOR {};",
|
||||
"ALTER TABLE {table} ADD CONSTRAINT {} DEFAULT {} FOR {};",
|
||||
quote_ident(dialect, &constraint_name),
|
||||
format_default_for_sql(StructureDialect::SqlServer, &column.data_type, &default_value),
|
||||
quote_ident(dialect, ¤t_name)
|
||||
));
|
||||
@@ -789,10 +789,11 @@ pub(super) fn build_sqlserver_existing_column_sql(
|
||||
statements
|
||||
}
|
||||
|
||||
fn build_sqlserver_alter_column_preserving_default_sql(
|
||||
pub(crate) fn build_sqlserver_alter_column_preserving_default_sql(
|
||||
table: &str,
|
||||
column_name: &str,
|
||||
column: &EditableStructureColumn,
|
||||
data_type: &str,
|
||||
is_nullable: bool,
|
||||
) -> String {
|
||||
let dialect = StructureDialect::SqlServer;
|
||||
let sql_var = sqlserver_default_constraint_sql_var(table, column_name);
|
||||
@@ -802,7 +803,7 @@ fn build_sqlserver_alter_column_preserving_default_sql(
|
||||
let column_literal = column_name.replace('\'', "''");
|
||||
let quoted_column = quote_ident(dialect, column_name);
|
||||
let quoted_column_literal = quoted_column.replace('\'', "''");
|
||||
let null_clause = if column.is_nullable { "NULL" } else { "NOT NULL" };
|
||||
let null_clause = if is_nullable { "NULL" } else { "NOT NULL" };
|
||||
|
||||
format!(
|
||||
"DECLARE {sql_var} NVARCHAR(MAX), {name_var} sysname, {definition_var} NVARCHAR(MAX); \
|
||||
@@ -812,11 +813,11 @@ fn build_sqlserver_alter_column_preserving_default_sql(
|
||||
IF {name_var} IS NOT NULL BEGIN SET {sql_var} = N'ALTER TABLE {table_literal} DROP CONSTRAINT ' + QUOTENAME({name_var}); EXEC sp_executesql {sql_var}; END; \
|
||||
ALTER TABLE {table} ALTER COLUMN {quoted_column} {data_type} {null_clause}; \
|
||||
IF {name_var} IS NOT NULL BEGIN SET {sql_var} = N'ALTER TABLE {table_literal} ADD CONSTRAINT ' + QUOTENAME({name_var}) + N' DEFAULT ' + {definition_var} + N' FOR {quoted_column_literal}'; EXEC sp_executesql {sql_var}; END;",
|
||||
data_type = column_data_type(dialect, column),
|
||||
data_type = data_type,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_sqlserver_drop_default_constraint_sql(table: &str, column_name: &str) -> String {
|
||||
pub(crate) fn build_sqlserver_drop_default_constraint_sql(table: &str, column_name: &str) -> String {
|
||||
let sql_var = sqlserver_default_constraint_sql_var(table, column_name);
|
||||
let table_literal = table.replace('\'', "''");
|
||||
let column_literal = column_name.replace('\'', "''");
|
||||
|
||||
@@ -50,30 +50,36 @@ pub(super) fn sqlserver_schema_name(schema: Option<&str>) -> String {
|
||||
schema.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()).unwrap_or_else(|| "dbo".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlserver_table_comment_sql(
|
||||
fn build_sqlserver_extended_property_comment_sql(exists: &str, levels: &str, new_comment: &str) -> Vec<String> {
|
||||
let new_comment = clean(new_comment);
|
||||
if new_comment.is_empty() {
|
||||
return vec![format!("IF {exists} EXEC sys.sp_dropextendedproperty @name=N'MS_Description', {levels};")];
|
||||
}
|
||||
|
||||
let escaped_comment = new_comment.replace('\'', "''");
|
||||
vec![format!(
|
||||
"IF {exists} EXEC sys.sp_updateextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels} ELSE EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels};"
|
||||
)]
|
||||
}
|
||||
|
||||
pub(crate) fn build_sqlserver_table_comment_sql(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
let mut statements = Vec::new();
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let exists = format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = 0 AND ep.name = N'MS_Description')"
|
||||
);
|
||||
let levels = format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}'"
|
||||
);
|
||||
|
||||
statements.push(format!(
|
||||
"IF EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{escaped_qualified}') AND minor_id = 0 AND name = N'MS_Description') EXEC sys.sp_dropextendedproperty @name=N'MS_Description', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}';"
|
||||
));
|
||||
|
||||
if !clean(new_comment).is_empty() {
|
||||
let quoted_comment = clean(new_comment).replace('\'', "''");
|
||||
statements.push(format!(
|
||||
"EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{quoted_comment}', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}';"
|
||||
));
|
||||
}
|
||||
|
||||
statements
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment)
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlserver_index_comment_sql(
|
||||
@@ -83,51 +89,97 @@ pub(super) fn build_sqlserver_index_comment_sql(
|
||||
index_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
let mut statements = Vec::new();
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let escaped_idx = index_name.replace('\'', "''");
|
||||
let exists = format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep INNER JOIN sys.indexes AS i ON i.object_id = ep.major_id AND i.index_id = ep.minor_id WHERE ep.class = 7 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND i.name = N'{escaped_idx}' AND ep.name = N'MS_Description')"
|
||||
);
|
||||
let levels = format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}'"
|
||||
);
|
||||
|
||||
statements.push(format!(
|
||||
"IF EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{escaped_qualified}') AND minor_id = 0 AND name = N'MS_Description' AND class_desc = 'INDEX') EXEC sys.sp_dropextendedproperty @name=N'MS_Description', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}';"
|
||||
));
|
||||
|
||||
if !clean(new_comment).is_empty() {
|
||||
let quoted_comment = clean(new_comment).replace('\'', "''");
|
||||
statements.push(format!(
|
||||
"EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{quoted_comment}', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}';"
|
||||
));
|
||||
}
|
||||
|
||||
statements
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment)
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlserver_column_comment_sql(
|
||||
pub(crate) fn build_sqlserver_column_comment_sql(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
let mut statements = Vec::new();
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let escaped_col = column_name.replace('\'', "''");
|
||||
let exists = format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(N'{escaped_qualified}'), N'{escaped_col}', 'ColumnId') AND ep.name = N'MS_Description')"
|
||||
);
|
||||
let levels = format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}'"
|
||||
);
|
||||
|
||||
statements.push(format!(
|
||||
"IF EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{escaped_qualified}') AND minor_id = COLUMNPROPERTY(OBJECT_ID(N'{escaped_qualified}'), N'{escaped_col}', 'ColumnId') AND name = N'MS_Description') EXEC sys.sp_dropextendedproperty @name=N'MS_Description', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}';"
|
||||
));
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment)
|
||||
}
|
||||
|
||||
if !clean(new_comment).is_empty() {
|
||||
let quoted_comment = clean(new_comment).replace('\'', "''");
|
||||
statements.push(format!(
|
||||
"EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{quoted_comment}', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}';"
|
||||
));
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlserver_table_comment_updates_or_adds_without_dropping() {
|
||||
let statements = build_sqlserver_table_comment_sql("[app's].[user's]", Some("app's"), "user's", "owner's 新值");
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("ep.class = 1"), "table extended-property class: {sql}");
|
||||
assert!(sql.contains("sys.sp_updateextendedproperty"), "update existing comment: {sql}");
|
||||
assert!(sql.contains("ELSE EXEC sys.sp_addextendedproperty"), "add missing comment: {sql}");
|
||||
assert!(!sql.contains("sys.sp_dropextendedproperty"), "non-empty comments are not dropped first: {sql}");
|
||||
assert!(sql.contains("OBJECT_ID(N'[app''s].[user''s]')"), "object name escaping: {sql}");
|
||||
assert!(sql.contains("@value=N'owner''s 新值'"), "Unicode comment escaping: {sql}");
|
||||
assert_eq!(
|
||||
crate::sql::split_sql_statements_for_database(sql, crate::models::connection::DatabaseType::SqlServer)
|
||||
.len(),
|
||||
1,
|
||||
"IF/ELSE comment upsert must remain one executable statement: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
statements
|
||||
#[test]
|
||||
fn sqlserver_empty_column_comment_drops_only_when_present() {
|
||||
let statements = build_sqlserver_column_comment_sql("[dbo].[orders]", None, "orders", "owner'id", " ");
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("COLUMNPROPERTY(OBJECT_ID(N'[dbo].[orders]'), N'owner''id', 'ColumnId')"));
|
||||
assert!(sql.contains("sys.sp_dropextendedproperty"), "drop existing comment: {sql}");
|
||||
assert!(!sql.contains("sys.sp_updateextendedproperty"), "empty comment must not update: {sql}");
|
||||
assert!(!sql.contains("sys.sp_addextendedproperty"), "empty comment must not add: {sql}");
|
||||
assert!(sql.contains("@level0name=N'dbo'"), "default schema: {sql}");
|
||||
assert_eq!(
|
||||
crate::sql::split_sql_statements_for_database(sql, crate::models::connection::DatabaseType::SqlServer)
|
||||
.len(),
|
||||
1,
|
||||
"conditional comment drop must remain one executable statement: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_index_comment_uses_index_extended_property_identity() {
|
||||
let statements =
|
||||
build_sqlserver_index_comment_sql("[dbo].[orders]", None, "orders", "ix_owner's", "index comment");
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("INNER JOIN sys.indexes AS i"), "index lookup: {sql}");
|
||||
assert!(sql.contains("ep.class = 7"), "index extended-property class: {sql}");
|
||||
assert!(sql.contains("i.name = N'ix_owner''s'"), "index name escaping: {sql}");
|
||||
assert!(sql.contains("sys.sp_updateextendedproperty"), "update existing comment: {sql}");
|
||||
assert!(sql.contains("ELSE EXEC sys.sp_addextendedproperty"), "add missing comment: {sql}");
|
||||
}
|
||||
}
|
||||
@@ -2525,7 +2525,7 @@ fn sqlserver_default_changes_drop_old_constraints_with_isolated_batches() {
|
||||
|
||||
assert_eq!(
|
||||
result.statements[1],
|
||||
"ALTER TABLE [core].[products] ADD CONSTRAINT [DF_products_sku] DEFAULT 'new sku' FOR [sku];"
|
||||
"ALTER TABLE [core].[products] ADD CONSTRAINT [DF_products_sku] DEFAULT N'new sku' FOR [sku];"
|
||||
);
|
||||
assert_eq!(
|
||||
result.statements[3],
|
||||
@@ -2599,6 +2599,35 @@ fn sqlserver_type_and_default_change_drops_before_alter_and_adds_new_default() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_generated_default_constraint_escapes_identifiers_and_unicode_values() {
|
||||
let mut owner = column("owner]id");
|
||||
owner.data_type = "nvarchar(40)".to_string();
|
||||
owner.default_value = "中文'值".to_string();
|
||||
owner.original = Some(ColumnInfo {
|
||||
name: "owner]id".to_string(),
|
||||
data_type: "nvarchar(40)".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: Some("N'旧值'".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
table_name: "order]s".to_string(),
|
||||
column: owner,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(result.statements.len(), 2);
|
||||
assert!(result.statements[0].contains("OBJECT_ID(N'[dbo].[order]]s]')"));
|
||||
assert_eq!(
|
||||
result.statements[1],
|
||||
"ALTER TABLE [dbo].[order]]s] ADD CONSTRAINT [DF_order]]s_owner]]id] DEFAULT N'中文''值' FOR [owner]]id];"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_rename_and_nullability_change_restores_default_on_new_column_name() {
|
||||
let mut renamed = column("is_enabled");
|
||||
|
||||
@@ -62,6 +62,26 @@ fn is_sql_string_literal(value: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn sqlserver_unicode_string_literal(value: &str) -> Option<String> {
|
||||
let mut candidate = value.trim();
|
||||
let mut parenthesis_depth = 0usize;
|
||||
|
||||
loop {
|
||||
if let Some(literal) = candidate.strip_prefix('N').or_else(|| candidate.strip_prefix('n')) {
|
||||
if is_sql_string_literal(literal) {
|
||||
return Some(format!("{}N{}{}", "(".repeat(parenthesis_depth), literal, ")".repeat(parenthesis_depth)));
|
||||
}
|
||||
}
|
||||
if is_sql_string_literal(candidate) {
|
||||
return Some(format!("{}N{}{}", "(".repeat(parenthesis_depth), candidate, ")".repeat(parenthesis_depth)));
|
||||
}
|
||||
|
||||
let inner = candidate.strip_prefix('(')?.strip_suffix(')')?;
|
||||
parenthesis_depth += 1;
|
||||
candidate = inner.trim();
|
||||
}
|
||||
}
|
||||
|
||||
fn postgres_string_default_literal(value: &str) -> Option<&str> {
|
||||
let trimmed = value.trim();
|
||||
let inner = trimmed.strip_prefix('\'')?;
|
||||
@@ -284,6 +304,17 @@ pub(super) fn format_default_for_sql(dialect: StructureDialect, data_type: &str,
|
||||
return quote_string(default_value);
|
||||
}
|
||||
if is_string_type_for_default(dialect, base_type) {
|
||||
if dialect == StructureDialect::SqlServer
|
||||
&& matches!(base_type.to_ascii_lowercase().as_str(), "nchar" | "nvarchar" | "ntext" | "sysname")
|
||||
{
|
||||
if let Some(literal) = sqlserver_unicode_string_literal(default_value) {
|
||||
return literal;
|
||||
}
|
||||
if default_value.contains('(') || default_value.contains(')') {
|
||||
return default_value.to_string();
|
||||
}
|
||||
return format!("N{}", quote_string(default_value));
|
||||
}
|
||||
// Only skip quoting for function-call expressions like `gen_random_uuid()`.
|
||||
// Simple identifiers like `CURRENT_TIMESTAMP` are not valid defaults for string columns.
|
||||
if is_sql_string_literal(default_value)
|
||||
@@ -316,3 +347,22 @@ pub(super) fn original_default(column: &EditableStructureColumn) -> String {
|
||||
pub(super) fn original_comment(column: &EditableStructureColumn) -> String {
|
||||
clean(column.original.as_ref().and_then(|original| original.comment.as_deref()).unwrap_or(""))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlserver_unicode_defaults_use_unicode_literals_without_double_quoting() {
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "中文"), "N'中文'");
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nchar(5)", "'中文'"), "N'中文'");
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "sysname", "n'guest'"), "N'guest'");
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "('中文')"), "(N'中文')");
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "((N'中文'))"), "((N'中文'))");
|
||||
assert_eq!(
|
||||
format_default_for_sql(StructureDialect::SqlServer, "nvarchar(max)", "CONCAT(N'a', N'b')"),
|
||||
"CONCAT(N'a', N'b')"
|
||||
);
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "varchar(50)", "中文"), "'中文'");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user