From 20c50550cf87c15b8db706749b804fe36ae5a1e5 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 27 Sep 2026 14:14:44 +0000 Subject: [PATCH] feat(schema-diff): add charset comparison option Closes #10396 --- .../src/components/diff/SchemaDiffDialog.vue | 2 +- .../__tests__/SchemaDiffOptionsPanel.spec.ts | 9 + .../useSchemaDiffConfig.charset.spec.ts | 66 +++++ .../__tests__/useSchemaDiffSession.spec.ts | 38 +++ .../src/composables/useSchemaDiffConfig.ts | 35 ++- .../src/composables/useSchemaDiffSession.ts | 1 + apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/lib/schema/schemaDiff.ts | 1 + .../src/lib/schema/schemaDiffOptions.ts | 5 +- apps/desktop/src/types/schemaDiff.ts | 3 + .../tests/api_contract_verification.rs | 2 + crates/dbx-sql-schema/src/schema_diff.rs | 229 +++++++++++++++++- 13 files changed, 380 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/composables/__tests__/useSchemaDiffConfig.charset.spec.ts diff --git a/apps/desktop/src/components/diff/SchemaDiffDialog.vue b/apps/desktop/src/components/diff/SchemaDiffDialog.vue index ffa3c21772..4196a7baf0 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDialog.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDialog.vue @@ -121,7 +121,7 @@ watch([sourceDbType, targetDbType], ([src, tgt]) => { }); const optionTree = computed(() => { const targetConfig = store.getConfig(targetConnectionId.value); - const dbType = targetConfig?.db_type || "postgres"; + const dbType = schemaDiffEngineDatabaseType(targetConfig) || targetConfig?.db_type || "postgres"; return getSchemaDiffOptionsForDbType(dbType); }); diff --git a/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts b/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts index 23ba99ed15..3edcdc5131 100644 --- a/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts +++ b/apps/desktop/src/components/diff/__tests__/SchemaDiffOptionsPanel.spec.ts @@ -58,6 +58,15 @@ describe("SchemaDiffOptionsPanel", () => { expect(normalized.ignoreColumnNameCase).toBe(false); }); + it("exposes charset comparison only for supported MySQL targets and defaults it on", () => { + expect(getSchemaDiffOptionsForDbType("mysql").map((item) => item.id)).toContain("compareCharset"); + expect(getSchemaDiffOptionsForDbType("goldendb").map((item) => item.id)).toContain("compareCharset"); + expect(getSchemaDiffOptionsForDbType("starrocks").map((item) => item.id)).not.toContain("compareCharset"); + expect(getSchemaDiffOptionsForDbType("doris").map((item) => item.id)).not.toContain("compareCharset"); + expect(getSchemaDiffOptionsForDbType("postgres").map((item) => item.id)).not.toContain("compareCharset"); + expect(normalizeSchemaDiffCompareOptions({}, "mysql").compareCharset).toBe(true); + }); + it("keeps unsaved checkbox edits when the parent refreshes equivalent options", async () => { const options = ref({ ...DEFAULT_POSTGRES_OPTIONS }); const optionTree: SchemaDiffOptionItem[] = [{ id: "views", labelKey: "views", defaultChecked: true }]; diff --git a/apps/desktop/src/composables/__tests__/useSchemaDiffConfig.charset.spec.ts b/apps/desktop/src/composables/__tests__/useSchemaDiffConfig.charset.spec.ts new file mode 100644 index 0000000000..82b482a6e6 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useSchemaDiffConfig.charset.spec.ts @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const CONFIG_KEY = "dbx-schema-diff-configs"; +const HISTORY_KEY = "dbx-schema-diff-history"; + +function storedConfig(compareCharset?: boolean) { + return { + id: "saved", + name: "Saved", + createdAt: 1, + updatedAt: 1, + sourceConnectionId: "source", + sourceDatabase: "app", + sourceSchema: "", + targetConnectionId: "target", + targetDatabase: "app_copy", + targetSchema: "", + options: { + tables: true, + ...(compareCharset === undefined ? {} : { compareCharset }), + }, + }; +} + +describe("schema diff charset option persistence", () => { + beforeEach(() => { + localStorage.clear(); + vi.resetModules(); + }); + + it("migrates legacy saved configs and history entries to comparison enabled", async () => { + localStorage.setItem(CONFIG_KEY, JSON.stringify([storedConfig()])); + localStorage.setItem(HISTORY_KEY, JSON.stringify([storedConfig()])); + + const { useSchemaDiffConfig } = await import("../useSchemaDiffConfig"); + const state = useSchemaDiffConfig(); + + expect(state.configs.value[0].options.compareCharset).toBe(true); + expect(state.recentConfigs.value[0].options.compareCharset).toBe(true); + expect(JSON.parse(localStorage.getItem(CONFIG_KEY) ?? "[]")[0].options.compareCharset).toBe(true); + expect(JSON.parse(localStorage.getItem(HISTORY_KEY) ?? "[]")[0].options.compareCharset).toBe(true); + }); + + it("preserves and persists an explicitly disabled value", async () => { + localStorage.setItem(CONFIG_KEY, JSON.stringify([storedConfig(false)])); + + const { useSchemaDiffConfig } = await import("../useSchemaDiffConfig"); + const state = useSchemaDiffConfig(); + state.activeConfigId.value = "saved"; + + expect(state.activeConfig.value?.options.compareCharset).toBe(false); + state.updateActiveConfigOptions({ ...state.activeConfig.value!.options, compareCharset: false }); + expect(JSON.parse(localStorage.getItem(CONFIG_KEY) ?? "[]")[0].options.compareCharset).toBe(false); + }); + + it("migrates legacy imported configs before persisting them", async () => { + const { useSchemaDiffConfig } = await import("../useSchemaDiffConfig"); + const state = useSchemaDiffConfig(); + + expect(state.importConfigs(JSON.stringify(storedConfig()), "replace")).toEqual({ imported: 1, renamed: 0 }); + expect(state.configs.value[0].options.compareCharset).toBe(true); + expect(JSON.parse(localStorage.getItem(CONFIG_KEY) ?? "[]")[0].options.compareCharset).toBe(true); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts b/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts index 8f672311ea..b56bc6713d 100644 --- a/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts @@ -31,6 +31,7 @@ async function waitForSession(session: { status: string }) { test("defaults enable tables and functions compare for common targets", () => { assert.equal(DEFAULT_MYSQL_OPTIONS.tables, true); assert.equal(DEFAULT_MYSQL_OPTIONS.functions, true); + assert.equal(DEFAULT_MYSQL_OPTIONS.compareCharset, true); assert.equal(getDefaultOptionsForDbType("oracle").tables, true); assert.equal(getDefaultOptionsForDbType("oracle").functions, true); assert.equal(getDefaultOptionsForDbType("mysql").tables, true); @@ -82,12 +83,49 @@ test("runs a schema diff session after the dialog is closed and retains the prep assert.equal(trackerMock.updateCompareTask.mock.calls.at(-1)?.[1].status, "Done"); // MySQL defaults now enable functions compare for same-dialect pairs. assert.equal(apiMock.listFunctions.mock.calls.length, 2); + assert.equal(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.compareCharset, true); const onOpen = trackerMock.addSchemaDiffTask.mock.calls[0]?.[2] as (() => void) | undefined; onOpen?.(); assert.equal(openMock.mock.calls.at(-1)?.[0], session.id); }); +test("forwards a disabled charset comparison to the backend", async () => { + apiMock.prepareSchemaDiff.mockClear(); + apiMock.prepareSchemaDiff.mockResolvedValue({ + diffs: [], + functionDiffs: [], + sequenceDiffs: [], + ruleDiffs: [], + ownerDiffs: [], + renameCandidates: [], + syncSql: "", + rollbackSyncSql: "", + }); + + const session = startSchemaDiffSession( + { + sourceConnectionId: "source", + sourceDatabase: "app", + sourceSchema: "", + targetConnectionId: "target", + targetDatabase: "warehouse", + targetSchema: "", + sourceDbType: "mysql", + targetDbType: "mysql", + options: { compareCharset: false }, + ignoreComments: false, + label: "charset disabled", + }, + { tableListLoader: { load: vi.fn().mockResolvedValue([]) } }, + ); + + await waitForSession(session); + + assert.equal(session.status, "completed"); + assert.equal(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.compareCharset, false); +}); + test("loads routines for mysql↔mysql when functions is enabled", async () => { apiMock.prepareSchemaDiff.mockClear(); apiMock.listFunctions.mockClear(); diff --git a/apps/desktop/src/composables/useSchemaDiffConfig.ts b/apps/desktop/src/composables/useSchemaDiffConfig.ts index 23f27b61bd..c991a19839 100644 --- a/apps/desktop/src/composables/useSchemaDiffConfig.ts +++ b/apps/desktop/src/composables/useSchemaDiffConfig.ts @@ -11,12 +11,29 @@ const configs = ref(loadConfigsFromStorage()); const activeConfigId = ref(""); const recentConfigs = ref(loadHistoryFromStorage()); +function migrateStoredConfig(config: SchemaDiffConfig): SchemaDiffConfig { + return { + ...config, + options: { + ...config.options, + compareCharset: config.options?.compareCharset ?? true, + }, + }; +} + +function parseStoredConfigs(raw: string | null, storageKey: string): SchemaDiffConfig[] { + if (!raw) return []; + const parsed = JSON.parse(raw) as SchemaDiffConfig[]; + if (!Array.isArray(parsed)) return []; + const migrated = parsed.map(migrateStoredConfig); + const serialized = JSON.stringify(migrated); + if (serialized !== raw) localStorage.setItem(storageKey, serialized); + return migrated; +} + function loadConfigsFromStorage(): SchemaDiffConfig[] { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw) as SchemaDiffConfig[]; - return Array.isArray(parsed) ? parsed : []; + return parseStoredConfigs(localStorage.getItem(STORAGE_KEY), STORAGE_KEY); } catch { return []; } @@ -24,10 +41,7 @@ function loadConfigsFromStorage(): SchemaDiffConfig[] { function loadHistoryFromStorage(): SchemaDiffConfig[] { try { - const raw = localStorage.getItem(HISTORY_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw) as SchemaDiffConfig[]; - return Array.isArray(parsed) ? parsed : []; + return parseStoredConfigs(localStorage.getItem(HISTORY_KEY), HISTORY_KEY); } catch { return []; } @@ -136,7 +150,8 @@ export function useSchemaDiffConfig() { } for (const item of items) { - let name = item.name; + const migrated = migrateStoredConfig(item); + let name = migrated.name; if (configs.value.some((c) => c.name === name && c.id !== item.id)) { let counter = 1; let candidate = `${name} (${counter})`; @@ -149,7 +164,7 @@ export function useSchemaDiffConfig() { } const newId = uuid(); configs.value.push({ - ...item, + ...migrated, id: newId, name, createdAt: Date.now(), diff --git a/apps/desktop/src/composables/useSchemaDiffSession.ts b/apps/desktop/src/composables/useSchemaDiffSession.ts index 1aa40ff153..8bd04a2b43 100644 --- a/apps/desktop/src/composables/useSchemaDiffSession.ts +++ b/apps/desktop/src/composables/useSchemaDiffSession.ts @@ -226,6 +226,7 @@ async function runSchemaDiffSession(session: SchemaDiffSession, dependencies: Sc ignoreComments: input.ignoreComments, cascadeDelete: sessionOptions.cascadeDelete ?? false, compareColumnOrder: sessionOptions.compareColumnOrder, + compareCharset: sessionOptions.compareCharset, ignoreTableNameCase: sessionOptions.ignoreTableNameCase, ignoreColumnNameCase: sessionOptions.ignoreColumnNameCase, detectRenames: sessionOptions.detectRenames ?? false, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index f5b7f7dcb4..de84c43351 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -7594,6 +7594,7 @@ export default { cascadeDelete: "Use CASCADE delete", sequenceLastValues: "Compare sequence last values", compareColumnOrder: "Compare column order", + compareCharset: "Compare character sets and collations", ignoreTableNameCase: "Ignore table name case", ignoreColumnNameCase: "Ignore column name case", detectRenames: "Detect renames", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 9d5a94601b..823b76de71 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -7560,6 +7560,7 @@ export default withEnglishFallback({ cascadeDelete: "使用级联删除", sequenceLastValues: "比较序列最后值", compareColumnOrder: "比较字段顺序", + compareCharset: "比较字符集和排序规则", ignoreTableNameCase: "忽略表名大小写", ignoreColumnNameCase: "忽略字段名大小写", detectRenames: "检测重命名", diff --git a/apps/desktop/src/lib/schema/schemaDiff.ts b/apps/desktop/src/lib/schema/schemaDiff.ts index 601c5efb44..e4ff8373fc 100644 --- a/apps/desktop/src/lib/schema/schemaDiff.ts +++ b/apps/desktop/src/lib/schema/schemaDiff.ts @@ -242,6 +242,7 @@ export interface SchemaDiffPreparationOptions { ignoreComments?: boolean; cascadeDelete?: boolean; compareColumnOrder?: boolean; + compareCharset?: boolean; ignoreTableNameCase?: boolean; ignoreColumnNameCase?: boolean; detectRenames?: boolean; diff --git a/apps/desktop/src/lib/schema/schemaDiffOptions.ts b/apps/desktop/src/lib/schema/schemaDiffOptions.ts index 309fa9cb27..cf0193e293 100644 --- a/apps/desktop/src/lib/schema/schemaDiffOptions.ts +++ b/apps/desktop/src/lib/schema/schemaDiffOptions.ts @@ -30,10 +30,13 @@ export const POSTGRES_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [ { id: "enableRollback", labelKey: "schemaDiff.options.enableRollback", defaultChecked: false }, ]; +export const MYSQL_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [...POSTGRES_SCHEMA_DIFF_OPTIONS, { id: "compareCharset", labelKey: "schemaDiff.options.compareCharset", defaultChecked: true }]; + export const SCHEMA_DIFF_OPTIONS_BY_DB_TYPE: Record = { postgres: POSTGRES_SCHEMA_DIFF_OPTIONS, opengauss: POSTGRES_SCHEMA_DIFF_OPTIONS, - // mysql: [...] 后续扩展 + mysql: MYSQL_SCHEMA_DIFF_OPTIONS, + goldendb: MYSQL_SCHEMA_DIFF_OPTIONS, // sqlserver: [...] 后续扩展 }; diff --git a/apps/desktop/src/types/schemaDiff.ts b/apps/desktop/src/types/schemaDiff.ts index 4fe9b9bb1a..61c3df1d47 100644 --- a/apps/desktop/src/types/schemaDiff.ts +++ b/apps/desktop/src/types/schemaDiff.ts @@ -27,6 +27,7 @@ export interface SchemaDiffCompareOptions { cascadeDelete: boolean; sequenceLastValues: boolean; compareColumnOrder: boolean; + compareCharset: boolean; ignoreTableNameCase: boolean; ignoreColumnNameCase: boolean; tableIncludePattern: string; @@ -114,6 +115,7 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = { cascadeDelete: false, sequenceLastValues: true, compareColumnOrder: false, + compareCharset: true, ignoreTableNameCase: false, ignoreColumnNameCase: false, tableIncludePattern: "", @@ -151,6 +153,7 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = { cascadeDelete: false, sequenceLastValues: false, compareColumnOrder: false, + compareCharset: true, ignoreTableNameCase: false, ignoreColumnNameCase: false, tableIncludePattern: "", diff --git a/crates/dbx-core/tests/api_contract_verification.rs b/crates/dbx-core/tests/api_contract_verification.rs index 42b6796bfa..e497e386b3 100644 --- a/crates/dbx-core/tests/api_contract_verification.rs +++ b/crates/dbx-core/tests/api_contract_verification.rs @@ -51,6 +51,7 @@ fn prepare_schema_diff_function_signature() { ignore_comments: false, cascade_delete: false, compare_column_order: false, + compare_charset: true, ignore_table_name_case: false, ignore_column_name_case: false, detect_renames: false, @@ -152,6 +153,7 @@ fn schema_diff_preparation_field_names() { ignore_comments: false, cascade_delete: false, compare_column_order: false, + compare_charset: true, ignore_table_name_case: false, ignore_column_name_case: false, detect_renames: false, diff --git a/crates/dbx-sql-schema/src/schema_diff.rs b/crates/dbx-sql-schema/src/schema_diff.rs index 13e075b167..e85d74ea52 100644 --- a/crates/dbx-sql-schema/src/schema_diff.rs +++ b/crates/dbx-sql-schema/src/schema_diff.rs @@ -449,6 +449,8 @@ pub struct SchemaDiffPreparationOptions { pub cascade_delete: bool, #[serde(default)] pub compare_column_order: bool, + #[serde(default = "default_compare_charset")] + pub compare_charset: bool, #[serde(default)] pub ignore_table_name_case: bool, #[serde(default)] @@ -483,6 +485,10 @@ pub struct SchemaDiffPreparationOptions { pub table_mappings: Vec, } +const fn default_compare_charset() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct MissingRollbackObject { @@ -1167,6 +1173,7 @@ pub fn diff_columns_with_compatibility( compatibility_threshold, field_mappings, false, + source_dialect == DialectKind::Mysql && target_dialect == DialectKind::Mysql, ) } @@ -1181,6 +1188,7 @@ fn diff_columns_with_compatibility_options( compatibility_threshold: f64, field_mappings: &[FieldMapping], ignore_column_name_case: bool, + compare_charset: bool, ) -> (Vec, Vec) { use crate::sql_dialect::descriptor::TypeMappingMatrix; @@ -1197,6 +1205,7 @@ fn diff_columns_with_compatibility_options( None, None, ignore_column_name_case, + compare_charset, ); let mut warnings = Vec::new(); @@ -1647,6 +1656,7 @@ pub fn shard_diff(options: &SchemaDiffPreparationOptions, shard_strategy: &Shard ignore_comments: options.ignore_comments, cascade_delete: options.cascade_delete, compare_column_order: options.compare_column_order, + compare_charset: options.compare_charset, ignore_table_name_case: options.ignore_table_name_case, ignore_column_name_case: options.ignore_column_name_case, source_dialect: options.source_dialect, @@ -1920,6 +1930,7 @@ impl Default for SchemaDiffPreparationOptions { ignore_comments: false, cascade_delete: false, compare_column_order: false, + compare_charset: true, ignore_table_name_case: false, ignore_column_name_case: false, detect_renames: false, @@ -2090,6 +2101,7 @@ pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffP options.compatibility_threshold, &options.field_mappings, options.ignore_column_name_case, + compare_mysql_column_charset(&options), ); all_warnings.extend(warnings); } @@ -2278,6 +2290,17 @@ fn resolve_schema_diff_table_names( SchemaDiffTableNameResolution { pairs, source_only, target_only } } +fn compare_mysql_column_charset(options: &SchemaDiffPreparationOptions) -> bool { + if !options.compare_charset || !matches!(options.database_type, DatabaseType::Mysql | DatabaseType::Goldendb) { + return false; + } + + let target_dialect = + options.target_dialect.unwrap_or_else(|| DialectKind::from_database_type(options.database_type)); + let source_dialect = options.source_dialect.unwrap_or(target_dialect); + source_dialect == DialectKind::Mysql && target_dialect == DialectKind::Mysql +} + fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { let source_details: HashMap<&str, &TableSchemaDetail> = options.source_details.iter().map(|detail| (detail.name.as_str(), detail)).collect(); @@ -2573,6 +2596,7 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { options.source_dialect, options.target_dialect, options.ignore_column_name_case, + compare_mysql_column_charset(options), ); let index_diffs = diff_indexes_with_options(&source.indexes, &target.indexes, options.ignore_column_name_case); let normalized_source_fks: Vec = source @@ -3164,6 +3188,17 @@ fn column_types_equal_for_dialects( } } +fn mysql_charset_value(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn mysql_charset_values_differ(source: Option<&str>, target: Option<&str>) -> bool { + match (mysql_charset_value(source), mysql_charset_value(target)) { + (Some(source), Some(target)) => !source.eq_ignore_ascii_case(target), + _ => false, + } +} + fn column_type_similarity_score(source_type: &str, target_type: &str) -> f64 { let s = ColumnType::parse(source_type).base_type.to_ascii_lowercase(); let t = ColumnType::parse(target_type).base_type.to_ascii_lowercase(); @@ -3237,6 +3272,7 @@ fn diff_columns_with_dialect_options( source_dialect, target_dialect, false, + source_dialect == Some(DialectKind::Mysql) && target_dialect == Some(DialectKind::Mysql), ) } @@ -3288,6 +3324,7 @@ fn diff_columns_with_identifier_options( source_dialect: Option, target_dialect: Option, ignore_column_name_case: bool, + compare_charset: bool, ) -> Vec { let mut diffs = Vec::new(); let column_matches = resolve_column_matches(source, target, ignore_column_name_case); @@ -3322,6 +3359,27 @@ fn diff_columns_with_identifier_options( source_column.column_default.as_deref().unwrap_or("NULL") )); } + if compare_charset + && mysql_charset_values_differ( + source_column.character_set.as_deref(), + target_column.character_set.as_deref(), + ) + { + changes.push(format!( + "character set: {} → {}", + mysql_charset_value(target_column.character_set.as_deref()).unwrap_or_default(), + mysql_charset_value(source_column.character_set.as_deref()).unwrap_or_default() + )); + } + if compare_charset + && mysql_charset_values_differ(source_column.collation.as_deref(), target_column.collation.as_deref()) + { + changes.push(format!( + "collation: {} → {}", + mysql_charset_value(target_column.collation.as_deref()).unwrap_or_default(), + mysql_charset_value(source_column.collation.as_deref()).unwrap_or_default() + )); + } if !ignore_comments && source_column.comment.as_deref().unwrap_or_default() != target_column.comment.as_deref().unwrap_or_default() @@ -4091,11 +4149,28 @@ fn column_modifier_tail( } fn column_def(col: &ColumnInfo, db_type: DatabaseType, source_dialect: Option) -> String { + column_def_with_charset(col, db_type, source_dialect, false) +} + +fn column_def_with_charset( + col: &ColumnInfo, + db_type: DatabaseType, + source_dialect: Option, + include_charset: bool, +) -> String { if db_type == DatabaseType::SqlServer { return sqlserver_column_definition(col, &col.data_type, source_dialect, None); } let profile = profile_for(db_type); let mut definition = format!("{} {}", quote_id(&col.name, db_type), col.data_type); + if include_charset && matches!(db_type, DatabaseType::Mysql | DatabaseType::Goldendb) { + if let Some(character_set) = mysql_charset_value(col.character_set.as_deref()) { + definition.push_str(&format!(" CHARACTER SET {}", quote_id(character_set, db_type))); + } + if let Some(collation) = mysql_charset_value(col.collation.as_deref()) { + definition.push_str(&format!(" COLLATE {}", quote_id(collation, db_type))); + } + } definition.push_str(&column_modifier_tail(&profile, col, &col.data_type, db_type, source_dialect, false)); // Suffix-style auto-increment is only valid in MySQL-family ALTER clauses // (ADD/MODIFY/CHANGE). Other dialects' identity clauses are order-sensitive @@ -6327,9 +6402,12 @@ fn generate_schema_sync_sql_inner( } else if profile.alter_uses_modify_column { if column.changes.iter().any(|change| !change.starts_with("order:")) { let modify_keyword = profile.alter_modify_keyword(); + let include_charset = column.changes.iter().any(|change| { + change.starts_with("character set:") || change.starts_with("collation:") + }); parts.push(format!( " {modify_keyword} {}", - column_def(&mapped, db_type, source_dialect) + column_def_with_charset(&mapped, db_type, source_dialect, include_charset) )); } } else { @@ -6796,6 +6874,154 @@ mod tests { } } + fn mysql_charset_options(compare_charset: bool) -> SchemaDiffPreparationOptions { + let mut source_column = column("name", "varchar(64)", None); + source_column.character_set = Some("utf8mb4".to_string()); + source_column.collation = Some("utf8mb4_0900_ai_ci".to_string()); + let mut target_column = column("name", "varchar(64)", None); + target_column.character_set = Some("latin1".to_string()); + target_column.collation = Some("latin1_swedish_ci".to_string()); + + SchemaDiffPreparationOptions { + source_tables: vec![table_info("users", "TABLE")], + target_tables: vec![table_info("users", "TABLE")], + source_details: vec![TableSchemaDetail { + name: "users".to_string(), + columns: vec![source_column], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + ddl: None, + }], + target_details: vec![TableSchemaDetail { + name: "users".to_string(), + columns: vec![target_column], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + ddl: None, + }], + database_type: DatabaseType::Mysql, + source_dialect: Some(DialectKind::Mysql), + target_dialect: Some(DialectKind::Mysql), + compare_charset, + ..Default::default() + } + } + + #[test] + fn mysql_charset_comparison_is_enabled_by_default_and_serializes_as_camel_case() { + let legacy: SchemaDiffPreparationOptions = + serde_json::from_value(serde_json::json!({ "databaseType": "mysql" })).unwrap(); + assert!(legacy.compare_charset); + + let json = serde_json::to_value(legacy).unwrap(); + assert_eq!(json["compareCharset"], true); + assert!(json.get("compare_charset").is_none()); + + let disabled: SchemaDiffPreparationOptions = + serde_json::from_value(serde_json::json!({ "databaseType": "mysql", "compareCharset": false })).unwrap(); + assert!(!disabled.compare_charset); + } + + #[test] + fn mysql_charset_only_difference_is_reported_when_enabled() { + let result = prepare_schema_diff(mysql_charset_options(true)); + let columns = result.diffs[0].columns.as_ref().expect("column diff"); + + assert_eq!( + columns[0].changes, + vec![ + "character set: latin1 → utf8mb4".to_string(), + "collation: latin1_swedish_ci → utf8mb4_0900_ai_ci".to_string(), + ] + ); + assert!(result.sync_sql.contains("CHARACTER SET `utf8mb4` COLLATE `utf8mb4_0900_ai_ci`")); + } + + #[test] + fn mysql_charset_only_difference_is_suppressed_when_disabled_and_restored_when_reenabled() { + assert!(prepare_schema_diff(mysql_charset_options(false)).diffs.is_empty()); + assert_eq!(prepare_schema_diff(mysql_charset_options(true)).diffs.len(), 1); + } + + #[test] + fn mysql_charset_missing_metadata_is_unknown() { + let mut options = mysql_charset_options(true); + options.target_details[0].columns[0].character_set = None; + options.target_details[0].columns[0].collation = None; + options.source_details[0].ddl = + Some("CREATE TABLE users (name varchar(64)) DEFAULT CHARSET=utf8mb4".to_string()); + options.target_details[0].ddl = + Some("CREATE TABLE users (name varchar(64)) DEFAULT CHARSET=latin1".to_string()); + + assert!(prepare_schema_diff(options).diffs.is_empty()); + } + + #[test] + fn goldendb_charset_metadata_uses_the_mysql_dialect() { + let mut options = mysql_charset_options(true); + options.database_type = DatabaseType::Goldendb; + options.source_dialect = None; + options.target_dialect = None; + + let result = prepare_schema_diff(options); + assert_eq!(result.diffs.len(), 1); + assert!(result.sync_sql.contains("CHARACTER SET `utf8mb4` COLLATE `utf8mb4_0900_ai_ci`")); + } + + #[test] + fn disabling_mysql_charset_comparison_preserves_other_column_and_key_differences() { + let mut options = mysql_charset_options(false); + let source_column = &mut options.source_details[0].columns[0]; + source_column.data_type = "varchar(128)".to_string(); + source_column.is_nullable = false; + source_column.column_default = Some("source".to_string()); + source_column.comment = Some("source comment".to_string()); + let target_column = &mut options.target_details[0].columns[0]; + target_column.is_nullable = true; + target_column.column_default = Some("target".to_string()); + target_column.comment = Some("target comment".to_string()); + options.source_details[0].indexes.push(index(IndexInfo { + name: "uq_users_name".to_string(), + columns: vec!["name".to_string()], + is_unique: true, + is_primary: false, + filter: None, + index_type: None, + included_columns: None, + comment: None, + key_is_expression: Vec::new(), + column_opclasses: Vec::new(), + key_options: Vec::new(), + constraint_backed: false, + })); + + let result = prepare_schema_diff(options); + let table = &result.diffs[0]; + let column_changes = &table.columns.as_ref().expect("column diff")[0].changes; + assert!(column_changes.iter().any(|change| change.starts_with("type:"))); + assert!(column_changes.iter().any(|change| change.starts_with("nullable:"))); + assert!(column_changes.iter().any(|change| change.starts_with("default:"))); + assert!(column_changes.iter().any(|change| change.starts_with("comment:"))); + assert!(!column_changes.iter().any(|change| change.starts_with("character set:"))); + assert!(!column_changes.iter().any(|change| change.starts_with("collation:"))); + assert!(table.indexes.as_ref().is_some_and(|indexes| { + indexes.iter().any(|index| { + index.diff_type == "added" + && index.source.as_ref().is_some_and(|info| info.is_unique && info.name == "uq_users_name") + }) + })); + } + + #[test] + fn mysql_charset_metadata_is_not_compared_unless_both_dialects_are_mysql() { + let mut options = mysql_charset_options(true); + options.source_dialect = Some(DialectKind::Postgres); + + assert!(prepare_schema_diff(options).diffs.is_empty()); + } + #[test] fn oracle_schema_sync_drops_indexes_without_if_exists() { assert_eq!( @@ -13058,6 +13284,7 @@ mod tests { ignore_comments: false, cascade_delete: false, compare_column_order: false, + compare_charset: true, ignore_table_name_case: false, ignore_column_name_case: false, detect_renames: false,