diff --git a/apps/chat2db-web/src/legacy.rs b/apps/chat2db-web/src/legacy.rs index 023012b..0c85320 100644 --- a/apps/chat2db-web/src/legacy.rs +++ b/apps/chat2db-web/src/legacy.rs @@ -359,17 +359,25 @@ pub struct LegacySimpleTable { #[serde(default, rename_all = "camelCase")] pub struct LegacyColumn { pub old_name: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub table_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub column_type: String, pub data_type: Option, pub default_value: Option, pub auto_increment: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub comment: String, pub primary_key: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub primary_key_name: String, + #[serde(default, deserialize_with = "deserialize_i32_or_default")] pub primary_key_order: i32, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub database_name: String, pub type_name: Option, pub column_size: Option, @@ -383,12 +391,18 @@ pub struct LegacyColumn { pub ordinal_position: Option, pub nullable: Option, pub generated_column: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub extent: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub char_set_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub collation_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub value: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub unit: String, pub sparse: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub default_constraint_name: String, pub seed: Option, pub increment: Option, @@ -399,21 +413,35 @@ pub struct LegacyColumn { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct LegacyIndexColumn { + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub index_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub table_name: String, - #[serde(rename = "type")] + #[serde( + rename = "type", + default, + deserialize_with = "deserialize_string_or_default" + )] pub index_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub comment: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub column_name: String, pub ordinal_position: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub collation: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub database_name: String, pub non_unique: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub index_qualifier: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub asc_or_desc: String, pub cardinality: Option, pub pages: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub filter_condition: String, pub sub_part: Option, pub edit_status: Option, @@ -424,20 +452,34 @@ pub struct LegacyIndexColumn { pub struct LegacyIndex { pub columns: Option, pub old_name: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub table_name: String, - #[serde(rename = "type")] + #[serde( + rename = "type", + default, + deserialize_with = "deserialize_string_or_default" + )] pub index_type: String, pub unique: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub comment: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub database_name: String, + #[serde(default, deserialize_with = "deserialize_vec_or_default")] pub column_list: Vec, pub edit_status: Option, pub concurrently: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub method: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub foreign_schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub foreign_table_name: String, + #[serde(default, deserialize_with = "deserialize_vec_or_default")] pub foreign_column_namelist: Vec, } @@ -445,27 +487,47 @@ pub struct LegacyIndex { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct LegacyEditableTable { + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub comment: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub database_name: String, - #[serde(rename = "type")] + #[serde( + rename = "type", + default, + deserialize_with = "deserialize_string_or_default" + )] pub table_type: String, + #[serde(default, deserialize_with = "deserialize_vec_or_default")] pub column_list: Vec, + #[serde(default, deserialize_with = "deserialize_vec_or_default")] pub index_list: Vec, + #[serde(default, deserialize_with = "deserialize_vec_or_default")] pub foreign_key_list: Vec, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub db_type: String, pub pinned: bool, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub ddl: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub engine: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub charset: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub collate: String, pub increment_value: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub partition: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub tablespace: String, pub rows: Option, pub data_length: Option, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub create_time: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] pub update_time: String, } @@ -1216,6 +1278,13 @@ where Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) } +fn deserialize_i32_or_default<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + fn deserialize_vec_or_default<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -2139,6 +2208,17 @@ pub(crate) async fn build_table_modify_sql( let datasource_id = request.data_source_id.as_string(); resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; let sql = if let Some(old_table) = request.old_table.as_ref() { + let reordered_columns = mysql_reordered_column_names(old_table, &request.new_table); + if !reordered_columns.is_empty() { + application + .validate_native_mysql_column_reorder( + &datasource_id, + &first_non_blank(&request.database_name, &old_table.database_name), + &required_name(&old_table.name, "oldTable.name")?, + &reordered_columns, + ) + .await?; + } build_mysql_alter_table(&mysql_table_alter( old_table, &request.new_table, @@ -2375,22 +2455,100 @@ pub(crate) async fn view_editor_meta( application: &Application, request: &LegacyViewOperationRequest, ) -> LegacyResult { - let view_name = first_non_blank(&request.view_name, &request.table_name); - let query = LegacyTableDetailQuery { - data_source_id: request.data_source_id.clone(), - database_name: request.database_name.clone(), - schema_name: request.schema_name.clone(), - database_type: request.database_type.clone(), - table_name: view_name, + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + let sql = "select * from table_name".to_owned(); + let preview_name = if request.database_name.trim().is_empty() { + "`undefined`".to_owned() + } else { + format!("`{}`.`undefined`", request.database_name.replace('`', "``")) }; - let view = Box::pin(get_editable_view(application, &query)).await?; + let preview_sql = format!("create view {preview_name} AS \n{sql};"); Ok(LegacyViewMetaResponse { - configurations: Vec::new(), - preview_sql: view.ddl.clone(), - sql: view.ddl, + configurations: mysql_view_configurations(), + preview_sql, + sql, }) } +fn mysql_view_configurations() -> Vec { + vec![ + serde_json::json!({ + "labelName": "算法", + "name": "algorithm", + "inputType": "select", + "defaultValue": "3", + "required": false, + "multiple": false, + "display": null, + "selects": [ + { "label": "UNDEFINED", "value": 0 }, + { "label": "MERGE", "value": 1 }, + { "label": "TEMPTABLE", "value": 2 }, + { "label": null, "value": 3 } + ] + }), + serde_json::json!({ + "labelName": "检查选项", + "name": "checkOption", + "inputType": "select", + "defaultValue": "2", + "required": false, + "multiple": false, + "display": null, + "selects": [ + { "label": "CASCADED", "value": 0 }, + { "label": "LOCAL", "value": 1 }, + { "label": null, "value": 2 } + ] + }), + serde_json::json!({ + "labelName": "SQL 安全性", + "name": "security", + "inputType": "select", + "defaultValue": "2", + "required": false, + "multiple": false, + "display": null, + "selects": [ + { "label": "DEFINER", "value": 0 }, + { "label": "INVOKER", "value": 1 }, + { "label": null, "value": 2 } + ] + }), + serde_json::json!({ + "labelName": "视图名称", + "name": "viewName", + "inputType": "input", + "defaultValue": null, + "required": false, + "multiple": false, + "display": null, + "selects": null + }), + serde_json::json!({ + "labelName": "定义者", + "name": "definer", + "inputType": "input", + "defaultValue": null, + "required": false, + "multiple": false, + "display": null, + "selects": null + }), + serde_json::json!({ + "labelName": "use or replace", + "name": "useOrReplace", + "inputType": "checkbox", + "defaultValue": "false", + "required": false, + "multiple": false, + "display": null, + "selects": null + }), + ] +} + /// Lists stored functions in the historical paged metadata shape. pub(crate) async fn list_functions( application: &Application, @@ -3894,6 +4052,7 @@ fn simple_table_response(table: CommunityTable) -> LegacySimpleTable { fn column_response(column: CommunityTableColumn) -> LegacyColumn { let old_name = Some(column.name.clone()); + let value = mysql_enum_set_editor_value(&column.extent); LegacyColumn { old_name, name: column.name, @@ -3923,7 +4082,7 @@ fn column_response(column: CommunityTableColumn) -> LegacyColumn { extent: column.extent, char_set_name: column.charset, collation_name: column.collation, - value: String::new(), + value, unit: column.unit, sparse: column.sparse, default_constraint_name: column.default_constraint_name, @@ -4631,11 +4790,11 @@ fn mysql_grid_copy_operation( { "CREATE" => MysqlResultGridCopyOperationType::Create, "UPDATE_COPY" => MysqlResultGridCopyOperationType::UpdateCopy, - "WHERE" => MysqlResultGridCopyOperationType::Where, + "WHERE" | "IN_VALUES" => MysqlResultGridCopyOperationType::Where, _ => { return Err(LegacyFailure::invalid( "invalid_mysql_result_grid", - "copy operation type must be CREATE, UPDATE_COPY, or WHERE", + "copy operation type must be CREATE, UPDATE_COPY, WHERE, or IN_VALUES", )); } }; @@ -4752,6 +4911,7 @@ fn mysql_table_definition( }) } +#[allow(clippy::too_many_lines)] fn mysql_table_alter( old_table: &LegacyEditableTable, new_table: &LegacyEditableTable, @@ -4800,6 +4960,16 @@ fn mysql_table_alter( "columnList.oldName", )?, }), + _ if mysql_column_moved(old_table, &active_columns, column) => { + columns.push(MysqlColumnAlter::Modify { + old_name: required_name( + column.old_name.as_deref().unwrap_or(&column.name), + "columnList.oldName", + )?, + column: mysql_column_definition(column)?, + position: mysql_column_position(&active_columns, column), + }); + } _ => {} } } @@ -5052,6 +5222,79 @@ fn mysql_column_position( } } +fn mysql_column_moved( + old_table: &LegacyEditableTable, + active_columns: &[&LegacyColumn], + column: &LegacyColumn, +) -> bool { + let original_name = legacy_column_original_name(column); + let old_columns = old_table + .column_list + .iter() + .filter(|candidate| !has_edit_status(candidate.edit_status.as_deref(), "DELETE")) + .collect::>(); + let Some(old_index) = old_columns.iter().position(|candidate| { + candidate.name.eq_ignore_ascii_case(original_name) + || legacy_column_original_name(candidate).eq_ignore_ascii_case(original_name) + }) else { + return false; + }; + let Some(new_index) = active_columns.iter().position(|candidate| { + std::ptr::eq(*candidate, column) + || legacy_column_original_name(candidate).eq_ignore_ascii_case(original_name) + }) else { + return false; + }; + + let old_previous = old_index + .checked_sub(1) + .map(|index| legacy_column_original_name(old_columns[index])); + let new_previous = new_index + .checked_sub(1) + .map(|index| legacy_column_original_name(active_columns[index])); + match (old_previous, new_previous) { + (None, None) => false, + (Some(old), Some(new)) => !old.eq_ignore_ascii_case(new), + _ => true, + } +} + +fn mysql_reordered_column_names( + old_table: &LegacyEditableTable, + new_table: &LegacyEditableTable, +) -> Vec { + let active_columns = new_table + .column_list + .iter() + .filter(|column| !has_edit_status(column.edit_status.as_deref(), "DELETE")) + .collect::>(); + new_table + .column_list + .iter() + .filter(|column| { + !matches!( + column + .edit_status + .as_deref() + .unwrap_or_default() + .trim() + .to_ascii_uppercase() + .as_str(), + "ADD" | "MODIFY" | "DELETE" + ) && mysql_column_moved(old_table, &active_columns, column) + }) + .map(|column| legacy_column_original_name(column).to_owned()) + .collect() +} + +fn legacy_column_original_name(column: &LegacyColumn) -> &str { + column + .old_name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .unwrap_or(&column.name) +} + fn mysql_view_definition( request: &LegacyViewOperationRequest, ) -> LegacyResult { @@ -5236,13 +5479,71 @@ fn parse_auto_increment(value: Option<&str>) -> LegacyResult> { } fn parse_enum_values(value: &str) -> Vec { - value - .split(',') - .map(str::trim) - .map(|value| value.trim_matches(['\'', '"'])) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - .collect() + let value = value.trim(); + let value = value + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(value); + let mut values = Vec::new(); + let mut chars = value.chars().peekable(); + while chars.peek().is_some() { + while matches!(chars.peek(), Some(character) if character.is_whitespace() || *character == ',') + { + chars.next(); + } + let Some(first) = chars.next() else { + break; + }; + let mut parsed = String::new(); + let quoted = matches!(first, '\'' | '"'); + if quoted { + let quote = first; + while let Some(character) = chars.next() { + if character == '\\' { + if let Some(escaped) = chars.next() { + parsed.push(escaped); + } + } else if character == quote { + if chars.peek() == Some("e) { + chars.next(); + parsed.push(quote); + } else { + break; + } + } else { + parsed.push(character); + } + } + while matches!(chars.peek(), Some(character) if character.is_whitespace()) { + chars.next(); + } + if chars.peek() == Some(&',') { + chars.next(); + } + } else { + parsed.push(first); + for character in chars.by_ref() { + if character == ',' { + break; + } + parsed.push(character); + } + parsed.truncate(parsed.trim_end().len()); + } + if quoted || !parsed.is_empty() { + values.push(parsed); + } + } + values +} + +fn mysql_enum_set_editor_value(extent: &str) -> String { + let extent = extent.trim(); + extent + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(extent) + .to_owned() } fn paginate(items: Vec, page_no: u32, page_size: u32) -> LegacyPage { @@ -6833,6 +7134,210 @@ mod tests { assert_eq!((timestamp.length, timestamp.scale), (Some(6), None)); } + #[test] + fn table_editor_preserves_mysql_enum_and_set_values() { + let column = column_response(CommunityTableColumn { + name: "state".to_owned(), + column_type: "ENUM".to_owned(), + extent: "('','draft','needs,review','O''Reilly','close)later')".to_owned(), + ..CommunityTableColumn::default() + }); + + assert_eq!( + column.value, + "'','draft','needs,review','O''Reilly','close)later'" + ); + let definition = mysql_column_definition(&column) + .expect("the retained enum definition must normalize for DDL"); + assert_eq!( + definition.enum_values, + vec!["", "draft", "needs,review", "O'Reilly", "close)later"] + ); + } + + #[test] + fn table_editor_accepts_null_heavy_frontend_rows() { + let request: LegacyTableModifyRequest = serde_json::from_str( + r#"{ + "dataSourceId": "mysql-local", + "databaseType": "MYSQL", + "databaseName": "inventory", + "newTable": { + "name": "items", + "comment": null, + "schemaName": null, + "type": null, + "dbType": null, + "ddl": null, + "engine": null, + "charset": null, + "collate": null, + "partition": null, + "tablespace": null, + "createTime": null, + "updateTime": null, + "columnList": [{ + "oldName": null, + "name": "state", + "tableName": null, + "columnType": "VARCHAR", + "dataType": null, + "defaultValue": null, + "autoIncrement": null, + "comment": null, + "primaryKey": null, + "primaryKeyName": null, + "primaryKeyOrder": null, + "schemaName": null, + "databaseName": null, + "typeName": null, + "columnSize": 32, + "bufferLength": null, + "decimalDigits": null, + "numPrecRadix": null, + "nullableInt": null, + "sqlDataType": null, + "sqlDatetimeSub": null, + "charOctetLength": null, + "ordinalPosition": null, + "nullable": 1, + "generatedColumn": null, + "extent": null, + "charSetName": null, + "collationName": null, + "value": null, + "unit": null, + "defaultConstraintName": null, + "editStatus": "ADD" + }], + "indexList": [{ + "name": "", + "type": null, + "comment": null, + "schemaName": null, + "databaseName": null, + "method": null, + "foreignSchemaName": null, + "foreignTableName": null, + "foreignColumnNamelist": null, + "columnList": [{ + "indexName": null, + "tableName": null, + "type": null, + "comment": null, + "columnName": "state", + "collation": null, + "schemaName": null, + "databaseName": null, + "indexQualifier": null, + "ascOrDesc": null, + "filterCondition": null + }], + "editStatus": "ADD" + }] + } + }"#, + ) + .expect("the retained Community editor payload must accept explicit nulls"); + + let column = &request.new_table.column_list[0]; + assert_eq!(column.primary_key_order, 0); + assert!(column.comment.is_empty()); + assert!(column.char_set_name.is_empty()); + let index = &request.new_table.index_list[0]; + assert!(index.index_type.is_empty()); + assert!(index.comment.is_empty()); + assert!(index.column_list[0].index_name.is_empty()); + } + + #[test] + fn result_grid_in_values_accepts_the_frontend_operation_name() { + let operation: LegacyGridOperationRequest = serde_json::from_value(serde_json::json!({ + "type": "IN_VALUES", + "dataList": ["1", "active"], + "selectCols": [1] + })) + .expect("the IN-values operation must deserialize"); + + let operation = mysql_grid_copy_operation(&operation) + .expect("the retained frontend operation name must normalize"); + assert_eq!( + operation.operation_type, + MysqlResultGridCopyOperationType::Where + ); + } + + #[test] + fn table_alter_detects_column_order_from_array_position() { + let column = |name: &str| LegacyColumn { + old_name: Some(name.to_owned()), + name: name.to_owned(), + column_type: "INT".to_owned(), + nullable: Some(1), + ..LegacyColumn::default() + }; + let old_table = LegacyEditableTable { + name: "items".to_owned(), + database_name: "inventory".to_owned(), + column_list: vec![column("a"), column("b"), column("c")], + ..LegacyEditableTable::default() + }; + let mut new_table = old_table.clone(); + new_table.column_list = vec![ + old_table.column_list[2].clone(), + old_table.column_list[0].clone(), + old_table.column_list[1].clone(), + ]; + + assert_eq!( + mysql_reordered_column_names(&old_table, &new_table), + ["c", "a"] + ); + + let alter = mysql_table_alter(&old_table, &new_table, "inventory", "") + .expect("a drag-only reorder must normalize"); + let sql = build_mysql_alter_table(&alter).expect("a drag-only reorder must build"); + + assert_eq!(sql.matches("MODIFY COLUMN").count(), 2); + assert!(sql.contains("MODIFY COLUMN `c` INT NULL FIRST")); + assert!(sql.contains("MODIFY COLUMN `a` INT NULL AFTER `c`")); + } + + #[tokio::test] + async fn view_meta_returns_the_community_creation_template() { + let request: LegacyViewOperationRequest = serde_json::from_value(serde_json::json!({ + "dataSourceId": "mysql-local", + "databaseType": "MYSQL", + "databaseName": "inventory", + "schemaName": "ignored_schema", + "viewName": "" + })) + .expect("view metadata request must deserialize"); + let metadata = view_editor_meta(&Application::new(), &request) + .await + .expect("view metadata must not require an existing view"); + + assert_eq!(metadata.sql, "select * from table_name"); + assert_eq!(metadata.configurations.len(), 6); + assert_eq!( + metadata + .configurations + .iter() + .map(|configuration| configuration["name"].as_str().unwrap_or_default()) + .collect::>(), + vec![ + "algorithm", + "checkOption", + "security", + "viewName", + "definer", + "useOrReplace" + ] + ); + assert!(metadata.preview_sql.contains("`inventory`.`undefined`")); + assert!(!metadata.preview_sql.contains("ignored_schema")); + } + #[tokio::test] async fn table_and_view_editor_payloads_map_to_core_builders() { let table_request: LegacyTableModifyRequest = serde_json::from_value(serde_json::json!({ diff --git a/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs b/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs index 9988fc1..b55e4fd 100644 --- a/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs +++ b/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs @@ -226,10 +226,75 @@ async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) .as_array_mut() .expect("table columns") .push(json!({ + "oldName": null, "name": "note", + "tableName": null, "columnType": "TEXT", + "dataType": null, + "defaultValue": null, + "autoIncrement": null, "nullable": 1, "comment": "nullable note", + "primaryKey": null, + "primaryKeyName": null, + "primaryKeyOrder": null, + "schemaName": null, + "databaseName": null, + "typeName": null, + "columnSize": null, + "bufferLength": null, + "decimalDigits": null, + "numPrecRadix": null, + "nullableInt": null, + "sqlDataType": null, + "sqlDatetimeSub": null, + "charOctetLength": null, + "ordinalPosition": null, + "generatedColumn": null, + "extent": null, + "charSetName": null, + "collationName": null, + "value": null, + "unit": null, + "defaultConstraintName": null, + "editStatus": "ADD" + })); + new_table["indexList"] + .as_array_mut() + .expect("table indexes") + .push(json!({ + "oldName": null, + "name": "idx_label", + "tableName": null, + "type": "Normal", + "unique": null, + "comment": null, + "schemaName": null, + "databaseName": null, + "concurrently": null, + "method": null, + "foreignSchemaName": null, + "foreignTableName": null, + "foreignColumnNamelist": null, + "columnList": [{ + "indexName": null, + "tableName": null, + "type": null, + "comment": null, + "columnName": "label", + "ordinalPosition": null, + "collation": null, + "schemaName": null, + "databaseName": null, + "nonUnique": null, + "indexQualifier": null, + "ascOrDesc": null, + "cardinality": null, + "pages": null, + "filterCondition": null, + "subPart": null, + "editStatus": null + }], "editStatus": "ADD" })); let alter_sql = post( @@ -253,6 +318,7 @@ async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) ) .await; assert!(column_exists(config, database_name, "items", "note").await); + assert!(index_exists(config, database_name, "items", "idx_label").await); let old_table = get( &router, @@ -295,6 +361,28 @@ async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) primary_key_columns(config, database_name, "items").await, ["id", "label"] ); + let primary_key_metadata = get( + &router, + &format!( + "/api/rdb/table/query?dataSourceId={datasource}&databaseType=MYSQL&databaseName={database_name}&tableName=items" + ), + ) + .await; + assert_eq!( + primary_key_metadata["columnList"] + .as_array() + .expect("table columns") + .iter() + .filter(|column| column["primaryKey"] == true) + .map(|column| ( + column["name"].as_str().expect("primary-key name"), + column["primaryKeyOrder"] + .as_i64() + .expect("primary-key order") + )) + .collect::>(), + [("id", 1), ("label", 2)] + ); assert_java_dormant(&application); let empty_preview = preview(&router, &datasource, database_name, "items").await; @@ -385,7 +473,7 @@ async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) "headerList": headers, "sourceType": "RESULT_SET", "operations": [{ - "type": "WHERE", + "type": "IN_VALUES", "dataList": cell_values(&updated_preview["dataList"][0]), "selectCols": [2], "selectedCell": updated_preview["dataList"][0][2] @@ -418,6 +506,44 @@ async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) .await; assert_eq!(scalar_count(config, database_name, "items_copy").await, 0); + let view_meta = get( + &router, + &format!( + "/api/rdb/view/view_meta?dataSourceId={datasource}&databaseType=MYSQL&databaseName={database_name}" + ), + ) + .await; + assert_eq!( + view_meta["configurations"] + .as_array() + .expect("view configurations") + .iter() + .map(|configuration| configuration["name"].as_str().expect("configuration name")) + .collect::>(), + [ + "algorithm", + "checkOption", + "security", + "viewName", + "definer", + "useOrReplace" + ] + ); + assert_eq!(view_meta["sql"], "select * from table_name"); + assert_eq!( + view_meta["previewSql"], + format!("create view `{database_name}`.`undefined` AS \nselect * from table_name;") + ); + let root_view_meta = get( + &router, + &format!("/api/rdb/view/view_meta?dataSourceId={root}&databaseType=MYSQL&databaseName="), + ) + .await; + assert_eq!( + root_view_meta["previewSql"], + "create view `undefined` AS \nselect * from table_name;" + ); + let view_sql = post( &router, "/api/rdb/view/modify/sql", @@ -495,6 +621,241 @@ async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) assert!(!object_exists(config, database_name, "item_labels").await); assert_java_dormant(&application); + execute_ddl( + &router, + &datasource, + database_name, + "metadata_items", + &format!( + "CREATE TABLE `{database_name}`.`metadata_items` (\ + `b` BIGINT UNSIGNED NOT NULL, \ + `a` INT UNSIGNED NOT NULL, \ + `state` ENUM('','active','not UNSIGNED value','needs,review','O''Reilly') NOT NULL, \ + `permissions` SET('read','write','close)later') NULL, \ + PRIMARY KEY (`b`, `a`)) ENGINE = InnoDB" + ), + ) + .await; + let metadata_table = get( + &router, + &format!( + "/api/rdb/table/query?dataSourceId={datasource}&databaseType=MYSQL&databaseName={database_name}&tableName=metadata_items" + ), + ) + .await; + let metadata_columns = metadata_table["columnList"] + .as_array() + .expect("metadata columns"); + assert_eq!( + metadata_columns + .iter() + .find(|column| column["name"] == "b") + .expect("unsigned column")["columnType"], + "BIGINT UNSIGNED" + ); + assert_eq!( + metadata_columns + .iter() + .find(|column| column["name"] == "state") + .expect("enum column")["value"], + "'','active','not UNSIGNED value','needs,review','O''Reilly'" + ); + assert_eq!( + metadata_columns + .iter() + .find(|column| column["name"] == "state") + .expect("enum column")["columnType"], + "ENUM" + ); + assert_eq!( + metadata_columns + .iter() + .find(|column| column["name"] == "permissions") + .expect("set column")["value"], + "'read','write','close)later'" + ); + assert_eq!( + metadata_columns + .iter() + .filter(|column| column["primaryKey"] == true) + .map(|column| ( + column["name"].as_str().expect("primary-key name"), + column["primaryKeyOrder"] + .as_i64() + .expect("primary-key order") + )) + .collect::>(), + [("b", 1), ("a", 2)] + ); + + let mut reordered_table = metadata_table.clone(); + reordered_table["columnList"] = Value::Array( + ["permissions", "state", "b", "a"] + .iter() + .map(|name| { + metadata_columns + .iter() + .find(|column| column["name"] == *name) + .unwrap_or_else(|| panic!("missing metadata column {name}")) + .clone() + }) + .collect(), + ); + let reorder_sql = post( + &router, + "/api/rdb/table/modify/sql", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "oldTable": metadata_table, + "newTable": reordered_table + }), + ) + .await; + execute_ddl( + &router, + &datasource, + database_name, + "metadata_items", + reorder_sql[0]["sql"].as_str().expect("column reorder SQL"), + ) + .await; + assert_eq!( + column_order(config, database_name, "metadata_items").await, + ["permissions", "state", "b", "a"] + ); + assert_eq!( + primary_key_columns(config, database_name, "metadata_items").await, + ["b", "a"] + ); + assert_eq!( + column_definition(config, database_name, "metadata_items", "b").await, + "bigint unsigned" + ); + assert_eq!( + column_definition(config, database_name, "metadata_items", "state").await, + "enum('','active','not UNSIGNED value','needs,review','O''Reilly')" + ); + assert_eq!( + column_definition(config, database_name, "metadata_items", "permissions").await, + "set('read','write','close)later')" + ); + + execute_ddl( + &router, + &datasource, + database_name, + "reorder_guard", + &format!( + "CREATE TABLE `{database_name}`.`reorder_guard` (\ + `base` INT NOT NULL, \ + `generated_value` INT GENERATED ALWAYS AS (`base` + 1) STORED, \ + `hidden_value` INT INVISIBLE, \ + `tail` INT NOT NULL) ENGINE = InnoDB" + ), + ) + .await; + let guard_table = get( + &router, + &format!( + "/api/rdb/table/query?dataSourceId={datasource}&databaseType=MYSQL&databaseName={database_name}&tableName=reorder_guard" + ), + ) + .await; + let guard_columns = guard_table["columnList"].as_array().expect("guard columns"); + assert!( + column_extra(config, database_name, "reorder_guard", "generated_value") + .await + .contains("STORED GENERATED") + ); + assert!( + column_extra(config, database_name, "reorder_guard", "hidden_value") + .await + .contains("INVISIBLE") + ); + + let mut generated_reorder = guard_table.clone(); + generated_reorder["columnList"] = Value::Array( + ["generated_value", "base", "hidden_value", "tail"] + .iter() + .map(|name| { + guard_columns + .iter() + .find(|column| column["name"] == *name) + .unwrap_or_else(|| panic!("missing guard column {name}")) + .clone() + }) + .collect(), + ); + let generated_failure = post_failure( + &router, + "/api/rdb/table/modify/sql", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "oldTable": guard_table, + "newTable": generated_reorder + }), + ) + .await; + assert_eq!(generated_failure["errorCode"], "invalid_mysql_ddl"); + assert!( + generated_failure["errorMessage"] + .as_str() + .expect("generated-column failure message") + .contains("generated-column") + ); + + let mut invisible_reorder = guard_table.clone(); + invisible_reorder["columnList"] = Value::Array( + ["base", "generated_value", "tail", "hidden_value"] + .iter() + .map(|name| { + guard_columns + .iter() + .find(|column| column["name"] == *name) + .unwrap_or_else(|| panic!("missing guard column {name}")) + .clone() + }) + .collect(), + ); + let invisible_failure = post_failure( + &router, + "/api/rdb/table/modify/sql", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "oldTable": guard_table, + "newTable": invisible_reorder + }), + ) + .await; + assert_eq!(invisible_failure["errorCode"], "invalid_mysql_ddl"); + assert!( + invisible_failure["errorMessage"] + .as_str() + .expect("invisible-column failure message") + .contains("INVISIBLE") + ); + assert_eq!( + column_order(config, database_name, "reorder_guard").await, + ["base", "generated_value", "hidden_value", "tail"] + ); + assert!( + column_extra(config, database_name, "reorder_guard", "generated_value") + .await + .contains("STORED GENERATED") + ); + assert!( + column_extra(config, database_name, "reorder_guard", "hidden_value") + .await + .contains("INVISIBLE") + ); + assert_java_dormant(&application); + let delete_sql = grid_sql( &router, "/api/rdb/dml/get_update_sql", @@ -510,7 +871,7 @@ async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) execute_update(&router, &datasource, database_name, &delete_sql).await; assert_eq!(scalar_count(config, database_name, "items").await, 0); - for table in ["items_copy", "items"] { + for table in ["reorder_guard", "metadata_items", "items_copy", "items"] { post( &router, "/api/rdb/ddl/delete", @@ -801,6 +1162,98 @@ async fn column_exists( count > 0 } +async fn index_exists( + config: &MysqlTestConfig, + database_name: &str, + table_name: &str, + index_name: &str, +) -> bool { + let mut conn = Conn::new(config.options()) + .await + .expect("index probe must connect"); + let count = conn + .exec_first::( + "SELECT COUNT(*) FROM information_schema.STATISTICS \ + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?", + (database_name, table_name, index_name), + ) + .await + .expect("index probe must execute") + .unwrap_or_default(); + conn.disconnect().await.expect("index probe must close"); + count > 0 +} + +async fn column_order( + config: &MysqlTestConfig, + database_name: &str, + table_name: &str, +) -> Vec { + let mut conn = Conn::new(config.options()) + .await + .expect("column-order probe must connect"); + let columns = conn + .exec::( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", + (database_name, table_name), + ) + .await + .expect("column-order probe must execute"); + conn.disconnect() + .await + .expect("column-order probe must close"); + columns +} + +async fn column_definition( + config: &MysqlTestConfig, + database_name: &str, + table_name: &str, + column_name: &str, +) -> String { + let mut conn = Conn::new(config.options()) + .await + .expect("column-definition probe must connect"); + let definition = conn + .exec_first::( + "SELECT COLUMN_TYPE FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?", + (database_name, table_name, column_name), + ) + .await + .expect("column-definition probe must execute") + .expect("column definition must exist"); + conn.disconnect() + .await + .expect("column-definition probe must close"); + definition +} + +async fn column_extra( + config: &MysqlTestConfig, + database_name: &str, + table_name: &str, + column_name: &str, +) -> String { + let mut conn = Conn::new(config.options()) + .await + .expect("column-extra probe must connect"); + let extra = conn + .exec_first::( + "SELECT COALESCE(EXTRA, '') FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?", + (database_name, table_name, column_name), + ) + .await + .expect("column-extra probe must execute") + .expect("column extra must exist"); + conn.disconnect() + .await + .expect("column-extra probe must close"); + extra +} + async fn primary_key_columns( config: &MysqlTestConfig, database_name: &str, diff --git a/crates/chat2db-core/src/community.rs b/crates/chat2db-core/src/community.rs index 56b8b90..151ac7f 100644 --- a/crates/chat2db-core/src/community.rs +++ b/crates/chat2db-core/src/community.rs @@ -293,6 +293,28 @@ impl Application { .await } + /// Rejects native `MySQL` column reorders that cannot be rebuilt losslessly. + /// + /// # Errors + /// + /// Returns datasource, metadata, connection, or unsupported-column errors. + pub async fn validate_native_mysql_column_reorder( + &self, + datasource_id: &str, + database_name: &str, + table_name: &str, + column_names: &[String], + ) -> Result<(), AppError> { + native_mysql::validate_column_reorder( + self, + datasource_id, + database_name, + table_name, + column_names, + ) + .await + } + /// Lists indexes through Community metadata using a forced read-only session. /// /// # Errors diff --git a/crates/chat2db-core/src/native_mysql.rs b/crates/chat2db-core/src/native_mysql.rs index f830012..32af5fe 100644 --- a/crates/chat2db-core/src/native_mysql.rs +++ b/crates/chat2db-core/src/native_mysql.rs @@ -17,7 +17,7 @@ use chat2db_storage::Storage; use mysql_async::{ Column, Conn, Error as MysqlError, Opts, OptsBuilder, Row, SslOpts, Value, consts::{ColumnFlags, ColumnType}, - prelude::{FromValue, Queryable}, + prelude::{FromRow, FromValue, Queryable}, }; use prost::Message; use std::{ @@ -70,20 +70,23 @@ type TableRow = ( Option, Option, ); -type ColumnRow = ( - String, - String, - Option, - String, - String, - String, - String, - i32, - Option, - String, - Option, - Option, -); +#[derive(FromRow)] +#[mysql(crate_name = "mysql_async")] +struct ColumnRow { + name: String, + data_type: String, + default_value: Option, + extra: String, + comment: String, + column_key: String, + is_nullable: String, + ordinal_position: i32, + numeric_scale: Option, + column_definition: String, + charset: Option, + collation: Option, + primary_key_order: i32, +} type IndexRow = ( String, String, @@ -259,12 +262,19 @@ pub(crate) async fn list_columns( validate_metadata_identifier(table_name, "tableName")?; let resolved = resolve_native_connection(application, datasource_id).await?; let mut conn = open_connection(&resolved.connection).await?; - let query = "SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COALESCE(EXTRA, ''), \ - COALESCE(COLUMN_COMMENT, ''), COALESCE(COLUMN_KEY, ''), IS_NULLABLE, \ - ORDINAL_POSITION, NUMERIC_SCALE, COLUMN_TYPE, CHARACTER_SET_NAME, \ - COLLATION_NAME \ - FROM information_schema.COLUMNS \ - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION"; + let query = "SELECT c.COLUMN_NAME AS name, c.DATA_TYPE AS data_type, \ + c.COLUMN_DEFAULT AS default_value, COALESCE(c.EXTRA, '') AS extra, \ + COALESCE(c.COLUMN_COMMENT, '') AS comment, \ + COALESCE(c.COLUMN_KEY, '') AS column_key, c.IS_NULLABLE AS is_nullable, \ + c.ORDINAL_POSITION AS ordinal_position, c.NUMERIC_SCALE AS numeric_scale, \ + c.COLUMN_TYPE AS column_definition, c.CHARACTER_SET_NAME AS charset, \ + c.COLLATION_NAME AS collation, \ + CAST(COALESCE(pk.SEQ_IN_INDEX, 0) AS SIGNED) AS primary_key_order \ + FROM information_schema.COLUMNS AS c \ + LEFT JOIN information_schema.STATISTICS AS pk \ + ON pk.TABLE_SCHEMA = c.TABLE_SCHEMA AND pk.TABLE_NAME = c.TABLE_NAME \ + AND pk.COLUMN_NAME = c.COLUMN_NAME AND pk.INDEX_NAME = 'PRIMARY' \ + WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION"; let result = metadata_query( conn.exec::(query, (database_name.to_owned(), table_name.to_owned())), ) @@ -278,6 +288,56 @@ pub(crate) async fn list_columns( finish_connection(conn, result).await } +pub(crate) async fn validate_column_reorder( + application: &Application, + datasource_id: &str, + database_name: &str, + table_name: &str, + column_names: &[String], +) -> Result<(), AppError> { + if column_names.is_empty() { + return Ok(()); + } + validate_metadata_identifier(database_name, "databaseName")?; + validate_metadata_identifier(table_name, "tableName")?; + let resolved = resolve_native_connection(application, datasource_id).await?; + let mut conn = open_connection(&resolved.connection).await?; + let query = "SELECT COLUMN_NAME, COLUMN_TYPE, COALESCE(EXTRA, ''), \ + COALESCE(GENERATION_EXPRESSION, '') \ + FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?"; + let result = metadata_query(conn.exec::<(String, String, String, String), _, _>( + query, + (database_name.to_owned(), table_name.to_owned()), + )) + .await + .and_then(|rows| { + for column_name in column_names { + let Some((_, column_type, extra, generation_expression)) = rows + .iter() + .find(|(name, _, _, _)| name.eq_ignore_ascii_case(column_name)) + else { + return Err(AppError::invalid( + "invalid_mysql_ddl", + format!( + "Cannot safely reorder column {column_name}: its live metadata changed" + ), + )); + }; + if let Some(reason) = + mysql_column_reorder_hazard(column_type, extra, generation_expression) + { + return Err(AppError::invalid( + "invalid_mysql_ddl", + format!("Cannot safely reorder column {column_name}: {reason}"), + )); + } + } + Ok(()) + }); + finish_connection(conn, result).await +} + pub(crate) async fn list_indexes( application: &Application, datasource_id: &str, @@ -2553,7 +2613,7 @@ fn community_column( database_name: &str, schema_name: &str, table_name: &str, - ( + ColumnRow { name, data_type, default_value, @@ -2566,7 +2626,8 @@ fn community_column( column_definition, charset, collation, - ): ColumnRow, + primary_key_order, + }: ColumnRow, ) -> CommunityTableColumn { let data_type = data_type.to_ascii_uppercase(); let (column_size, decimal_digits) = @@ -2576,15 +2637,17 @@ fn community_column( schema_name: schema_name.to_owned(), table_name: table_name.to_owned(), name, - column_type: data_type, + column_type: mysql_metadata_column_type(&data_type, &column_definition), default_value, auto_increment: Some(extra.contains("auto_increment")), comment, primary_key: Some(column_key.eq_ignore_ascii_case("PRI")), + primary_key_order, column_size, decimal_digits, ordinal_position: Some(ordinal_position), nullable: Some(i32::from(is_nullable.eq_ignore_ascii_case("YES"))), + extent: mysql_enum_set_extent(&data_type, &column_definition), charset: charset.unwrap_or_default(), collation: collation.unwrap_or_default(), on_update_current_timestamp: Some(extra.contains("on update CURRENT_TIMESTAMP")), @@ -2592,6 +2655,84 @@ fn community_column( } } +fn mysql_metadata_column_type(data_type: &str, column_definition: &str) -> String { + let mut projected = data_type.to_owned(); + for modifier in ["UNSIGNED", "ZEROFILL"] { + if mysql_column_has_modifier(column_definition, modifier) { + projected.push(' '); + projected.push_str(modifier); + } + } + projected +} + +fn mysql_column_has_modifier(column_definition: &str, expected: &str) -> bool { + mysql_column_modifier_suffix(column_definition) + .split_ascii_whitespace() + .any(|modifier| modifier.eq_ignore_ascii_case(expected)) +} + +fn mysql_column_modifier_suffix(column_definition: &str) -> &str { + if let Some(close) = column_definition.rfind(')') { + &column_definition[close + 1..] + } else if let Some(separator) = column_definition.find(char::is_whitespace) { + &column_definition[separator..] + } else { + "" + } +} + +fn mysql_column_reorder_hazard( + column_definition: &str, + extra: &str, + generation_expression: &str, +) -> Option<&'static str> { + let normalized_extra = extra + .split_ascii_whitespace() + .collect::>() + .join(" ") + .to_ascii_uppercase(); + if !generation_expression.trim().is_empty() + || normalized_extra + .split_ascii_whitespace() + .any(|part| part == "GENERATED") + { + return Some("generated-column expressions are not represented by the editor"); + } + if normalized_extra + .split_ascii_whitespace() + .any(|part| part == "INVISIBLE") + { + return Some("the INVISIBLE attribute is not represented by the editor"); + } + if mysql_column_has_modifier(column_definition, "ZEROFILL") { + return Some("the ZEROFILL attribute is not represented by the editor"); + } + if !matches!( + normalized_extra.as_str(), + "" | "AUTO_INCREMENT" | "ON UPDATE CURRENT_TIMESTAMP" + ) { + return Some("one or more MySQL column attributes are not represented by the editor"); + } + None +} + +fn mysql_enum_set_extent(data_type: &str, column_definition: &str) -> String { + if !matches!(data_type, "ENUM" | "SET") { + return String::new(); + } + let Some(open) = column_definition.find('(') else { + return String::new(); + }; + let Some(close) = column_definition.rfind(')') else { + return String::new(); + }; + if close <= open { + return String::new(); + } + column_definition[open..=close].to_owned() +} + fn mysql_column_size( data_type: &str, column_definition: &str, @@ -3337,13 +3478,14 @@ mod tests { use tokio::sync::watch; use super::{ - ConsoleExecutionError, ConsoleStatementExecution, MAX_CONSOLE_PAGE_SIZE, + ColumnRow, ConsoleExecutionError, ConsoleStatementExecution, MAX_CONSOLE_PAGE_SIZE, MAX_CONSOLE_RESULT_BYTES, community_column, community_foreign_key, community_function_parameter, community_indexes, community_procedure_parameter, connection_opts, execute_console_statement, is_mysql_database_type, - is_native_read_candidate, normalize_table_type, open_connection_with_opts, - qualified_identifier, quote_identifier, reserve_console_result_bytes, split_mysql_script, - validate_console_request, validate_read_only_console, validate_read_sql, + is_native_read_candidate, mysql_column_reorder_hazard, mysql_metadata_column_type, + normalize_table_type, open_connection_with_opts, qualified_identifier, quote_identifier, + reserve_console_result_bytes, split_mysql_script, validate_console_request, + validate_read_only_console, validate_read_sql, }; use crate::{MysqlConsoleRequest, operation::CancellationRequest}; @@ -3831,35 +3973,123 @@ mod tests { "inventory", "", "items", - ( - "amount".to_owned(), - "decimal".to_owned(), - Some("0.00".to_owned()), - "DEFAULT_GENERATED on update CURRENT_TIMESTAMP".to_owned(), - "Money".to_owned(), - "PRI".to_owned(), - "NO".to_owned(), - 2, - Some(2), - "decimal(12,2) unsigned".to_owned(), - None, - None, - ), + ColumnRow { + name: "amount".to_owned(), + data_type: "decimal".to_owned(), + default_value: Some("0.00".to_owned()), + extra: "DEFAULT_GENERATED on update CURRENT_TIMESTAMP".to_owned(), + comment: "Money".to_owned(), + column_key: "PRI".to_owned(), + is_nullable: "NO".to_owned(), + ordinal_position: 2, + numeric_scale: Some(2), + column_definition: "decimal(12,2) unsigned".to_owned(), + charset: None, + collation: None, + primary_key_order: 2, + }, ); assert_eq!(column.database_name, "inventory"); assert_eq!(column.table_name, "items"); - assert_eq!(column.column_type, "DECIMAL"); + assert_eq!(column.column_type, "DECIMAL UNSIGNED"); assert_eq!(column.default_value.as_deref(), Some("0.00")); assert_eq!(column.column_size, Some(12)); assert_eq!(column.decimal_digits, Some(2)); assert_eq!(column.ordinal_position, Some(2)); assert_eq!(column.nullable, Some(0)); assert_eq!(column.primary_key, Some(true)); + assert_eq!(column.primary_key_order, 2); assert_eq!(column.auto_increment, Some(false)); assert_eq!(column.on_update_current_timestamp, Some(true)); } + #[test] + fn mysql_column_metadata_preserves_enum_and_set_definitions() { + for (data_type, definition, expected_extent) in [ + ( + "enum", + "enum('','draft','not UNSIGNED value','O''Reilly')", + "('','draft','not UNSIGNED value','O''Reilly')", + ), + ( + "set", + "set('read','write','close)later')", + "('read','write','close)later')", + ), + ] { + let column = community_column( + "inventory", + "", + "items", + ColumnRow { + name: "permissions".to_owned(), + data_type: data_type.to_owned(), + default_value: None, + extra: String::new(), + comment: String::new(), + column_key: String::new(), + is_nullable: "YES".to_owned(), + ordinal_position: 3, + numeric_scale: None, + column_definition: definition.to_owned(), + charset: Some("utf8mb4".to_owned()), + collation: Some("utf8mb4_0900_ai_ci".to_owned()), + primary_key_order: 0, + }, + ); + + assert_eq!(column.column_type, data_type.to_ascii_uppercase()); + assert_eq!(column.extent, expected_extent); + assert_eq!(column.column_size, None); + assert_eq!(column.primary_key_order, 0); + } + } + + #[test] + fn mysql_column_metadata_reads_only_real_type_modifiers() { + assert_eq!( + mysql_metadata_column_type("ENUM", "enum('','active','not UNSIGNED value')"), + "ENUM" + ); + assert_eq!( + mysql_metadata_column_type("SET", "set('UNSIGNED','read')"), + "SET" + ); + assert_eq!( + mysql_metadata_column_type("DECIMAL", "decimal(12,2) unsigned"), + "DECIMAL UNSIGNED" + ); + assert_eq!( + mysql_metadata_column_type("INT", "int unsigned zerofill"), + "INT UNSIGNED ZEROFILL" + ); + } + + #[test] + fn mysql_column_reorder_rejects_unrepresented_attributes() { + assert!( + mysql_column_reorder_hazard("int", "STORED GENERATED", "(`base` + 1)") + .is_some_and(|reason| reason.contains("generated-column")) + ); + assert!( + mysql_column_reorder_hazard("int", "INVISIBLE", "") + .is_some_and(|reason| reason.contains("INVISIBLE")) + ); + assert!( + mysql_column_reorder_hazard("int unsigned zerofill", "", "") + .is_some_and(|reason| reason.contains("ZEROFILL")) + ); + assert_eq!( + mysql_column_reorder_hazard("enum('UNSIGNED','active')", "", ""), + None + ); + assert_eq!( + mysql_column_reorder_hazard("bigint unsigned", "AUTO_INCREMENT", ""), + None + ); + } + #[test] fn mysql_index_metadata_groups_columns_and_uses_community_types() { let indexes = community_indexes( diff --git a/docs/architecture.md b/docs/architecture.md index 84979b1..91aec5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,7 +50,14 @@ testing, datasource CRUD/tree, relational object metadata, editable table preview and insert/update/delete SQL, copy/count helpers, database/schema creation and confirmed deletion, table create/alter/drop/truncate/copy, view query/create-or-replace/drop, and bounded unparameterized Console reads, -writes, scripts, transactions, cancellation, history, and large values. +writes, scripts, transactions, cancellation, history, and large values. The +MySQL editor adapter accepts the original frontend's explicit-null rows and +`IN_VALUES` operation, derives column moves from array order, and preserves +`UNSIGNED`, empty and quoted ENUM/SET members, and composite primary-key order +through metadata round trips. Type modifiers are read outside the type's value +list, so an ENUM/SET member containing `UNSIGNED` is not misclassified. Its view +metadata route returns the original six-field creation form without querying an +existing view. Signing, distribution, the remaining dialect estate, broader historical API coverage, and packaging remain target components. CLI and MCP attach to a running host rather than composing a second product runtime. @@ -62,9 +69,13 @@ and retained-result paging. On 2026-07-29 the native MySQL path passed against MySQL 8.4 with a deliberately missing Java executable, including active-query cancellation and dormant-Java assertions after every product operation. The same local Docker MySQL 8.4 service also passed the historical Web editable-grid -and DDL lifecycle, including database, table, and view cleanup, while Java -remained dormant. The complete repository `make verify` gate and an explicit -real-MySQL rerun passed after the final implementation. +and DDL lifecycle, including explicit-null editor payloads, `IN_VALUES`, +drag-only column reordering, type-preserving ENUM/SET and `UNSIGNED` ALTERs, +composite primary-key ordering, and fail-closed generated/invisible-column +reorders that retained their live definitions. View metadata and full +database/table/view cleanup also passed while Java remained dormant. The +complete repository `make verify` gate and an explicit real-MySQL rerun passed +after the final implementation. ## Ownership @@ -146,13 +157,19 @@ The native MySQL baseline implements: - JDBC-URL and connection-property translation into `mysql_async::Opts`, with explicit Rustls policy, TCP preference, and a 15-second connect deadline; - native database/schema/table/column/index/key/view/routine/trigger metadata - and safely quoted bounded table preview; + and safely quoted bounded table preview, including type-suffix-aware + `UNSIGNED`/`ZEROFILL`, empty and quoted ENUM/SET members, and ordered composite + primary keys required by the retained table editor; - structured, bounded insert/update/delete SQL for editable result grids, - primary-key-first optimistic predicates, copy/count helpers, and native - execution with datasource read-only enforcement; + primary-key-first optimistic predicates, copy/count helpers including the + frontend `IN_VALUES` operation, and native execution with datasource + read-only enforcement; - validated database/schema create and confirmed delete, table - create/alter/drop/truncate/copy, and view create-or-replace/drop operations - exposed through the shared historical Web/Tauri dispatcher; + create/alter/drop/truncate/copy, drag-derived column positioning, and view + metadata/create-or-replace/drop operations exposed through the shared + historical Web/Tauri dispatcher. Before rebuilding a moved column, the Rust + path checks its live MySQL definition and rejects generated, invisible, + `ZEROFILL`, or other attributes that the retained editor cannot represent; - semicolon and `DELIMITER` script splitting, preserved-single dispatch, multiple result sets, DDL/DML, explicit transactions, error continuation, `EXPLAIN`, normal/all-row paging, and datasource read-only enforcement; diff --git a/docs/mysql-community-parity.md b/docs/mysql-community-parity.md index e3bbe9a..eef1a9d 100644 --- a/docs/mysql-community-parity.md +++ b/docs/mysql-community-parity.md @@ -3,7 +3,7 @@ ## Status - Community baseline: `OtterMind/Chat2DB` `main@3cb8af54cad5bd5caa20bb25f10d9b0e4f01931c`. -- Rust baseline: `OtterMind/Chat2DB-Rust` `main@e9789cded5d227ba1f48690264046610eedb0807`. +- Rust baseline: `OtterMind/Chat2DB-Rust` `main@e6e0c9ff07b4d2c8d07d8ce8a0ea12849e596db4`. - Product target: the original Community React frontend running against the Rust Web or Tauri host. - Runtime-tested now: datasource CRUD/test; database, schema, table, column, index, foreign-key, primary-key, view, function, procedure, trigger, and @@ -24,7 +24,16 @@ copy-as-SQL helpers, bounded counts, table metadata/query, database/schema create and confirmed delete, table create/alter/drop/truncate/copy, and view query/metadata/create-or-replace/drop. A real Web-to-native-MySQL 8.4 vertical - exercises these mutations while proving Java remains dormant. + exercises these mutations while proving Java remains dormant. The retained + editor now accepts its explicit-null column and index payloads, recognizes + `IN_VALUES`, infers `FIRST`/`AFTER` changes from drag-only array order, and + preserves `UNSIGNED`, empty and quoted ENUM/SET values, and composite + primary-key order across native metadata and subsequent ALTER statements. + Type modifiers are parsed outside ENUM/SET value lists. Dragging a generated, + invisible, `ZEROFILL`, or otherwise unmodeled column is rejected after a live + metadata check instead of emitting a lossy `MODIFY COLUMN`. MySQL `view_meta` + returns the original six form configurations and creation template without + requiring an existing view. - Complete parity: not implemented. This file is the acceptance contract for MySQL work. Community frontend routes @@ -55,10 +64,10 @@ route reaches it and a real MySQL product test covers the behavior. | Datasource import/export and namespaces | converter upload routes, `/api/connection/datasource/import_community`, `/datasource/export`, `/api/namespaces/*` | Not implemented | Support Community, Chat2DB, Navicat, DBeaver, DataGrip, export, grouping, and ordering. | | Database and schema metadata | `/api/rdb/database/list`, `/database_schema_list`, `/api/rdb/schema/list` | Database/schema list implemented | Match filtering, system flags, charset/collation, comments, and pagination envelopes. | | Database and schema mutation | database create/modify/delete and `/api/rdb/delete/{database,schema}/{prepare,execute}` | Historical create-SQL routes and two-phase confirmed database/schema deletion are implemented; database create/delete is real-MySQL tested | Add unsupported database alteration fields and close remaining exact projection differences. | -| Table inventory and detail | `/api/rdb/table/list`, `/table_list`, `/table_meta`, `/column_list`, `/index_list`, `/key_list`, `/query` | List, compact list, table metadata/query, column, index, and key routes are implemented with native MySQL metadata; nullable defaults and legacy envelopes match Community | Close remaining field-level differences as original editor scenarios expose them. | -| Table data operations | `/api/rdb/dml/execute_table`, `/execute_update`, `/get_update_sql`, `/copy_update_sql`, `/copy_in_values_sql`, `/count` | Editable previews, PK-first optimistic insert/update/delete SQL, bounded native execution, copy-as-INSERT/UPDATE/WHERE, `IN` values, and protected count queries are implemented and real-MySQL tested | Close remaining clipboard and uncommon result-type differences. | -| Table DDL | `/api/rdb/ddl/*`, `/api/rdb/table/modify/sql`, `/delete`, `/truncate`, `/copy`, create/update examples, DDL export | Create/alter/drop/truncate/copy previews and execution are implemented for columns, indexes, engine, charset, collation, comments, auto-increment, and MySQL editor types; the lifecycle is real-MySQL tested | Add foreign-key mutation, examples/export, and remaining table options. | -| Views | `/api/rdb/view/list`, `/column_list`, `/detail`, `/query`, `/view_meta`, `/modify/sql`, `/delete`, `/drop` | Native list/detail plus historical query, metadata, create-or-replace preview/execution, and drop are implemented and real-MySQL tested | Add any remaining delete alias and uncommon definer/security projection differences. | +| Table inventory and detail | `/api/rdb/table/list`, `/table_list`, `/table_meta`, `/column_list`, `/index_list`, `/key_list`, `/query` | List, compact list, table metadata/query, column, index, and key routes are implemented with native MySQL metadata; nullable defaults, type-suffix-aware `UNSIGNED`/`ZEROFILL`, empty and quoted ENUM/SET values, composite primary-key order, and legacy envelopes match the retained editor and are real-MySQL tested | Close remaining field-level differences as original editor scenarios expose them. | +| Table data operations | `/api/rdb/dml/execute_table`, `/execute_update`, `/get_update_sql`, `/copy_update_sql`, `/copy_in_values_sql`, `/count` | Editable previews, PK-first optimistic insert/update/delete SQL, bounded native execution, copy-as-INSERT/UPDATE/WHERE, frontend `IN_VALUES`, and protected count queries are implemented and real-MySQL tested | Close remaining clipboard and uncommon result-type differences. | +| Table DDL | `/api/rdb/ddl/*`, `/api/rdb/table/modify/sql`, `/delete`, `/truncate`, `/copy`, create/update examples, DDL export | Create/alter/drop/truncate/copy previews and execution are implemented for columns, indexes, engine, charset, collation, comments, auto-increment, and MySQL editor types; explicit-null editor rows and drag-only `FIRST`/`AFTER` reordering are real-MySQL tested, while live metadata rejects generated, invisible, `ZEROFILL`, and other unmodeled columns before a lossy reorder | Add foreign-key mutation, examples/export, and remaining table options. | +| Views | `/api/rdb/view/list`, `/column_list`, `/detail`, `/query`, `/view_meta`, `/modify/sql`, `/delete`, `/drop` | Native list/detail plus historical query, the six-option Community `view_meta` creation template, create-or-replace preview/execution, and drop are implemented and real-MySQL tested | Add any remaining delete alias and uncommon definer/security projection differences. | | Functions, procedures, and triggers | `/api/rdb/{function,procedure,trigger}/{list,detail}`, `/api/rdb/routine/{preview_invocation,preview_migration,execute_migration}` | Native list/detail and routine-parameter projections are implemented; every original list/detail route is mapped | Add invocation and migration preview/execution flows. | | Console SELECT | `/api/rdb/dml/execute`, desktop `sql-execute`/`sql-cancel` | Native unparameterized MySQL reads, CTEs, normal/all-row paging, preserved-single dispatch, `EXPLAIN`, limits, multiple result sets, affected-row counts, datasource read-only enforcement, and cancellation implemented | Add JDBC-style bind parameters and close remaining warning/error/result-shape differences. | | Console scripts and writes | `/api/rdb/dml/execute`, `/execute_ddl` | Native unparameterized DDL/DML, semicolon and `DELIMITER` scripts, explicit transactions, error-continue policy, cancellation, and per-statement results implemented | Add bind parameters and complete exact Community conformance for unsupported edge-case scripts. |