diff --git a/apps/chat2db-web/src/legacy.rs b/apps/chat2db-web/src/legacy.rs index 0c85320..a05dcdd 100644 --- a/apps/chat2db-web/src/legacy.rs +++ b/apps/chat2db-web/src/legacy.rs @@ -1116,6 +1116,12 @@ pub struct LegacyDriverQuery { pub db_type: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyTableDdlExampleQuery { + pub db_type: String, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LegacyMetadataQuery { @@ -1958,6 +1964,36 @@ pub(crate) async fn list_keys( list_indexes(application, query).await } +/// Returns the native `SHOW CREATE TABLE` result used by Community's export action. +pub(crate) async fn export_table_ddl( + application: &Application, + query: &LegacyTableDetailQuery, +) -> LegacyResult { + let datasource_id = query.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &query.database_type).await?; + Ok(application + .table_ddl( + &datasource_id, + &query.database_name, + &query.schema_name, + &query.table_name, + ) + .await?) +} + +/// Preserves Community `MySQL`'s null create/alter example configuration. +pub(crate) fn mysql_table_ddl_example( + query: &LegacyTableDdlExampleQuery, +) -> LegacyResult> { + if normalize_database_type(&query.db_type) != "MYSQL" { + return Err(LegacyFailure { + code: "unsupported_database_type".to_owned(), + message: "This Community compatibility route currently supports MySQL only".to_owned(), + }); + } + Ok(None) +} + /// Lists views in the same page wrapper used by the retained tree. pub(crate) async fn list_views( application: &Application, @@ -5734,6 +5770,22 @@ async fn dispatch_inner( Err(error) => Err(error), } } + ("get", "/api/rdb/ddl/export" | "/api/rdb/table/export") => { + match decode::(request.message) { + Ok(query) => serialized(export_table_ddl(application, &query).await), + Err(error) => Err(error), + } + } + ( + "get", + "/api/rdb/ddl/create/example" + | "/api/rdb/ddl/update/example" + | "/api/rdb/table/create/example" + | "/api/rdb/table/update/example", + ) => match decode::(request.message) { + Ok(query) => serialized(mysql_table_ddl_example(&query)), + Err(error) => Err(error), + }, ("get", "/api/rdb/table/list") => match decode::(request.message) { Ok(query) => serialized(list_tables(application, &query).await), Err(error) => Err(error), @@ -5983,6 +6035,9 @@ const LEGACY_PATHS: &[&str] = &[ "/api/rdb/table/list", "/api/rdb/table/table_meta", "/api/rdb/table/query", + "/api/rdb/table/export", + "/api/rdb/table/create/example", + "/api/rdb/table/update/example", "/api/rdb/table/modify/sql", "/api/rdb/table/truncate", "/api/rdb/table/copy", @@ -5993,6 +6048,9 @@ const LEGACY_PATHS: &[&str] = &[ "/api/rdb/ddl/column_list", "/api/rdb/ddl/index_list", "/api/rdb/ddl/key_list", + "/api/rdb/ddl/export", + "/api/rdb/ddl/create/example", + "/api/rdb/ddl/update/example", "/api/rdb/ddl/delete", "/api/rdb/delete/database/prepare", "/api/rdb/delete/database/execute", @@ -6148,6 +6206,15 @@ pub(crate) fn routes() -> Router { .route("/api/rdb/table/list", get(table_list_handler)) .route("/api/rdb/table/table_meta", get(table_meta_handler)) .route("/api/rdb/table/query", get(table_query_handler)) + .route("/api/rdb/table/export", get(table_ddl_export_handler)) + .route( + "/api/rdb/table/create/example", + get(table_ddl_example_handler), + ) + .route( + "/api/rdb/table/update/example", + get(table_ddl_example_handler), + ) .route("/api/rdb/table/modify/sql", post(table_modify_sql_handler)) .route("/api/rdb/table/truncate", post(table_truncate_handler)) .route("/api/rdb/table/copy", post(table_copy_handler)) @@ -6158,6 +6225,15 @@ pub(crate) fn routes() -> Router { .route("/api/rdb/ddl/column_list", get(ddl_column_list_handler)) .route("/api/rdb/ddl/index_list", get(ddl_index_list_handler)) .route("/api/rdb/ddl/key_list", get(ddl_key_list_handler)) + .route("/api/rdb/ddl/export", get(table_ddl_export_handler)) + .route( + "/api/rdb/ddl/create/example", + get(table_ddl_example_handler), + ) + .route( + "/api/rdb/ddl/update/example", + get(table_ddl_example_handler), + ) .route("/api/rdb/ddl/delete", post(table_drop_handler)) .route( "/api/rdb/delete/database/prepare", @@ -6432,6 +6508,19 @@ async fn table_query_handler( envelope(Box::pin(get_editable_table(&application, &query)).await) } +async fn table_ddl_export_handler( + State(application): State, + Query(query): Query, +) -> Json> { + envelope(export_table_ddl(&application, &query).await) +} + +async fn table_ddl_example_handler( + Query(query): Query, +) -> Json>> { + envelope(mysql_table_ddl_example(&query)) +} + async fn table_modify_sql_handler( State(application): State, Json(request): Json, @@ -6760,6 +6849,12 @@ mod tests { const REQUIRED_EDITABLE_PATHS: &[(&str, &str)] = &[ ("get", "/api/rdb/table/table_meta"), ("get", "/api/rdb/table/query"), + ("get", "/api/rdb/table/export"), + ("get", "/api/rdb/table/create/example"), + ("get", "/api/rdb/table/update/example"), + ("get", "/api/rdb/ddl/export"), + ("get", "/api/rdb/ddl/create/example"), + ("get", "/api/rdb/ddl/update/example"), ("post", "/api/rdb/table/modify/sql"), ("post", "/api/rdb/ddl/delete"), ("post", "/api/rdb/table/truncate"), @@ -7444,6 +7539,52 @@ mod tests { } } + #[tokio::test] + async fn mysql_ddl_example_aliases_preserve_community_null_data() { + let application = Application::new(); + let router = routes().with_state(application.clone()); + for path in [ + "/api/rdb/ddl/create/example", + "/api/rdb/ddl/update/example", + "/api/rdb/table/create/example", + "/api/rdb/table/update/example", + ] { + let response = router + .clone() + .oneshot( + Request::get(format!("{path}?dbType=MYSQL")) + .body(Body::empty()) + .expect("request must build"), + ) + .await + .expect("router must respond"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("response body must collect") + .to_bytes(); + let http: serde_json::Value = + serde_json::from_slice(&body).expect("response must be JSON"); + assert_eq!(http["success"], true, "HTTP alias failed: {path}"); + assert!(http["data"].is_null(), "HTTP alias returned SQL: {path}"); + assert!(http["errorCode"].is_null()); + assert!(http["errorMessage"].is_null()); + + let desktop = dispatch( + &application, + LegacyDispatchRequest { + request_url: path.to_owned(), + method: "get".to_owned(), + message: serde_json::json!({"dbType": "MYSQL"}), + }, + ) + .await; + assert_eq!(desktop, http, "desktop alias diverged: {path}"); + } + } + #[test] fn metadata_projections_use_the_historical_frontend_field_names() { let column = column_response(CommunityTableColumn { 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 b55e4fd..719dd91 100644 --- a/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs +++ b/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs @@ -214,6 +214,46 @@ async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) ) .await; + let export_message = json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "schemaName": "", + "tableName": "items" + }); + let mut exported_ddl = None; + for path in ["/api/rdb/ddl/export", "/api/rdb/table/export"] { + let http_ddl = get( + &router, + &format!( + "{path}?dataSourceId={datasource}&databaseType=MYSQL&databaseName={database_name}&schemaName=&tableName=items" + ), + ) + .await; + let http_ddl = http_ddl.as_str().expect("exported DDL must be a string"); + assert!(http_ddl.starts_with("CREATE TABLE `items`")); + assert!(http_ddl.contains("`label` varchar(128) NOT NULL COMMENT 'editable label'")); + assert!(http_ddl.ends_with(';')); + if let Some(expected) = exported_ddl.as_deref() { + assert_eq!(http_ddl, expected, "HTTP export aliases diverged"); + } else { + exported_ddl = Some(http_ddl.to_owned()); + } + + let desktop = chat2db_web::legacy::dispatch( + &application, + chat2db_web::legacy::LegacyDispatchRequest { + request_url: path.to_owned(), + method: "get".to_owned(), + message: export_message.clone(), + }, + ) + .await; + assert_eq!(desktop["success"], true, "desktop export failed: {desktop}"); + assert_eq!(desktop["data"], http_ddl, "desktop export diverged: {path}"); + } + assert_java_dormant(&application); + let old_table = get( &router, &format!( diff --git a/crates/chat2db-core/src/community.rs b/crates/chat2db-core/src/community.rs index 151ac7f..b155fd2 100644 --- a/crates/chat2db-core/src/community.rs +++ b/crates/chat2db-core/src/community.rs @@ -315,6 +315,21 @@ impl Application { .await } + /// Reads a `MySQL` table definition through the native driver without starting Java. + /// + /// # Errors + /// + /// Returns datasource, metadata, connection, or identifier validation errors. + pub async fn table_ddl( + &self, + data_source_id: &str, + database_name: &str, + schema_name: &str, + table_name: &str, + ) -> Result { + native_mysql::table_ddl(self, data_source_id, database_name, schema_name, table_name).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 32af5fe..9f24651 100644 --- a/crates/chat2db-core/src/native_mysql.rs +++ b/crates/chat2db-core/src/native_mysql.rs @@ -430,6 +430,30 @@ pub(crate) async fn get_view( finish_connection(conn, result).await } +pub(crate) async fn table_ddl( + application: &Application, + datasource_id: &str, + database_name: &str, + _schema_name: &str, + table_name: &str, +) -> Result { + validate_metadata_identifier(database_name, "databaseName")?; + validate_metadata_identifier(table_name, "tableName")?; + let qualified_name = + qualified_identifier(database_name, "databaseName", table_name, "tableName")?; + let resolved = resolve_native_connection(application, datasource_id).await?; + let mut conn = open_connection(&resolved.connection).await?; + let result = + metadata_query(conn.query_first::(format!("SHOW CREATE TABLE {qualified_name}"))) + .await + .and_then(|row| { + let row = + row.ok_or_else(|| metadata_not_found("table", database_name, table_name))?; + row_string_at(&row, 1).map(|ddl| format!("{ddl};")) + }); + finish_connection(conn, result).await +} + pub(crate) async fn list_imported_keys( application: &Application, datasource_id: &str, diff --git a/crates/chat2db-core/tests/native_mysql_product.rs b/crates/chat2db-core/tests/native_mysql_product.rs index c00c483..ebe5612 100644 --- a/crates/chat2db-core/tests/native_mysql_product.rs +++ b/crates/chat2db-core/tests/native_mysql_product.rs @@ -248,6 +248,22 @@ async fn verify_native_metadata( assert_eq!(table.table_type, "TABLE"); assert_eq!(table.engine, "InnoDB"); assert_java_dormant(application); + + let ddl = application + .table_ddl(datasource_id, database_name, "", "items") + .await + .expect("native MySQL table DDL must load"); + assert!(ddl.starts_with("CREATE TABLE `items`")); + assert!(ddl.contains("CONSTRAINT `fk_items_category`")); + assert!(ddl.ends_with(';')); + assert_java_dormant(application); + + let invalid = application + .table_ddl(datasource_id, database_name, "", "") + .await + .expect_err("an empty MySQL table identifier must be rejected"); + assert_eq!(invalid.api_error().code, "invalid_mysql_metadata_request"); + assert_java_dormant(application); } #[allow(clippy::too_many_lines)] diff --git a/docs/mysql-community-parity.md b/docs/mysql-community-parity.md index eef1a9d..7e7d0bc 100644 --- a/docs/mysql-community-parity.md +++ b/docs/mysql-community-parity.md @@ -3,7 +3,9 @@ ## Status - Community baseline: `OtterMind/Chat2DB` `main@3cb8af54cad5bd5caa20bb25f10d9b0e4f01931c`. -- Rust baseline: `OtterMind/Chat2DB-Rust` `main@e6e0c9ff07b4d2c8d07d8ce8a0ea12849e596db4`. +- Rust baseline: `OtterMind/Chat2DB-Rust` `main@fb042aa056a7dbc18969f1bc191d2b771fea787d`. +- Current Issue `#10` branch: commits `1e52a69` and `21947cb` add native + table-DDL retrieval plus the original Web and Tauri route aliases. - 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 @@ -33,7 +35,11 @@ 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. + requiring an existing view. Native `SHOW CREATE TABLE` now backs both + `/api/rdb/ddl/export` and `/api/rdb/table/export`; the four Community + create/update example aliases preserve MySQL's successful `data: null` + contract. HTTP and desktop dispatch return identical envelopes, and the real + MySQL 8.4 vertical proves Java remains dormant. - Complete parity: not implemented. This file is the acceptance contract for MySQL work. Community frontend routes @@ -66,7 +72,7 @@ route reaches it and a real MySQL product test covers the behavior. | 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, 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. | +| 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. Native `SHOW CREATE TABLE` backs both export aliases with the Community trailing semicolon; all four MySQL example aliases preserve Community's null response. Foreign keys are implemented as read-only metadata; pinned Community `MysqlSqlBuilder` and `MysqlIndexTypeEnum` do not generate or modify `foreignKeyList`, and the Community MySQL editor exposes no foreign-key mutation contract. | Add remaining table options and close field-level edge cases; foreign-key mutation is not a current Community parity requirement, while foreign-key metadata remains available to read-only metadata and future ER flows. | | 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. | @@ -90,8 +96,11 @@ route reaches it and a real MySQL product test covers the behavior. large-cell retrieval. Bind parameters and remaining exact Community edge-case conformance are still required for complete parity. 3. Implemented slice: table data editing plus database/schema/table/view DDL - preview and execution. Foreign-key mutation, DDL export/examples, and - remaining exact Community edge cases are still required for complete parity. + preview and execution, native table DDL retrieval/export, and the Community + create/update example route aliases. + Foreign-key mutation is not a current Community MySQL editor requirement; + foreign keys remain read-only metadata for metadata and future ER flows. + Remaining exact Community edge cases are still required for complete parity. 4. Import/export/tasks, datasource lifecycle/SSH/import, account administration, routines, structure comparison, pins, and ER metadata. 5. Original AI mapping and MySQL conformance for Agent, CLI, and MCP.