fix(sqlserver): complete schema diff DDL support

This commit is contained in:
KuriyamaMirai authored and GitHub committed 2026-08-26 13:21:50 +08:00
1 parent 2b7d353313
commit a4bf8442d7
13 files changed
+3301 -282

No files matched your search

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}");
}
}
+132 -7
View File
@@ -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;";
+11 -6
View File
@@ -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 &[
@@ -202,7 +202,6 @@ expression: all
"drop_sequence",
"alter_owner",
"grant_revoke",
"if_not_exists",
"temporary_table",
"transactional_ddl",
"identity_columns"
@@ -5,7 +5,7 @@ expression: desc
---
{
"dialect": "sql_server",
"flags": 3086983151,
"flags": 3019874287,
"max_identifier_length": 128,
"supports_schemas": true,
"supports_catalogs": true,
+222 -1
View File
@@ -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, &current_name, column));
statements.push(build_sqlserver_alter_column_preserving_default_sql(
table,
&current_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, &current_name));
@@ -760,15 +765,10 @@ pub(super) fn build_sqlserver_existing_column_sql(
statements.push(build_sqlserver_drop_default_constraint_sql(table, &current_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, &current_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)", "中文"), "'中文'");
}
}