feat(mysql): edit column charset and collation

This commit is contained in:
DengQingNian authored and GitHub committed 2026-07-10 15:13:57 +08:00
1 parent 0674433eea
commit e429c8945b
38 files changed
+524 -20

No files matched your search

@@ -1,4 +1,4 @@
use super::column_format::{clickhouse_column_type, column_data_type, column_definition};
use super::column_format::{clickhouse_column_type, column_data_type, column_definition, is_mysql_character_data_type};
use super::columns::build_drop_column_sql;
use super::comments::build_sqlserver_column_comment_sql;
use super::dialect::{capabilities_for, database_label, StructureDialect};
@@ -54,7 +54,10 @@ pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> Ta
let has_attribute_change = options.column.data_type.trim() != original.data_type.trim()
|| options.column.is_nullable != original.is_nullable
|| normalize_default(Some(&options.column.default_value)) != original_default(&options.column)
|| clean(&options.column.comment) != original_comment(&options.column);
|| clean(&options.column.comment) != original_comment(&options.column)
|| (is_mysql_character_data_type(&options.column.data_type)
&& (options.column.character_set.trim() != original.character_set.as_deref().unwrap_or("")
|| options.column.collation.trim() != original.collation.as_deref().unwrap_or("")));
if has_rename && !capabilities.rename_column {
warnings.push(format!("Renaming columns is not supported for {database_label} from this editor."));
@@ -727,4 +730,7 @@ pub(super) fn has_existing_column_attribute_change(column: &EditableStructureCol
|| column.is_nullable != original.is_nullable
|| normalize_default(Some(&column.default_value)) != original_default(column)
|| clean(&column.comment) != original_comment(column)
|| (is_mysql_character_data_type(&column.data_type)
&& (column.character_set.trim() != original.character_set.as_deref().unwrap_or("")
|| column.collation.trim() != original.collation.as_deref().unwrap_or("")))
}
@@ -10,6 +10,15 @@ pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStru
parts.insert(0, "IF NOT EXISTS".to_string());
return parts.join(" ");
}
// CHARACTER SET / COLLATE — MySQL character data types only
if dialect == StructureDialect::Mysql && is_mysql_character_data_type(&column.data_type) {
if !column.character_set.trim().is_empty() {
parts.push(format!("CHARACTER SET {}", quote_ident(dialect, &column.character_set)));
}
if !column.collation.trim().is_empty() {
parts.push(format!("COLLATE {}", quote_ident(dialect, &column.collation)));
}
}
if !column.is_nullable && !is_oracle_like(dialect) && dialect != StructureDialect::ClickHouse {
parts.push("NOT NULL".to_string());
}
@@ -329,3 +338,20 @@ pub(super) fn questdb_column_type(column: &EditableStructureColumn) -> String {
None => data_type,
}
}
/// Returns `true` when the data type is a MySQL character/string type that accepts
/// `CHARACTER SET` and `COLLATE` clauses in column definitions.
///
/// Character types that support per-column charset/collation:
/// `char`, `varchar`, `tinytext`, `text`, `mediumtext`, `longtext`, `enum`, `set`
///
/// Numeric, temporal, spatial, binary/blob, and JSON types do NOT support these clauses.
pub(super) fn is_mysql_character_data_type(data_type: &str) -> bool {
let trimmed = data_type.trim();
let base_type = match trimmed.find('(') {
Some(open_index) => trimmed[..open_index].trim(),
None => trimmed,
};
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
matches!(normalized.as_str(), "char" | "varchar" | "tinytext" | "text" | "mediumtext" | "longtext" | "enum" | "set")
}
@@ -4,7 +4,7 @@ use super::column_alter::{
build_postgres_existing_column_sql, build_questdb_existing_column_sql, build_sqlite_existing_column_sql,
build_sqlserver_existing_column_sql, has_column_extra_change, has_existing_column_attribute_change,
};
use super::column_format::column_definition;
use super::column_format::{column_definition, is_mysql_character_data_type};
use super::comments::build_sqlserver_column_comment_sql;
use super::dialect::{capabilities_for, database_label, StructureDialect};
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
@@ -119,6 +119,9 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu
|| column.is_nullable != original.is_nullable
|| normalize_default(Some(&column.default_value)) != original_default(column)
|| clean(&column.comment) != original_comment(column)
|| (is_mysql_character_data_type(&column.data_type)
&& (column.character_set.trim() != original.character_set.as_deref().unwrap_or("")
|| column.collation.trim() != original.collation.as_deref().unwrap_or("")))
|| has_column_extra_change(column);
if has_position_change && !capabilities.reorder_column {
warnings.push(format!("Reordering columns is not supported for {database_label} from this editor."));
@@ -14,6 +14,8 @@ fn column(name: &str) -> EditableStructureColumn {
original: None,
original_position: None,
marked_for_drop: false,
character_set: String::new(),
collation: String::new(),
}
}
@@ -75,6 +77,7 @@ fn builds_mysql_column_and_index_changes() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let mut email = column("email");
email.is_nullable = false;
@@ -151,6 +154,7 @@ fn doris_table_editor_renames_column_without_mysql_change_syntax() {
is_primary_key: false,
extra: None,
comment: Some("Group DTP".to_string()),
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -182,6 +186,7 @@ fn doris_single_column_alter_renames_then_modifies_column_definition() {
is_primary_key: false,
extra: None,
comment: Some("Division DTP".to_string()),
..Default::default()
});
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
@@ -283,6 +288,7 @@ fn builds_informix_column_and_index_changes() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let mut email = column("email");
email.is_nullable = false;
@@ -296,6 +302,7 @@ fn builds_informix_column_and_index_changes() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut old_index = index("idx_old", &["name"]);
old_index.marked_for_drop = true;
@@ -350,6 +357,7 @@ fn oracle_does_not_generate_drop_sql_for_all_columns() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut name = column("name");
name.marked_for_drop = true;
@@ -361,6 +369,7 @@ fn oracle_does_not_generate_drop_sql_for_all_columns() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -397,6 +406,7 @@ fn oracle_timestamp_default_precedes_nullability_in_modify_sql() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
@@ -425,6 +435,7 @@ fn oracle_timestamp_precision_change_does_not_repeat_unchanged_nullability() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
@@ -535,6 +546,7 @@ fn manticoresearch_builds_add_and_drop_column_sql() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut name = column("name");
@@ -580,6 +592,7 @@ fn gbase8a_uses_limited_mysql_ddl() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let new_col = column("nickname");
let mut old_col = column("old_col");
@@ -592,6 +605,7 @@ fn gbase8a_uses_limited_mysql_ddl() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut index = index("idx_users_email", &["display_email"]);
index.original = Some(IndexInfo {
@@ -643,6 +657,7 @@ fn gbase8a_allows_mysql_style_column_reorder() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut name = column("name");
@@ -655,6 +670,7 @@ fn gbase8a_allows_mysql_style_column_reorder() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut email = column("email");
@@ -667,6 +683,7 @@ fn gbase8a_allows_mysql_style_column_reorder() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -698,6 +715,7 @@ fn manticoresearch_does_not_drop_id_column() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -734,6 +752,7 @@ fn manticoresearch_warns_when_existing_column_properties_change() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut resource = column("resource");
@@ -747,6 +766,7 @@ fn manticoresearch_warns_when_existing_column_properties_change() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut old_resource = column("old_resource");
@@ -760,6 +780,7 @@ fn manticoresearch_warns_when_existing_column_properties_change() {
is_primary_key: false,
extra: Some("secondary_index='1'".to_string()),
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -997,6 +1018,7 @@ fn warns_for_sqlite_unsafe_column_changes() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1084,6 +1106,7 @@ fn builds_mysql_column_reorder_statements() {
is_primary_key: true,
extra: None,
comment: None,
..Default::default()
});
let mut email = column("email");
@@ -1096,6 +1119,7 @@ fn builds_mysql_column_reorder_statements() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut name = column("display_name");
@@ -1110,6 +1134,7 @@ fn builds_mysql_column_reorder_statements() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1143,6 +1168,7 @@ fn mysql_add_column_before_existing_column_does_not_reorder_shifted_column() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let new_column = column("sss");
@@ -1161,6 +1187,7 @@ fn mysql_add_column_before_existing_column_does_not_reorder_shifted_column() {
is_primary_key: false,
extra: None,
comment: Some("tenant id".to_string()),
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1194,6 +1221,7 @@ fn mysql_existing_column_reorder_does_not_reorder_columns_shifted_by_prior_move(
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut name = column("name");
@@ -1206,6 +1234,7 @@ fn mysql_existing_column_reorder_does_not_reorder_columns_shifted_by_prior_move(
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut email = column("email");
@@ -1218,6 +1247,7 @@ fn mysql_existing_column_reorder_does_not_reorder_columns_shifted_by_prior_move(
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1250,6 +1280,7 @@ fn mysql_moving_first_column_to_end_uses_single_reorder_statement() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut col_1 = column("col_1");
@@ -1262,6 +1293,7 @@ fn mysql_moving_first_column_to_end_uses_single_reorder_statement() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut col_2 = column("col_2");
@@ -1274,6 +1306,7 @@ fn mysql_moving_first_column_to_end_uses_single_reorder_statement() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut col_3 = column("col_3");
@@ -1286,6 +1319,7 @@ fn mysql_moving_first_column_to_end_uses_single_reorder_statement() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1411,6 +1445,7 @@ fn sqlserver_default_changes_drop_old_constraints_with_isolated_batches() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let mut active = column("active");
@@ -1425,6 +1460,7 @@ fn sqlserver_default_changes_drop_old_constraints_with_isolated_batches() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1610,6 +1646,7 @@ fn sqlserver_unchanged_identity_extra_does_not_mark_existing_column_changed() {
is_primary_key: true,
extra: Some("IDENTITY(1,1)".to_string()),
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1647,6 +1684,7 @@ fn sqlserver_existing_column_identity_change_warns_without_unchanged_foreign_key
is_primary_key: true,
extra: None,
comment: None,
..Default::default()
});
let mut user_fk = foreign_key("fk_orders_user_id", "user_id", "users", "id");
@@ -1730,6 +1768,7 @@ fn builds_clickhouse_nullable_comment_and_reorder_statements() {
is_primary_key: false,
extra: None,
comment: Some("old status".to_string()),
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1773,6 +1812,7 @@ fn builds_h2_schema_qualified_existing_column_statements() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1815,6 +1855,7 @@ fn builds_postgres_alter_table_add_primary_key() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1847,6 +1888,7 @@ fn builds_postgres_alter_table_drop_primary_key() {
is_primary_key: true,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1880,6 +1922,7 @@ fn builds_mysql_alter_table_change_primary_key() {
is_primary_key: true,
extra: None,
comment: None,
..Default::default()
});
let mut new_pk = column("uuid");
@@ -1895,6 +1938,7 @@ fn builds_mysql_alter_table_change_primary_key() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1930,6 +1974,7 @@ fn builds_no_statements_when_primary_key_unchanged() {
is_primary_key: true,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -1962,6 +2007,7 @@ fn warns_sqlite_cannot_alter_primary_key() {
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
@@ -2230,6 +2276,7 @@ fn postgres_timestamp_literal_is_quoted() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
@@ -2255,6 +2302,7 @@ fn mysql_single_column_alter_quotes_datetime_literal() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
@@ -2498,6 +2546,7 @@ fn postgres_varchar_default_is_quoted() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
@@ -2523,6 +2572,7 @@ fn postgres_empty_string_default_is_not_quoted_again() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
@@ -2548,6 +2598,7 @@ fn postgres_string_default_cast_matches_plain_literal() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
@@ -2573,6 +2624,7 @@ fn postgres_integer_default_is_not_quoted() {
is_primary_key: false,
extra: None,
comment: Some(String::new()),
..Default::default()
});
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
@@ -2584,3 +2636,174 @@ fn postgres_integer_default_is_not_quoted() {
assert_eq!(result.statements, vec!["ALTER TABLE \"core\".\"products\" ALTER COLUMN \"stock\" SET DEFAULT 0;"]);
}
#[test]
fn mysql_character_column_add_with_charset_collation() {
let mut col = column("name");
col.data_type = "varchar(255)".to_string();
col.character_set = "utf8mb4".to_string();
col.collation = "utf8mb4_unicode_ci".to_string();
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "users".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"ALTER TABLE `users` ADD COLUMN `name` varchar(255) CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci`;"
]
);
}
#[test]
fn mysql_numeric_column_omits_charset_collation_in_column_definition() {
let mut col = column("score");
col.data_type = "int".to_string();
// Even if charset/collation are set on the editable column, they must NOT
// appear in the DDL because int does not accept CHARACTER SET or COLLATE.
col.character_set = "utf8mb4".to_string();
col.collation = "utf8mb4_unicode_ci".to_string();
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "games".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert!(result.statements.len() == 1);
let sql = &result.statements[0];
assert!(!sql.contains("CHARACTER SET"));
assert!(!sql.contains("COLLATE"));
assert!(sql.contains("int"));
}
#[test]
fn mysql_numeric_column_ignores_charset_collation_in_change_detection() {
// When an existing INT column has no original character_set / collation but
// the editable draft carries stale values, the column should NOT be flagged
// as having an attribute change.
let mut col = column("score");
col.data_type = "int".to_string();
col.character_set = "utf8mb4".to_string();
col.collation = "utf8mb4_unicode_ci".to_string();
col.original = Some(ColumnInfo {
name: "score".to_string(),
data_type: "int".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "games".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
// No ALTER should be emitted — charset/collation changes on
// non-character columns are no-ops.
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(result.statements, Vec::<String>::new());
}
#[test]
fn mysql_character_column_detects_charset_collation_change() {
let mut col = column("name");
col.data_type = "varchar(255)".to_string();
col.character_set = "utf8mb4".to_string();
col.collation = "utf8mb4_unicode_ci".to_string();
col.original = Some(ColumnInfo {
name: "name".to_string(),
data_type: "varchar(255)".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "users".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec!["ALTER TABLE `users` MODIFY COLUMN `name` varchar(255) CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci`;"]
);
}
#[test]
fn mysql_character_column_preserves_charset_collation_on_other_change() {
// Changing the default value on a character column should still
// re-emit the charset/collation clauses so they are not lost.
let mut col = column("name");
col.data_type = "varchar(255)".to_string();
col.character_set = "utf8mb4".to_string();
col.collation = "utf8mb4_unicode_ci".to_string();
col.default_value = "guest".to_string();
col.original = Some(ColumnInfo {
name: "name".to_string(),
data_type: "varchar(255)".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
character_set: Some("utf8mb4".to_string()),
collation: Some("utf8mb4_unicode_ci".to_string()),
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "users".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec!["ALTER TABLE `users` MODIFY COLUMN `name` varchar(255) CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci` DEFAULT 'guest';"]
);
}
@@ -23,6 +23,10 @@ pub struct EditableStructureColumn {
pub original_position: Option<usize>,
#[serde(default)]
pub marked_for_drop: bool,
#[serde(default)]
pub character_set: String,
#[serde(default)]
pub collation: String,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
@@ -55,7 +59,7 @@ pub struct ColumnIdentity {
pub increment: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ColumnInfo {
pub name: String,
pub data_type: String,
@@ -67,6 +71,10 @@ pub struct ColumnInfo {
pub extra: Option<String>,
#[serde(default)]
pub comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub character_set: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collation: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]