diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index c7b1b46607..cf105e5fce 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -3562,6 +3562,94 @@ fn columns_sql(database: &str, table: &str) -> String { ) } +fn generation_expressions_sql(database: &str, table: &str) -> String { + format!( + "SELECT COLUMN_NAME, GENERATION_EXPRESSION \ + FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \ + AND GENERATION_EXPRESSION IS NOT NULL AND GENERATION_EXPRESSION <> '' \ + ORDER BY ORDINAL_POSITION", + quote_value(database), + quote_value(table), + ) +} + +fn mysql_generated_column_storage(extra: &str) -> Option<&'static str> { + let normalized = extra.split_whitespace().collect::>().join(" ").to_ascii_lowercase(); + if normalized.contains("virtual generated") { + Some("VIRTUAL") + } else if normalized.contains("stored generated") { + Some("STORED") + } else if normalized.contains("persistent generated") { + Some("PERSISTENT") + } else { + None + } +} + +fn mysql_generated_column_extra(extra: &str, expression: &str) -> Option { + let storage = mysql_generated_column_storage(extra)?; + let expression = expression.trim(); + if expression.is_empty() { + return None; + } + Some(format!("GENERATED ALWAYS AS ({expression}) {storage}")) +} + +async fn enrich_mysql_generated_column_expressions( + conn: &mut mysql_async::Conn, + database: &str, + table: &str, + columns: &mut [ColumnInfo], +) { + if database.trim().is_empty() + || !columns + .iter() + .any(|column| column.extra.as_deref().is_some_and(|extra| mysql_generated_column_storage(extra).is_some())) + { + return; + } + + let sql = generation_expressions_sql(database, table); + let rows = match conn.query_iter(&sql).await { + Ok(result) => match result.collect_and_drop::().await { + Ok(rows) => rows, + Err(error) => { + log::debug!("Failed to collect MySQL generated-column expressions with `{sql}`: {error}"); + return; + } + }, + Err(error) => { + log::debug!("Failed to read MySQL generated-column expressions with `{sql}`: {error}"); + return; + } + }; + let expressions = rows + .iter() + .filter_map(|row| { + let name = get_str_by_name(row, "COLUMN_NAME"); + let expression = get_str_by_name(row, "GENERATION_EXPRESSION"); + (!name.is_empty() && !expression.trim().is_empty()).then_some((name, expression)) + }) + .collect::>(); + + apply_mysql_generated_column_expressions(columns, &expressions); +} + +fn apply_mysql_generated_column_expressions(columns: &mut [ColumnInfo], expressions: &HashMap) { + for column in columns { + let Some(expression) = expressions.get(&column.name) else { + continue; + }; + let Some(extra) = column.extra.as_deref() else { + continue; + }; + if let Some(generated_extra) = mysql_generated_column_extra(extra, expression) { + column.extra = Some(generated_extra); + } + } +} + fn table_collation_sql(database: &str, table: &str) -> String { format!( "SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} LIMIT 1", @@ -3778,6 +3866,7 @@ where return get_columns_show(pool, database, table).await; } + enrich_mysql_generated_column_expressions(&mut conn, database, table, &mut columns).await; normalize_mysql_column_charset_metadata(&mut columns, table_collation.as_deref()); Ok(columns) } @@ -3834,6 +3923,7 @@ where }) }) .collect(); + enrich_mysql_generated_column_expressions(&mut conn, database, table, &mut columns).await; normalize_mysql_column_charset_metadata(&mut columns, table_collation.as_deref()); Ok(columns) } @@ -6603,6 +6693,57 @@ mod tests { assert!(!sql.contains("AS ENUM_VALUES")); } + #[test] + fn mysql_generation_expression_sql_is_separate_and_scoped() { + let sql = generation_expressions_sql("app", "products"); + + assert!(sql.contains("COLUMN_NAME, GENERATION_EXPRESSION")); + assert!(sql.contains("TABLE_SCHEMA = 'app'")); + assert!(sql.contains("TABLE_NAME = 'products'")); + assert!(sql.contains("GENERATION_EXPRESSION <> ''")); + } + + #[test] + fn mysql_generated_column_extra_rebuilds_virtual_and_stored_clauses() { + assert_eq!( + mysql_generated_column_extra("STORED GENERATED", "`price` * `quantity`"), + Some("GENERATED ALWAYS AS (`price` * `quantity`) STORED".to_string()) + ); + assert_eq!( + mysql_generated_column_extra("VIRTUAL GENERATED", "lower(`name`)"), + Some("GENERATED ALWAYS AS (lower(`name`)) VIRTUAL".to_string()) + ); + assert_eq!(mysql_generated_column_extra("DEFAULT_GENERATED", "current_timestamp()"), None); + } + + #[test] + fn mysql_generated_column_expressions_enrich_only_generated_columns() { + let mut columns = vec![ + ColumnInfo { name: "total".to_string(), extra: Some("STORED GENERATED".to_string()), ..Default::default() }, + ColumnInfo { + name: "search_name".to_string(), + extra: Some("VIRTUAL GENERATED".to_string()), + ..Default::default() + }, + ColumnInfo { + name: "created_at".to_string(), + extra: Some("DEFAULT_GENERATED".to_string()), + ..Default::default() + }, + ]; + let expressions = HashMap::from([ + ("total".to_string(), "`price` * `quantity`".to_string()), + ("search_name".to_string(), "lower(`name`)".to_string()), + ("created_at".to_string(), "current_timestamp()".to_string()), + ]); + + apply_mysql_generated_column_expressions(&mut columns, &expressions); + + assert_eq!(columns[0].extra.as_deref(), Some("GENERATED ALWAYS AS (`price` * `quantity`) STORED")); + assert_eq!(columns[1].extra.as_deref(), Some("GENERATED ALWAYS AS (lower(`name`)) VIRTUAL")); + assert_eq!(columns[2].extra.as_deref(), Some("DEFAULT_GENERATED")); + } + #[test] fn mysql_nullable_table_collation_uses_optional_string_conversion() { let collation = mysql_async::from_value_opt::>(mysql_async::Value::NULL) diff --git a/crates/dbx-core/src/table_structure_sql/column_alter.rs b/crates/dbx-core/src/table_structure_sql/column_alter.rs index 2bfdfd65cc..09dc85891a 100644 --- a/crates/dbx-core/src/table_structure_sql/column_alter.rs +++ b/crates/dbx-core/src/table_structure_sql/column_alter.rs @@ -1,4 +1,7 @@ -use super::column_format::{clickhouse_column_type, column_data_type, column_definition, is_mysql_character_data_type}; +use super::column_format::{ + clickhouse_column_type, column_data_type, column_definition, is_mysql_character_data_type, + original_is_mysql_generated_column, original_mysql_generated_clause, +}; use super::columns::build_drop_column_sql; use super::comments::build_sqlserver_column_comment_sql; use super::dialect::{capabilities_for, database_label, StructureDialect}; @@ -79,6 +82,16 @@ pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> Ta { return TableStructureSqlResult { statements, warnings }; } + if dialect == StructureDialect::Mysql + && original_is_mysql_generated_column(&options.column) + && original_mysql_generated_clause(&options.column).is_none() + { + warnings.push(format!( + "Column \"{}\" is generated, but its generation expression could not be loaded; no ALTER statement was generated to avoid removing the generated-column definition.", + original.name + )); + return TableStructureSqlResult { statements, warnings }; + } if !has_rename && !has_attribute_change && !has_column_extra_change(&options.column) { return TableStructureSqlResult { statements, warnings }; } @@ -130,6 +143,14 @@ fn is_column_extra_empty(extra: &ColumnExtra) -> bool { fn original_manticore_extra_flags(extra: &str) -> (bool, bool, bool, bool) { let lower = extra.to_lowercase(); + let mut tokens = lower.split_whitespace(); + let first = tokens.next(); + let is_generated_column = first.is_some_and(|token| token == "generated") + || matches!(first, Some("virtual" | "stored" | "persistent")) + && tokens.next().is_some_and(|token| token == "generated"); + if is_generated_column { + return (false, false, false, false); + } ( lower.split_whitespace().any(|token| token == "indexed"), lower.split_whitespace().any(|token| token == "stored"), diff --git a/crates/dbx-core/src/table_structure_sql/column_format.rs b/crates/dbx-core/src/table_structure_sql/column_format.rs index 58b7c020b8..289dc528fa 100644 --- a/crates/dbx-core/src/table_structure_sql/column_format.rs +++ b/crates/dbx-core/src/table_structure_sql/column_format.rs @@ -19,19 +19,28 @@ pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStru parts.push(format!("COLLATE {}", quote_ident(dialect, &column.collation))); } } + let mysql_generated_clause = + (dialect == StructureDialect::Mysql).then(|| original_mysql_generated_clause(column)).flatten(); + if let Some(generated_clause) = mysql_generated_clause.as_ref() { + parts.push(generated_clause.clone()); + } if !column.is_nullable && !is_oracle_like(dialect) && dialect != StructureDialect::ClickHouse { parts.push("NOT NULL".to_string()); } - if let Some(extra_clause) = column_extra_clause(dialect, column) { - parts.push(extra_clause); + if mysql_generated_clause.is_none() { + if let Some(extra_clause) = column_extra_clause(dialect, column) { + parts.push(extra_clause); + } } let default_value = normalize_default(Some(&column.default_value)); - if !default_value.is_empty() { + if mysql_generated_clause.is_none() && !default_value.is_empty() { parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value))); } - if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) { - if on_update && dialect == StructureDialect::Mysql { - parts.push("ON UPDATE CURRENT_TIMESTAMP".to_string()); + if mysql_generated_clause.is_none() { + if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) { + if on_update && dialect == StructureDialect::Mysql { + parts.push("ON UPDATE CURRENT_TIMESTAMP".to_string()); + } } } if matches!(dialect, StructureDialect::Mysql | StructureDialect::Doris) && !clean(&column.comment).is_empty() { @@ -40,6 +49,25 @@ pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStru parts.join(" ") } +pub(super) fn original_mysql_generated_clause(column: &EditableStructureColumn) -> Option { + let extra = column.original.as_ref()?.extra.as_deref()?.trim(); + let normalized = extra.split_whitespace().collect::>().join(" ").to_ascii_lowercase(); + (normalized.starts_with("generated ") && normalized.contains(" as (")).then(|| extra.to_string()) +} + +pub(super) fn original_is_mysql_generated_column(column: &EditableStructureColumn) -> bool { + let Some(extra) = column.original.as_ref().and_then(|original| original.extra.as_deref()) else { + return false; + }; + let mut tokens = extra.split_whitespace(); + let first = tokens.next(); + first.is_some_and(|token| token.eq_ignore_ascii_case("generated")) + || matches!(first, Some(token) if token.eq_ignore_ascii_case("virtual") + || token.eq_ignore_ascii_case("stored") + || token.eq_ignore_ascii_case("persistent")) + && tokens.next().is_some_and(|token| token.eq_ignore_ascii_case("generated")) +} + pub(super) fn column_extra_clause(dialect: StructureDialect, column: &EditableStructureColumn) -> Option { let extra = column.extra.as_ref()?; match dialect { diff --git a/crates/dbx-core/src/table_structure_sql/columns.rs b/crates/dbx-core/src/table_structure_sql/columns.rs index 941a7da6b3..8902558b30 100644 --- a/crates/dbx-core/src/table_structure_sql/columns.rs +++ b/crates/dbx-core/src/table_structure_sql/columns.rs @@ -7,6 +7,7 @@ use super::column_alter::{ }; use super::column_format::{ column_definition, has_dameng_identity, is_dameng_identity_compatible_type, is_mysql_character_data_type, + original_is_mysql_generated_column, original_mysql_generated_clause, }; use super::comments::build_sqlserver_column_comment_sql; use super::dialect::{capabilities_for, database_label, is_oracle_like, StructureDialect}; @@ -165,6 +166,16 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu { continue; } + if dialect == StructureDialect::Mysql + && original_is_mysql_generated_column(column) + && original_mysql_generated_clause(column).is_none() + { + warnings.push(format!( + "Column \"{}\" is generated, but its generation expression could not be loaded; no ALTER statement was generated to avoid removing the generated-column definition.", + original.name + )); + continue; + } if !has_rename && !has_attribute_change && !has_position_change { continue; } diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 0c0ca9b152..4afdb51fdd 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -3838,6 +3838,34 @@ fn mysql_single_column_alter_quotes_datetime_literal() { assert!(result.statements.iter().any(|s| s.contains("DEFAULT '2024-01-01 00:00:00'"))); } +#[test] +fn mysql_single_generated_column_change_is_blocked_without_expression_metadata() { + let mut generated = column("total"); + generated.data_type = "decimal(14,2)".to_string(); + generated.extra = Some(ColumnExtra::default()); + generated.original = Some(ColumnInfo { + name: "total".to_string(), + data_type: "decimal(12,2)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: Some("STORED GENERATED".to_string()), + comment: None, + ..Default::default() + }); + + let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { + database_type: Some(DatabaseType::Mysql), + schema: None, + table_name: "products".to_string(), + column: generated, + }); + + assert!(result.statements.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert!(result.warnings[0].contains("generation expression could not be loaded")); +} + #[test] fn builds_mysql_foreign_key_changes() { let mut existing = foreign_key("fk_orders_users", "user_id", "users", "id"); @@ -4582,6 +4610,116 @@ fn mysql_character_column_preserves_charset_collation_on_other_change() { ); } +#[test] +fn mysql_generated_column_preserves_expression_when_modified() { + let mut generated = column("total"); + generated.data_type = "decimal(14,2)".to_string(); + generated.is_nullable = false; + generated.comment = "Computed total".to_string(); + generated.extra = Some(ColumnExtra::default()); + generated.original = Some(ColumnInfo { + name: "total".to_string(), + data_type: "decimal(12,2)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: Some("GENERATED ALWAYS AS (`price` * `quantity`) STORED".to_string()), + comment: None, + ..Default::default() + }); + generated.original_position = Some(0); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Mysql, + None, + "products", + vec![generated], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE `products` MODIFY COLUMN `total` decimal(14,2) GENERATED ALWAYS AS (`price` * `quantity`) STORED NOT NULL COMMENT 'Computed total';" + ] + ); +} + +#[test] +fn mysql_unchanged_generated_column_is_not_modified_with_other_columns() { + let mut generated = column("total"); + generated.data_type = "decimal(12,2)".to_string(); + generated.extra = Some(ColumnExtra::default()); + generated.original = Some(ColumnInfo { + name: "total".to_string(), + data_type: "decimal(12,2)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: Some("STORED GENERATED".to_string()), + comment: None, + ..Default::default() + }); + generated.original_position = Some(0); + + let mut status = column("status"); + status.data_type = "varchar(50)".to_string(); + status.comment = "状态1".to_string(); + status.original = Some(ColumnInfo { + name: "status".to_string(), + data_type: "varchar(50)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + ..Default::default() + }); + status.original_position = Some(1); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Mysql, + None, + "product_info", + vec![generated, status], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["ALTER TABLE `product_info` MODIFY COLUMN `status` varchar(50) COMMENT '状态1';"] + ); +} + +#[test] +fn mysql_generated_column_change_is_blocked_without_expression_metadata() { + let mut generated = column("total"); + generated.data_type = "decimal(14,2)".to_string(); + generated.extra = Some(ColumnExtra::default()); + generated.original = Some(ColumnInfo { + name: "total".to_string(), + data_type: "decimal(12,2)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: Some("STORED GENERATED".to_string()), + comment: None, + ..Default::default() + }); + generated.original_position = Some(0); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Mysql, + None, + "products", + vec![generated], + )); + + assert!(result.statements.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert!(result.warnings[0].contains("generation expression could not be loaded")); +} + // ---- Oscar (神通) ---- // 神通 v7 是 Oracle 兼容方言,且实测支持 ALTER TABLE DROP/ADD PRIMARY KEY(与 Dameng 一致, // 不同于 Oracle)。DDL 生成走 StructureDialect::Oscar,与 Dameng 共享 Oracle-like 分支。 diff --git a/crates/dbx-core/tests/live_mysql_generated_columns.rs b/crates/dbx-core/tests/live_mysql_generated_columns.rs new file mode 100644 index 0000000000..dd3ecf7703 --- /dev/null +++ b/crates/dbx-core/tests/live_mysql_generated_columns.rs @@ -0,0 +1,114 @@ +use dbx_core::db::mysql; +use dbx_core::models::connection::DatabaseType; +use dbx_core::table_structure_sql::{ + build_table_structure_change_sql, ColumnExtra, ColumnInfo as StructureColumnInfo, EditableStructureColumn, + TableStructureSqlOptions, +}; +use dbx_core::types::ColumnInfo; +use mysql_async::prelude::Queryable; + +fn editable_column(column: &ColumnInfo, index: usize) -> EditableStructureColumn { + EditableStructureColumn { + id: format!("existing:{}", column.name), + name: column.name.clone(), + data_type: column.data_type.clone(), + is_nullable: column.is_nullable, + default_value: column.column_default.clone().unwrap_or_default(), + comment: column.comment.clone().unwrap_or_default(), + is_primary_key: column.is_primary_key, + extra: Some(ColumnExtra::default()), + original: Some(StructureColumnInfo { + name: column.name.clone(), + data_type: column.data_type.clone(), + is_nullable: column.is_nullable, + column_default: column.column_default.clone(), + is_primary_key: column.is_primary_key, + extra: column.extra.clone(), + comment: column.comment.clone(), + character_set: column.character_set.clone(), + collation: column.collation.clone(), + }), + original_position: Some(index), + marked_for_drop: false, + character_set: column.character_set.clone().unwrap_or_default(), + collation: column.collation.clone().unwrap_or_default(), + } +} + +fn change_options(table_name: &str, columns: Vec) -> TableStructureSqlOptions { + TableStructureSqlOptions { + database_type: Some(DatabaseType::Mysql), + schema: None, + table_name: table_name.to_string(), + columns, + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + partitioned: false, + } +} + +fn generated_extra(columns: &[ColumnInfo]) -> &str { + columns + .iter() + .find(|column| column.name == "total") + .and_then(|column| column.extra.as_deref()) + .expect("generated column metadata") +} + +#[tokio::test] +#[ignore = "requires DBX_LIVE_MYSQL_GENERATED_URL and DBX_LIVE_MYSQL_GENERATED_DATABASE"] +async fn live_mysql_generated_column_metadata_and_alter_preserve_expression() { + let url = std::env::var("DBX_LIVE_MYSQL_GENERATED_URL").expect("DBX_LIVE_MYSQL_GENERATED_URL"); + let database = std::env::var("DBX_LIVE_MYSQL_GENERATED_DATABASE").expect("DBX_LIVE_MYSQL_GENERATED_DATABASE"); + let table = format!("dbx_generated_{}", uuid::Uuid::new_v4().simple()); + let pool = mysql::connect(&url, std::time::Duration::from_secs(10)).await.unwrap(); + let mut conn = mysql::get_conn_with_health_check(&pool).await.unwrap(); + conn.query_drop(format!( + "CREATE TABLE `{table}` (\ + `price` DECIMAL(10,2) NOT NULL, \ + `quantity` INT NOT NULL, \ + `total` DECIMAL(12,2) GENERATED ALWAYS AS (`price` * `quantity`) STORED, \ + `status` VARCHAR(50) NULL)" + )) + .await + .unwrap(); + + let columns = mysql::get_columns(&pool, &database, &table).await.unwrap(); + let extra = generated_extra(&columns); + assert!(extra.starts_with("GENERATED ALWAYS AS ("), "extra: {extra}"); + assert!(extra.contains("`price` * `quantity`"), "extra: {extra}"); + assert!(extra.ends_with(" STORED"), "extra: {extra}"); + + let mut drafts = + columns.iter().enumerate().map(|(index, column)| editable_column(column, index)).collect::>(); + drafts.iter_mut().find(|column| column.name == "status").unwrap().comment = "状态1".to_string(); + let status_change = build_table_structure_change_sql(change_options(&table, drafts)); + assert_eq!(status_change.warnings, Vec::::new()); + assert_eq!(status_change.statements.len(), 1, "statements: {:?}", status_change.statements); + assert!(status_change.statements[0].contains("MODIFY COLUMN `status`")); + assert!(!status_change.statements[0].contains("`total`")); + conn.query_drop(&status_change.statements[0]).await.unwrap(); + + let columns = mysql::get_columns(&pool, &database, &table).await.unwrap(); + let mut drafts = + columns.iter().enumerate().map(|(index, column)| editable_column(column, index)).collect::>(); + drafts.iter_mut().find(|column| column.name == "total").unwrap().data_type = "decimal(14,2)".to_string(); + let generated_change = build_table_structure_change_sql(change_options(&table, drafts)); + assert_eq!(generated_change.warnings, Vec::::new()); + assert_eq!(generated_change.statements.len(), 1, "statements: {:?}", generated_change.statements); + assert!(generated_change.statements[0].contains("GENERATED ALWAYS AS (")); + assert!(generated_change.statements[0].contains("`price` * `quantity`")); + assert!(generated_change.statements[0].contains(") STORED")); + conn.query_drop(&generated_change.statements[0]).await.unwrap(); + + let columns = mysql::get_columns(&pool, &database, &table).await.unwrap(); + assert_eq!(columns.iter().find(|column| column.name == "total").unwrap().data_type, "decimal(14,2)"); + assert!(generated_extra(&columns).contains("`price` * `quantity`")); + + conn.query_drop(format!("DROP TABLE `{table}`")).await.unwrap(); + drop(conn); + pool.disconnect().await.unwrap(); +} diff --git a/packages/app-tests/tableStructureEditorState.test.ts b/packages/app-tests/tableStructureEditorState.test.ts index e4fe3c872d..5418f9028e 100644 --- a/packages/app-tests/tableStructureEditorState.test.ts +++ b/packages/app-tests/tableStructureEditorState.test.ts @@ -119,6 +119,27 @@ test("creates editable column drafts from column metadata", () => { ); }); +test("preserves MySQL generated expressions in original column metadata", () => { + const expression = "GENERATED ALWAYS AS (`price` * `quantity`) STORED"; + const [draft] = createColumnDrafts( + [ + { + name: "total", + data_type: "decimal(12,2)", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: expression, + comment: null, + }, + ], + "mysql", + ); + + assert.deepEqual(draft.extra, {}); + assert.equal(draft.original?.extra, expression); +}); + test("rehydrates restored existing column drafts from live metadata", () => { const drafts = rehydrateColumnDraftsFromMetadata( [