diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a39eef5..d7f7153 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,6 +203,15 @@ jobs: run: >- cargo test -p chat2db-core --test native_mysql_console_docker --locked -- --ignored + - name: Verify native MySQL editable grid and DDL without Java + env: + MYSQL_TEST_HOST: 127.0.0.1 + MYSQL_TEST_PORT: "3306" + MYSQL_TEST_USER: root + MYSQL_TEST_PASSWORD: chat2db-ci-root + run: >- + cargo test -p chat2db-web --test native_mysql_editable_ddl_docker --locked + -- --ignored - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.7.1 with: distribution: temurin diff --git a/Cargo.lock b/Cargo.lock index 65a9b07..43ae5cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -785,6 +785,7 @@ dependencies = [ "chrono", "futures-util", "http-body-util", + "mysql_async", "serde", "serde_json", "subtle", @@ -796,6 +797,7 @@ dependencies = [ "tracing-subscriber", "utoipa", "utoipa-axum", + "uuid", ] [[package]] diff --git a/Makefile b/Makefile index 89336ab..877b725 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,11 @@ native-mysql-integration: MYSQL_TEST_USER="$(MYSQL_TEST_USER)" \ MYSQL_TEST_PASSWORD="$(MYSQL_TEST_PASSWORD)" \ cargo test -p chat2db-core --test native_mysql_console_docker --locked -- --ignored + @MYSQL_TEST_HOST="$(MYSQL_TEST_HOST)" \ + MYSQL_TEST_PORT="$(MYSQL_TEST_PORT)" \ + MYSQL_TEST_USER="$(MYSQL_TEST_USER)" \ + MYSQL_TEST_PASSWORD="$(MYSQL_TEST_PASSWORD)" \ + cargo test -p chat2db-web --test native_mysql_editable_ddl_docker --locked -- --ignored community-product-mysql-integration: java community-h2-classpath mysql-driver-pack @test -n "$(MYSQL_TEST_USER)" || (echo "MYSQL_TEST_USER is required" >&2; exit 1) diff --git a/apps/chat2db-web/Cargo.toml b/apps/chat2db-web/Cargo.toml index f192297..4c859e8 100644 --- a/apps/chat2db-web/Cargo.toml +++ b/apps/chat2db-web/Cargo.toml @@ -31,10 +31,12 @@ utoipa-axum.workspace = true [dev-dependencies] http-body-util.workspace = true +mysql_async.workspace = true serde.workspace = true serde_json.workspace = true tempfile = "3" tower.workspace = true +uuid.workspace = true [lints] workspace = true diff --git a/apps/chat2db-web/src/legacy.rs b/apps/chat2db-web/src/legacy.rs index abf1dd9..ae76b20 100644 --- a/apps/chat2db-web/src/legacy.rs +++ b/apps/chat2db-web/src/legacy.rs @@ -7,6 +7,7 @@ use std::{ collections::{BTreeMap, HashSet}, fs, + future::Future, path::PathBuf, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; @@ -36,6 +37,21 @@ use chat2db_contract::{ use chat2db_core::{ AppError, Application, LargeValueChunk, LargeValueEncoding, LargeValuePreview, LargeValueType, MysqlConsoleCancellation, MysqlConsoleRequest, MysqlConsoleResult, + mysql_ddl::{ + MysqlColumnAlter, MysqlColumnDefinition, MysqlColumnPosition, MysqlDatabaseDefinition, + MysqlIndexAlter, MysqlIndexColumn, MysqlIndexDefinition, MysqlIndexKind, MysqlIndexMethod, + MysqlQualifiedName, MysqlResultGridCopyOperation, MysqlResultGridCopyOperationType, + MysqlResultGridHeader, MysqlResultGridOperation, MysqlResultGridOperationType, + MysqlSortOrder, MysqlTableAlter, MysqlTableCopy, MysqlTableDefinition, + MysqlTableEditorMeta, MysqlViewAlgorithm, MysqlViewCheckOption, MysqlViewDefiner, + MysqlViewDefinition, MysqlViewSecurity, build_mysql_alter_table, build_mysql_copy_table, + build_mysql_count_query, build_mysql_create_database, build_mysql_create_schema, + build_mysql_create_table, build_mysql_create_view, build_mysql_drop_database, + build_mysql_drop_schema, build_mysql_drop_table, build_mysql_drop_view, + build_mysql_external_in_values, build_mysql_result_grid_copy_sql, + build_mysql_result_grid_in_values, build_mysql_result_grid_script, + build_mysql_truncate_table, mysql_table_editor_meta, + }, }; use chat2db_storage::{ CreateOperationLog, CreateSavedConsole, OperationLogListQuery, OperationLogRecord, @@ -54,6 +70,7 @@ const MAX_SQL_ROWS: u32 = 10_000; const LARGE_VALUE_CHUNK_SIZE: u32 = 256 * 1024; const LARGE_VALUE_FALLBACK_PREVIEW_BYTES: usize = 64 * 1024; const RESULT_PAGE_MAX_BYTES: u64 = 8 * 1024 * 1024; +const LARGE_VALUE_PREVIEW_PREFIX: &str = "CHAT2DB_LARGE_VALUE_PREVIEW:"; const PREVIEW_TIMEOUT: Duration = Duration::from_secs(30); const SQL_EXECUTION_TIMEOUT: Duration = Duration::from_secs(60); const SQL_CANCELLATION_GRACE: Duration = Duration::from_secs(10); @@ -338,8 +355,8 @@ pub struct LegacySimpleTable { pub table_type: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] pub struct LegacyColumn { pub old_name: Option, pub name: String, @@ -350,6 +367,8 @@ pub struct LegacyColumn { pub auto_increment: Option, pub comment: String, pub primary_key: Option, + pub primary_key_name: String, + pub primary_key_order: i32, pub schema_name: String, pub database_name: String, pub type_name: Option, @@ -365,11 +384,20 @@ pub struct LegacyColumn { pub nullable: Option, pub generated_column: Option, pub extent: String, + pub char_set_name: String, + pub collation_name: String, + pub value: String, + pub unit: String, + pub sparse: Option, + pub default_constraint_name: String, + pub seed: Option, + pub increment: Option, + pub on_update_current_timestamp: Option, pub edit_status: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] pub struct LegacyIndexColumn { pub index_name: String, pub table_name: String, @@ -387,17 +415,269 @@ pub struct LegacyIndexColumn { pub cardinality: Option, pub pages: Option, pub filter_condition: String, + pub sub_part: Option, + pub edit_status: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] pub struct LegacyIndex { pub columns: Option, + pub old_name: Option, pub name: String, + pub table_name: String, #[serde(rename = "type")] pub index_type: String, + pub unique: Option, pub comment: String, + pub schema_name: String, + pub database_name: String, pub column_list: Vec, + pub edit_status: Option, + pub concurrently: Option, + pub method: String, + pub foreign_schema_name: String, + pub foreign_table_name: String, + pub foreign_column_namelist: Vec, +} + +/// Full table projection consumed by Community's retained table editor. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LegacyEditableTable { + pub name: String, + pub comment: String, + pub schema_name: String, + pub database_name: String, + #[serde(rename = "type")] + pub table_type: String, + pub column_list: Vec, + pub index_list: Vec, + pub foreign_key_list: Vec, + pub db_type: String, + pub pinned: bool, + pub ddl: String, + pub engine: String, + pub charset: String, + pub collate: String, + pub increment_value: Option, + pub partition: String, + pub tablespace: String, + pub rows: Option, + pub data_length: Option, + pub create_time: String, + pub update_time: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyTableModifyRequest { + pub data_source_id: LegacyIdentifier, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub table_name: String, + #[serde(default)] + pub old_table: Option, + pub new_table: LegacyEditableTable, +} + +/// Historical `{ sql }` object returned by DDL-preview routes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacySqlResponse { + pub sql: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyGridHeaderRequest { + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub column_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub column_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub data_type: String, + #[serde(default, deserialize_with = "deserialize_boolish_or_default")] + pub primary_key: bool, + #[serde(default, deserialize_with = "deserialize_boolish_or_default")] + pub auto_increment: bool, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyGridOperationRequest { + #[serde( + rename = "type", + default, + deserialize_with = "deserialize_string_or_default" + )] + pub operation_type: String, + #[serde(default, deserialize_with = "deserialize_optional_string_vec")] + pub data_list: Vec>, + #[serde(default, deserialize_with = "deserialize_optional_string_vec")] + pub old_data_list: Vec>, + #[serde(default)] + pub select_cols: Vec, + #[serde(default)] + pub selected_cell: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyGridUpdateRequest { + pub data_source_id: LegacyIdentifier, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub table_name: String, + #[serde(default)] + pub console_id: Option, + #[serde(default)] + pub header_list: Vec, + #[serde(default)] + pub operations: Vec, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub source_type: String, + #[serde(default)] + pub external_values: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyTableOperationRequest { + pub data_source_id: LegacyIdentifier, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub table_name: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyTableCopyRequest { + pub data_source_id: LegacyIdentifier, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub table_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub new_name: String, + #[serde(default)] + pub copy_data: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyDatabaseDefinitionRequest { + pub data_source_id: LegacyIdentifier, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub charset: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub collation: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacySchemaDefinitionRequest { + pub data_source_id: LegacyIdentifier, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyDeleteObjectRequest { + pub data_source_id: LegacyIdentifier, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub confirm_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyDeletePrepareResponse { + pub confirm_name: String, + pub sql_preview: String, + pub object_type: String, + pub db_type: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyViewOperationRequest { + pub data_source_id: LegacyIdentifier, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_type: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub database_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub schema_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub table_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub view_name: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub view_body: String, + #[serde(default)] + pub view_attributes: Vec, + #[serde(default)] + pub use_or_replace: bool, + #[serde(default)] + pub use_if_not_exists: bool, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub algorithm: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub definer: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub security: String, + #[serde(default, deserialize_with = "deserialize_string_or_default")] + pub check_option: String, + #[serde(default)] + pub is_modify: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyViewMetaResponse { + pub configurations: Vec, + pub sql: String, + pub preview_sql: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -944,6 +1224,46 @@ where Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) } +fn deserialize_boolish_or_default<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(serde_json::Value::Bool(value)) => value, + Some(serde_json::Value::Number(value)) => value.as_i64().is_some_and(|value| value != 0), + Some(serde_json::Value::String(value)) => { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) + } + None + | Some( + serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_), + ) => false, + }) +} + +fn deserialize_optional_string_vec<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + Option::>::deserialize(deserializer)? + .unwrap_or_default() + .into_iter() + .map(|value| match value { + serde_json::Value::Null => Ok(None), + serde_json::Value::String(value) => Ok(Some(value)), + serde_json::Value::Bool(value) => Ok(Some(value.to_string())), + serde_json::Value::Number(value) => Ok(Some(value.to_string())), + _ => Err(serde::de::Error::custom( + "grid values must be strings, numbers, booleans, or null", + )), + }) + .collect() +} + fn default_page_no() -> u32 { DEFAULT_PAGE_NO } @@ -1615,36 +1935,492 @@ pub(crate) async fn get_view( )) } -/// Lists stored functions in the historical paged metadata shape. -pub(crate) async fn list_functions( +/// Reads the full table projection required by Community's table editor. +pub(crate) async fn get_editable_table( application: &Application, - query: &LegacyTableListQuery, -) -> LegacyResult> { - validate_metadata_page(query)?; + query: &LegacyTableDetailQuery, +) -> LegacyResult { + if query.table_name.trim().is_empty() { + return Err(LegacyFailure::invalid( + "invalid_table_query", + "tableName is required", + )); + } let datasource_id = query.data_source_id.as_string(); let database_type = - resolve_database_type(application, &datasource_id, &query.database_type).await?; - let items = application - .list_community_functions(ListCommunityFunctionsRequest { - datasource_id, - database_type, + resolve_mysql_database_type(application, &datasource_id, &query.database_type).await?; + let tables = application + .list_community_tables(ListCommunityTablesRequest { + datasource_id: datasource_id.clone(), + database_type: database_type.clone(), database_name: query.database_name.clone(), schema_name: query.schema_name.clone(), + table_name_pattern: query.table_name.clone(), }) .await? - .items + .items; + let table = tables .into_iter() - .map(function_response) - .collect(); - Ok(full_page(items)) + .find(|table| table.name.eq_ignore_ascii_case(&query.table_name)) + .ok_or_else(|| LegacyFailure { + code: "table_not_found".to_owned(), + message: format!("Table {} does not exist", query.table_name), + })?; + let (columns, indexes) = tokio::try_join!( + application.list_community_columns(ListCommunityColumnsRequest { + datasource_id: datasource_id.clone(), + database_type: database_type.clone(), + database_name: query.database_name.clone(), + schema_name: query.schema_name.clone(), + table_name: query.table_name.clone(), + }), + application.list_community_indexes(ListCommunityIndexesRequest { + datasource_id, + database_type: database_type.clone(), + database_name: query.database_name.clone(), + schema_name: query.schema_name.clone(), + table_name: query.table_name.clone(), + }), + )?; + Ok(editable_table_response( + table, + columns.items, + indexes.items, + database_type, + )) } -/// Reads one stored function in the historical metadata shape. -pub(crate) async fn get_function( +/// Reads the full view projection used by Community's retained view editor. +pub(crate) async fn get_editable_view( application: &Application, - query: &LegacyFunctionDetailQuery, -) -> LegacyResult { - let datasource_id = query.data_source_id.as_string(); + query: &LegacyTableDetailQuery, +) -> LegacyResult { + if query.table_name.trim().is_empty() { + return Err(LegacyFailure::invalid( + "invalid_view_query", + "tableName is required", + )); + } + let datasource_id = query.data_source_id.as_string(); + let database_type = + resolve_mysql_database_type(application, &datasource_id, &query.database_type).await?; + let (view, columns) = tokio::try_join!( + application.get_community_view(ListCommunityViewsRequest { + datasource_id: datasource_id.clone(), + database_type: database_type.clone(), + database_name: query.database_name.clone(), + schema_name: query.schema_name.clone(), + view_name_pattern: query.table_name.clone(), + }), + application.list_community_columns(ListCommunityColumnsRequest { + datasource_id, + database_type: database_type.clone(), + database_name: query.database_name.clone(), + schema_name: query.schema_name.clone(), + table_name: query.table_name.clone(), + }), + )?; + Ok(editable_table_response( + view, + columns.items, + Vec::new(), + database_type, + )) +} + +/// Returns the `MySQL` type and option inventory used by the retained table editor. +pub(crate) async fn table_editor_meta( + application: &Application, + query: &LegacyMetadataQuery, +) -> LegacyResult { + let datasource_id = query.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &query.database_type).await?; + Ok(mysql_table_editor_meta()) +} + +/// Builds a `MySQL` script for Community result-grid create, update, and delete operations. +pub(crate) async fn build_grid_update_sql( + application: &Application, + request: &LegacyGridUpdateRequest, +) -> LegacyResult { + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + if request.table_name.trim().is_empty() { + return Err(LegacyFailure::invalid( + "invalid_mysql_result_grid", + "tableName is required", + )); + } + let headers = request + .header_list + .iter() + .map(mysql_grid_header) + .collect::>>()?; + let operations = request + .operations + .iter() + .map(mysql_grid_operation) + .collect::>>()?; + reject_legacy_partial_large_values(&operations)?; + Ok(build_mysql_result_grid_script( + &mysql_qualified_name( + &request.database_name, + &request.schema_name, + &request.table_name, + ), + &headers, + &operations, + )?) +} + +/// Builds Community's copy-as-INSERT, copy-as-UPDATE, or copy-as-WHERE SQL. +pub(crate) async fn build_grid_copy_sql( + application: &Application, + request: &LegacyGridUpdateRequest, +) -> LegacyResult { + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + let headers = request + .header_list + .iter() + .map(mysql_grid_header) + .collect::>>()?; + let operations = request + .operations + .iter() + .map(mysql_grid_copy_operation) + .collect::>>()?; + Ok(build_mysql_result_grid_copy_sql( + &mysql_qualified_name( + &request.database_name, + &request.schema_name, + &required_name(&request.table_name, "tableName")?, + ), + &headers, + &operations, + )?) +} + +/// Builds Community's clipboard SQL `IN` list for result cells or external text. +pub(crate) async fn build_grid_in_values( + application: &Application, + request: &LegacyGridUpdateRequest, +) -> LegacyResult { + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + match request.source_type.trim().to_ascii_uppercase().as_str() { + "EXTERNAL_TEXT" => Ok(build_mysql_external_in_values(&request.external_values)?), + "RESULT_SET" => { + let headers = request + .header_list + .iter() + .map(mysql_grid_header) + .collect::>>()?; + reject_unsupported_copy_cells(&request.operations)?; + let operations = request + .operations + .iter() + .map(mysql_grid_copy_operation) + .collect::>>()?; + Ok(build_mysql_result_grid_in_values(&headers, &operations)?) + } + _ => Err(LegacyFailure::invalid( + "invalid_mysql_result_grid", + "sourceType must be RESULT_SET or EXTERNAL_TEXT", + )), + } +} + +/// Builds CREATE or ALTER TABLE statements in the historical `{ sql }[]` shape. +pub(crate) async fn build_table_modify_sql( + application: &Application, + request: &LegacyTableModifyRequest, +) -> LegacyResult> { + 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() { + build_mysql_alter_table(&mysql_table_alter( + old_table, + &request.new_table, + &request.database_name, + &request.schema_name, + )?)? + } else { + build_mysql_create_table(&mysql_table_definition( + &request.new_table, + &request.database_name, + &request.schema_name, + )?)? + }; + Ok(vec![LegacySqlResponse { sql }]) +} + +/// Builds a CREATE DATABASE preview without executing it. +pub(crate) async fn build_create_database_sql( + application: &Application, + request: &LegacyDatabaseDefinitionRequest, +) -> LegacyResult { + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + let name = first_non_blank(&request.name, &request.database_name); + Ok(LegacySqlResponse { + sql: build_mysql_create_database(&MysqlDatabaseDefinition { + name, + if_not_exists: false, + charset: non_blank(&request.charset), + collation: non_blank(&request.collation), + })?, + }) +} + +/// `MySQL` treats Community schemas as database aliases. +pub(crate) async fn build_create_schema_sql( + application: &Application, + request: &LegacySchemaDefinitionRequest, +) -> LegacyResult { + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + let name = first_non_blank(&request.name, &request.schema_name); + Ok(LegacySqlResponse { + sql: build_mysql_create_schema(&MysqlDatabaseDefinition { + name, + if_not_exists: false, + charset: None, + collation: None, + })?, + }) +} + +pub(crate) async fn prepare_database_delete( + application: &Application, + request: &LegacyDeleteObjectRequest, +) -> LegacyResult { + let datasource_id = request.data_source_id.as_string(); + let database_type = + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + let confirm_name = required_name(&request.database_name, "databaseName")?; + Ok(LegacyDeletePrepareResponse { + sql_preview: build_mysql_drop_database(&confirm_name, false)?, + confirm_name, + object_type: "DATABASE".to_owned(), + db_type: database_type, + }) +} + +pub(crate) async fn prepare_schema_delete( + application: &Application, + request: &LegacyDeleteObjectRequest, +) -> LegacyResult { + let datasource_id = request.data_source_id.as_string(); + let database_type = + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + let target = first_non_blank(&request.schema_name, &request.database_name); + let confirm_name = required_name(&target, "schemaName")?; + Ok(LegacyDeletePrepareResponse { + sql_preview: build_mysql_drop_schema(&confirm_name, false)?, + confirm_name, + object_type: "SCHEMA".to_owned(), + db_type: database_type, + }) +} + +pub(crate) async fn execute_database_delete( + application: &Application, + request: &LegacyDeleteObjectRequest, +) -> LegacyResult<()> { + let prepared = prepare_database_delete(application, request).await?; + validate_delete_confirmation(&prepared.confirm_name, &request.confirm_name)?; + execute_generated_action( + application, + request.data_source_id.clone(), + &prepared.confirm_name, + "", + "", + prepared.sql_preview, + ) + .await +} + +pub(crate) async fn execute_schema_delete( + application: &Application, + request: &LegacyDeleteObjectRequest, +) -> LegacyResult<()> { + let prepared = prepare_schema_delete(application, request).await?; + validate_delete_confirmation(&prepared.confirm_name, &request.confirm_name)?; + execute_generated_action( + application, + request.data_source_id.clone(), + &prepared.confirm_name, + "", + "", + prepared.sql_preview, + ) + .await +} + +pub(crate) async fn drop_table( + application: &Application, + request: &LegacyTableOperationRequest, +) -> LegacyResult<()> { + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + let sql = build_mysql_drop_table( + &mysql_qualified_name( + &request.database_name, + &request.schema_name, + &request.table_name, + ), + false, + )?; + execute_generated_action( + application, + request.data_source_id.clone(), + &request.database_name, + &request.schema_name, + &request.table_name, + sql, + ) + .await +} + +pub(crate) async fn truncate_table( + application: &Application, + request: &LegacyTableOperationRequest, +) -> LegacyResult<()> { + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + let sql = build_mysql_truncate_table(&mysql_qualified_name( + &request.database_name, + &request.schema_name, + &request.table_name, + ))?; + execute_generated_action( + application, + request.data_source_id.clone(), + &request.database_name, + &request.schema_name, + &request.table_name, + sql, + ) + .await +} + +pub(crate) async fn copy_table( + application: &Application, + request: &LegacyTableCopyRequest, +) -> LegacyResult<()> { + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + let new_name = if request.new_name.trim().is_empty() { + format!("{}_copy", request.table_name.trim()) + } else { + request.new_name.trim().to_owned() + }; + let statements = build_mysql_copy_table(&MysqlTableCopy { + source: mysql_qualified_name( + &request.database_name, + &request.schema_name, + &request.table_name, + ), + target: mysql_qualified_name(&request.database_name, &request.schema_name, &new_name), + if_not_exists: false, + copy_data: request.copy_data, + })?; + for sql in statements { + execute_generated_action( + application, + request.data_source_id.clone(), + &request.database_name, + &request.schema_name, + &new_name, + sql, + ) + .await?; + } + Ok(()) +} + +pub(crate) async fn build_view_modify_sql( + application: &Application, + request: &LegacyViewOperationRequest, +) -> LegacyResult { + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + Ok(build_mysql_create_view(&mysql_view_definition(request)?)?) +} + +pub(crate) async fn drop_view( + application: &Application, + request: &LegacyViewOperationRequest, +) -> LegacyResult<()> { + let datasource_id = request.data_source_id.as_string(); + resolve_mysql_database_type(application, &datasource_id, &request.database_type).await?; + let view_name = first_non_blank(&request.view_name, &request.table_name); + let sql = build_mysql_drop_view( + &mysql_qualified_name(&request.database_name, &request.schema_name, &view_name), + false, + )?; + execute_generated_action( + application, + request.data_source_id.clone(), + &request.database_name, + &request.schema_name, + &view_name, + sql, + ) + .await +} + +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 view = Box::pin(get_editable_view(application, &query)).await?; + Ok(LegacyViewMetaResponse { + configurations: Vec::new(), + preview_sql: view.ddl.clone(), + sql: view.ddl, + }) +} + +/// Lists stored functions in the historical paged metadata shape. +pub(crate) async fn list_functions( + application: &Application, + query: &LegacyTableListQuery, +) -> LegacyResult> { + validate_metadata_page(query)?; + let datasource_id = query.data_source_id.as_string(); + let database_type = + resolve_database_type(application, &datasource_id, &query.database_type).await?; + let items = application + .list_community_functions(ListCommunityFunctionsRequest { + datasource_id, + database_type, + database_name: query.database_name.clone(), + schema_name: query.schema_name.clone(), + }) + .await? + .items + .into_iter() + .map(function_response) + .collect(); + Ok(full_page(items)) +} + +/// Reads one stored function in the historical metadata shape. +pub(crate) async fn get_function( + application: &Application, + query: &LegacyFunctionDetailQuery, +) -> LegacyResult { + let datasource_id = query.data_source_id.as_string(); let database_type = resolve_database_type(application, &datasource_id, &query.database_type).await?; Ok(function_response( @@ -1795,6 +2571,16 @@ pub(crate) async fn preview_table( let datasource_id = request.data_source_id.as_string(); let database_type = resolve_database_type(application, &datasource_id, &request.database_type).await?; + let editable_columns = application + .list_community_columns(ListCommunityColumnsRequest { + datasource_id: datasource_id.clone(), + database_type: database_type.clone(), + database_name: request.database_name.clone(), + schema_name: request.schema_name.clone(), + table_name: request.table_name.clone(), + }) + .await? + .items; let accepted = application .start_community_table_preview(StartCommunityTablePreviewRequest { datasource_id, @@ -1832,9 +2618,10 @@ pub(crate) async fn preview_table( let has_next_page = page.has_more || page.metadata.truncated_by_max_rows || page.metadata.truncated_by_max_result_bytes; - let headers: Vec = page.columns.iter().map(result_header).collect(); + let mut headers: Vec = page.columns.iter().map(result_header).collect(); + enrich_headers_from_columns(&mut headers, &editable_columns); let large_value_owner = application.create_large_value_owner(); - let data_list = page + let mut data_list: Vec> = page .rows .into_iter() .map(|row| { @@ -1845,6 +2632,7 @@ pub(crate) async fn preview_table( .collect() }) .collect(); + prepend_synthetic_row_numbers(&mut headers, &mut data_list, u64::from(offset)); Ok(vec![LegacyManageResult { data_list, header_list: headers, @@ -1855,7 +2643,7 @@ pub(crate) async fn preview_table( success: true, duration: 0, update_count: 0, - can_edit: false, + can_edit: true, table_name: request.table_name.clone(), sql_type: "SELECT".to_owned(), refresh_targets: Vec::new(), @@ -2085,6 +2873,79 @@ pub async fn execute_ddl( }) } +/// Counts the rows produced by one `MySQL` query for Community's total-row control. +pub(crate) async fn count_mysql_rows( + application: &Application, + request: &LegacySqlExecuteRequest, +) -> LegacyResult { + let (datasource_id, _) = validate_sql_execute_request(request)?; + if !uses_native_mysql_console(application, request).await? { + return Err(LegacyFailure::invalid( + "unsupported_database_type", + "The historical count route currently supports native MySQL only", + )); + } + if request.sql.trim().is_empty() { + return Ok(0); + } + let count_sql = build_mysql_count_query(&request.sql)?; + let results = application + .execute_mysql_console( + MysqlConsoleRequest { + datasource_id, + database_name: request.database_name.clone(), + sql: count_sql, + page_no: 1, + page_size: 1, + result_set_id: None, + single: true, + page_size_all: false, + explain: false, + error_continue: false, + }, + MysqlConsoleCancellation::new(), + ) + .await?; + let result = results.into_iter().next().ok_or_else(|| { + LegacyFailure::invalid( + "mysql_count_failed", + "The MySQL count query returned no result", + ) + })?; + if !result.success { + return Err(LegacyFailure { + code: "mysql_count_failed".to_owned(), + message: result.error.map_or(result.message, |error| error.message), + }); + } + let value = result + .rows + .first() + .and_then(|row| row.values.first()) + .ok_or_else(|| { + LegacyFailure::invalid( + "mysql_count_failed", + "The MySQL count query returned no value", + ) + })?; + let (JdbcValue::SignedInteger { value } + | JdbcValue::UnsignedInteger { value } + | JdbcValue::Decimal { value } + | JdbcValue::Text { value }) = value + else { + return Err(LegacyFailure::invalid( + "mysql_count_failed", + "The MySQL count query returned a non-integer value", + )); + }; + value.parse::().map_err(|_| { + LegacyFailure::invalid( + "mysql_count_failed", + "The MySQL count query returned an invalid integer", + ) + }) +} + /// Executes the native `MySQL` Console contract with a caller-owned cancellation /// source. Desktop keeps this source by execution id while HTTP uses it for the /// synchronous timeout boundary. @@ -2100,6 +2961,23 @@ pub async fn execute_mysql_sql( history_source: &str, ) -> LegacyResult> { let (datasource_id, _) = validate_sql_execute_request(request)?; + let editable_columns = if request.table_name.trim().is_empty() { + None + } else { + let database_type = + resolve_database_type(application, &datasource_id, &request.database_type).await?; + application + .list_community_columns(ListCommunityColumnsRequest { + datasource_id: datasource_id.clone(), + database_type, + database_name: request.database_name.clone(), + schema_name: request.schema_name.clone(), + table_name: request.table_name.clone(), + }) + .await + .ok() + .map(|columns| columns.items) + }; let execution = application.execute_mysql_console( MysqlConsoleRequest { @@ -2125,7 +3003,15 @@ pub async fn execute_mysql_sql( let results = match timed_out { Some(Ok(results)) => results .into_iter() - .map(|result| mysql_console_result(application, &large_value_owner, request, result)) + .map(|result| { + mysql_console_result( + application, + &large_value_owner, + request, + result, + editable_columns.as_deref(), + ) + }) .collect(), Some(Err(error)) => { let error = LegacyFailure::from(error); @@ -2503,6 +3389,7 @@ fn mysql_console_result( large_value_owner: &str, request: &LegacySqlExecuteRequest, result: MysqlConsoleResult, + editable_columns: Option<&[CommunityTableColumn]>, ) -> LegacyManageResult { let MysqlConsoleResult { statement_sequence, @@ -2518,8 +3405,11 @@ fn mysql_console_result( duration_ms, error, } = result; - let header_list = columns.iter().map(result_header).collect(); - let data_list = rows + let mut header_list: Vec<_> = columns.iter().map(result_header).collect(); + let can_edit = editable_columns.is_some_and(|editable_columns| { + enrich_direct_table_headers(&mut header_list, editable_columns, &request.table_name) + }); + let mut data_list: Vec> = rows .into_iter() .map(|row| { row.values @@ -2529,6 +3419,11 @@ fn mysql_console_result( .collect() }) .collect(); + if can_edit { + let offset = u64::from(request.page_no.saturating_sub(1)) + .saturating_mul(u64::from(request.page_size)); + prepend_synthetic_row_numbers(&mut header_list, &mut data_list, offset); + } let sql_type = legacy_sql_type(&sql).to_owned(); let extra = error.map_or_else( || serde_json::json!({}), @@ -2559,7 +3454,7 @@ fn mysql_console_result( success, duration: duration_ms, update_count, - can_edit: false, + can_edit, table_name: request.table_name.clone(), refresh_targets: refresh_targets(request, &sql_type, success), sql_type, @@ -3009,6 +3904,8 @@ fn column_response(column: CommunityTableColumn) -> LegacyColumn { auto_increment: column.auto_increment, comment: column.comment, primary_key: column.primary_key, + primary_key_name: column.primary_key_name, + primary_key_order: column.primary_key_order, schema_name: column.schema_name, database_name: column.database_name, type_name: None, @@ -3024,6 +3921,15 @@ fn column_response(column: CommunityTableColumn) -> LegacyColumn { nullable: column.nullable, generated_column: column.generated_column, extent: column.extent, + char_set_name: column.charset, + collation_name: column.collation, + value: String::new(), + unit: column.unit, + sparse: column.sparse, + default_constraint_name: column.default_constraint_name, + seed: column.seed, + increment: column.increment, + on_update_current_timestamp: column.on_update_current_timestamp, edit_status: None, } } @@ -3031,15 +3937,46 @@ fn column_response(column: CommunityTableColumn) -> LegacyColumn { fn index_response(index: CommunityTableIndex) -> LegacyIndex { LegacyIndex { columns: None, + old_name: Some(index.name.clone()), name: index.name, + table_name: index.table_name, index_type: index.index_type, + unique: index.unique, comment: index.comment, + schema_name: index.schema_name, + database_name: index.database_name, column_list: index .columns .into_iter() .map(index_column_response) .collect(), + edit_status: None, + concurrently: index.concurrently, + method: index.method, + foreign_schema_name: index.foreign_schema_name, + foreign_table_name: index.foreign_table_name, + foreign_column_namelist: index.foreign_column_names, + } +} + +fn editable_index_response(index: CommunityTableIndex) -> LegacyIndex { + let method = index.index_type.clone(); + let index_type = if index.name.eq_ignore_ascii_case("PRIMARY") { + "Primary" + } else if method.eq_ignore_ascii_case("FULLTEXT") { + "Fulltext" + } else if method.eq_ignore_ascii_case("SPATIAL") { + "Spatial" + } else if index.unique == Some(true) { + "Unique" + } else { + "Normal" } + .to_owned(); + let mut response = index_response(index); + response.index_type = index_type; + response.method = method; + response } fn index_column_response(column: CommunityTableIndexColumn) -> LegacyIndexColumn { @@ -3059,24 +3996,57 @@ fn index_column_response(column: CommunityTableIndexColumn) -> LegacyIndexColumn cardinality: decimal_i64(column.cardinality.as_deref()), pages: decimal_i64(column.pages.as_deref()), filter_condition: column.filter_condition, + sub_part: decimal_i64(column.sub_part.as_deref()), + edit_status: None, } } -fn function_response(function: CommunityFunction) -> LegacyFunction { - LegacyFunction { - database_name: function.database_name, - schema_name: function.schema_name, - function_name: function.name, - remarks: function.remarks, - function_type: function.function_type, - specific_name: function.specific_name, - function_body: function.body, - function_template: function.template, - } -} - -fn procedure_response(procedure: CommunityProcedure) -> LegacyProcedure { - LegacyProcedure { +fn editable_table_response( + table: CommunityTable, + columns: Vec, + indexes: Vec, + database_type: String, +) -> LegacyEditableTable { + LegacyEditableTable { + name: table.name, + comment: table.comment, + schema_name: table.schema_name, + database_name: table.database_name, + table_type: table.table_type, + column_list: columns.into_iter().map(column_response).collect(), + index_list: indexes.into_iter().map(editable_index_response).collect(), + foreign_key_list: Vec::new(), + db_type: database_type, + pinned: table.pinned, + ddl: table.ddl, + engine: table.engine, + charset: table.charset, + collate: table.collation, + increment_value: table.increment_value, + partition: table.partition, + tablespace: table.tablespace, + rows: table.rows, + data_length: table.data_length, + create_time: table.create_time, + update_time: table.update_time, + } +} + +fn function_response(function: CommunityFunction) -> LegacyFunction { + LegacyFunction { + database_name: function.database_name, + schema_name: function.schema_name, + function_name: function.name, + remarks: function.remarks, + function_type: function.function_type, + specific_name: function.specific_name, + function_body: function.body, + function_template: function.template, + } +} + +fn procedure_response(procedure: CommunityProcedure) -> LegacyProcedure { + LegacyProcedure { database_name: procedure.database_name, schema_name: procedure.schema_name, procedure_name: procedure.name, @@ -3121,6 +4091,128 @@ fn result_header(column: &ResultColumn) -> LegacyResultHeader { } } +fn enrich_direct_table_headers( + headers: &mut [LegacyResultHeader], + columns: &[CommunityTableColumn], + expected_table_name: &str, +) -> bool { + if headers.is_empty() || expected_table_name.trim().is_empty() { + return false; + } + let mut direct_table = None::<&str>; + for header in headers.iter() { + let Some(table_name) = header.table_name.as_deref().filter(|name| !name.is_empty()) else { + return false; + }; + if !table_name.eq_ignore_ascii_case(expected_table_name) { + return false; + } + if direct_table.is_some_and(|current| !current.eq_ignore_ascii_case(table_name)) { + return false; + } + direct_table = Some(table_name); + } + + enrich_headers_from_columns(headers, columns) +} + +fn enrich_headers_from_columns( + headers: &mut [LegacyResultHeader], + columns: &[CommunityTableColumn], +) -> bool { + for header in headers.iter_mut() { + let metadata = columns.iter().find(|column| { + column.name.eq_ignore_ascii_case(&header.column_name) + || column.name.eq_ignore_ascii_case(&header.name) + }); + let Some(metadata) = metadata else { + return false; + }; + header.primary_key = metadata.primary_key.unwrap_or(false); + if !metadata.column_type.trim().is_empty() { + header.column_type.clone_from(&metadata.column_type); + } + header.table_name = non_blank(&metadata.table_name); + header.database_name = non_blank(&metadata.database_name); + header.schema_name = non_blank(&metadata.schema_name); + header.comment = Some(metadata.comment.clone()); + header.default_value.clone_from(&metadata.default_value); + header.auto_increment = metadata.auto_increment.map(i32::from); + header.nullable = metadata.nullable.unwrap_or(1) != 0; + header.column_size = metadata.column_size.and_then(|size| size.try_into().ok()); + header.decimal_digits = metadata.decimal_digits; + header.editor_type = Some(result_editor_type(&metadata.column_type).to_owned()); + } + true +} + +fn result_editor_type(column_type: &str) -> &'static str { + let base_type = column_type + .split(|character: char| character == '(' || character.is_ascii_whitespace()) + .next() + .unwrap_or_default(); + if base_type.eq_ignore_ascii_case("DATE") { + "DATE" + } else if base_type.eq_ignore_ascii_case("TIME") { + "TIME" + } else if base_type.eq_ignore_ascii_case("DATETIME") { + "DATETIME" + } else if base_type.eq_ignore_ascii_case("TIMESTAMP") { + "TIMESTAMP" + } else { + "TEXT" + } +} + +fn prepend_synthetic_row_numbers( + headers: &mut Vec, + rows: &mut [Vec], + offset: u64, +) { + headers.insert( + 0, + LegacyResultHeader { + data_type: "CHAT2DB_ROW_NUMBER".to_owned(), + name: "CHAT2DB_ROW_NUMBER".to_owned(), + column_name: "CHAT2DB_ROW_NUMBER".to_owned(), + column_type: "BIGINT".to_owned(), + table_name: None, + database_name: None, + schema_name: None, + primary_key: false, + comment: None, + default_value: None, + auto_increment: None, + nullable: false, + column_size: Some(20), + decimal_digits: Some(0), + editor_type: None, + }, + ); + for (index, row) in rows.iter_mut().enumerate() { + let row_number = offset + .saturating_add(u64::try_from(index).unwrap_or(u64::MAX)) + .saturating_add(1); + row.insert( + 0, + LegacyResultCell { + value: Some(row_number.to_string()), + large_value: false, + large_value_id: None, + value_type: "UNKNOWN".to_owned(), + sql_type: -5, + column_type: "BIGINT".to_owned(), + size_bytes: None, + size_chars: None, + loaded_bytes: None, + loaded_chars: None, + truncated: false, + unsupported_reason: None, + }, + ); + } +} + fn result_cell( application: &Application, large_value_owner: &str, @@ -3448,6 +4540,22 @@ async fn resolve_database_type( )) } +async fn resolve_mysql_database_type( + application: &Application, + datasource_id: &str, + explicit: &str, +) -> LegacyResult { + let database_type = resolve_database_type(application, datasource_id, explicit).await?; + if database_type == "MYSQL" { + Ok(database_type) + } else { + Err(LegacyFailure { + code: "unsupported_database_type".to_owned(), + message: "This Community compatibility route currently supports MySQL only".to_owned(), + }) + } +} + fn database_type_for_driver(driver: &JdbcDriver) -> String { let identity = format!("{} {} {}", driver.pack_id, driver.name, driver.driver_class).to_ascii_lowercase(); @@ -3475,6 +4583,559 @@ fn normalize_database_type(value: &str) -> String { } } +fn mysql_grid_header(header: &LegacyGridHeaderRequest) -> LegacyResult { + let name = first_non_blank(&header.column_name, &header.name); + Ok(MysqlResultGridHeader { + name: required_name(&name, "headerList.name")?, + column_type: header.column_type.clone(), + data_type: header.data_type.clone(), + primary_key: header.primary_key, + auto_increment: header.auto_increment, + }) +} + +fn mysql_grid_operation( + operation: &LegacyGridOperationRequest, +) -> LegacyResult { + let operation_type = match operation + .operation_type + .trim() + .to_ascii_uppercase() + .as_str() + { + "CREATE" => MysqlResultGridOperationType::Create, + "UPDATE" => MysqlResultGridOperationType::Update, + "DELETE" => MysqlResultGridOperationType::Delete, + _ => { + return Err(LegacyFailure::invalid( + "invalid_mysql_result_grid", + "operation type must be CREATE, UPDATE, or DELETE", + )); + } + }; + Ok(MysqlResultGridOperation { + operation_type, + data_list: operation.data_list.clone(), + old_data_list: operation.old_data_list.clone(), + }) +} + +fn mysql_grid_copy_operation( + operation: &LegacyGridOperationRequest, +) -> LegacyResult { + let operation_type = match operation + .operation_type + .trim() + .to_ascii_uppercase() + .as_str() + { + "CREATE" => MysqlResultGridCopyOperationType::Create, + "UPDATE_COPY" => MysqlResultGridCopyOperationType::UpdateCopy, + "WHERE" => MysqlResultGridCopyOperationType::Where, + _ => { + return Err(LegacyFailure::invalid( + "invalid_mysql_result_grid", + "copy operation type must be CREATE, UPDATE_COPY, or WHERE", + )); + } + }; + Ok(MysqlResultGridCopyOperation { + operation_type, + data_list: operation.data_list.clone(), + select_cols: operation.select_cols.clone(), + }) +} + +fn reject_unsupported_copy_cells(operations: &[LegacyGridOperationRequest]) -> LegacyResult<()> { + if operations.iter().any(|operation| { + operation.selected_cell.as_ref().is_some_and(|cell| { + cell.large_value + || cell.truncated + || cell.large_value_id.is_some() + || matches!(cell.value_type.as_str(), "BINARY" | "BLOB" | "BYTES") + }) + }) { + return Err(LegacyFailure::invalid( + "mysql_partial_large_value_rejected", + "Binary and partial large values cannot be copied as SQL IN values", + )); + } + Ok(()) +} + +fn reject_legacy_partial_large_values(operations: &[MysqlResultGridOperation]) -> LegacyResult<()> { + let has_partial = operations.iter().any(|operation| { + operation + .data_list + .iter() + .chain(&operation.old_data_list) + .flatten() + .any(|value| { + value + .to_ascii_uppercase() + .starts_with(LARGE_VALUE_PREVIEW_PREFIX) + }) + }); + if has_partial { + Err(LegacyFailure::invalid( + "mysql_partial_large_value_rejected", + "Partial large-value previews cannot be written back", + )) + } else { + Ok(()) + } +} + +fn mysql_qualified_name(database_name: &str, schema_name: &str, name: &str) -> MysqlQualifiedName { + MysqlQualifiedName { + database_name: non_blank(database_name), + schema_name: non_blank(schema_name), + name: name.trim().to_owned(), + } +} + +fn mysql_table_definition( + table: &LegacyEditableTable, + database_name: &str, + schema_name: &str, +) -> LegacyResult { + let columns = table + .column_list + .iter() + .filter(|column| !has_edit_status(column.edit_status.as_deref(), "DELETE")) + .map(mysql_column_definition) + .collect::>>()?; + let mut indexes = table + .index_list + .iter() + .filter(|index| !has_edit_status(index.edit_status.as_deref(), "DELETE")) + .map(mysql_index_definition) + .collect::>>()?; + if !indexes + .iter() + .any(|index| index.kind == MysqlIndexKind::Primary) + { + let primary_columns = table + .column_list + .iter() + .filter(|column| column.primary_key == Some(true)) + .map(|column| { + Ok(MysqlIndexColumn { + name: required_name(&column.name, "columnList.name")?, + prefix_length: None, + order: None, + }) + }) + .collect::>>()?; + if !primary_columns.is_empty() { + indexes.push(MysqlIndexDefinition { + kind: MysqlIndexKind::Primary, + name: None, + columns: primary_columns, + method: Some(MysqlIndexMethod::Btree), + comment: None, + }); + } + } + let table_database = first_non_blank(database_name, &table.database_name); + let table_schema = first_non_blank(schema_name, &table.schema_name); + Ok(MysqlTableDefinition { + name: mysql_qualified_name(&table_database, &table_schema, &table.name), + if_not_exists: false, + columns, + indexes, + engine: non_blank(&table.engine), + charset: non_blank(&table.charset), + collation: non_blank(&table.collate), + comment: Some(table.comment.clone()), + auto_increment: parse_auto_increment(table.increment_value.as_deref())?, + }) +} + +fn mysql_table_alter( + old_table: &LegacyEditableTable, + new_table: &LegacyEditableTable, + database_name: &str, + schema_name: &str, +) -> LegacyResult { + let table_database = first_non_blank(database_name, &old_table.database_name); + let table_schema = first_non_blank(schema_name, &old_table.schema_name); + let table = mysql_qualified_name(&table_database, &table_schema, &old_table.name); + let rename_to = (!new_table.name.eq_ignore_ascii_case(&old_table.name)).then(|| { + mysql_qualified_name( + &first_non_blank(database_name, &new_table.database_name), + &first_non_blank(schema_name, &new_table.schema_name), + &new_table.name, + ) + }); + let mut columns = Vec::new(); + let active_columns = new_table + .column_list + .iter() + .filter(|column| !has_edit_status(column.edit_status.as_deref(), "DELETE")) + .collect::>(); + for column in &new_table.column_list { + let status = column + .edit_status + .as_deref() + .unwrap_or_default() + .trim() + .to_ascii_uppercase(); + match status.as_str() { + "ADD" => columns.push(MysqlColumnAlter::Add { + column: mysql_column_definition(column)?, + position: mysql_column_position(&active_columns, column), + }), + "MODIFY" => 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), + }), + "DELETE" => columns.push(MysqlColumnAlter::Delete { + name: required_name( + column.old_name.as_deref().unwrap_or(&column.name), + "columnList.oldName", + )?, + }), + _ => {} + } + } + + let mut indexes = Vec::new(); + for index in &new_table.index_list { + let status = index + .edit_status + .as_deref() + .unwrap_or_default() + .trim() + .to_ascii_uppercase(); + let old_index = old_table.index_list.iter().find(|candidate| { + candidate + .name + .eq_ignore_ascii_case(index.old_name.as_deref().unwrap_or(index.name.as_str())) + }); + match status.as_str() { + "ADD" => indexes.push(MysqlIndexAlter::Add { + index: mysql_index_definition(index)?, + }), + "MODIFY" => indexes.push(MysqlIndexAlter::Modify { + old_kind: old_index.map_or_else(|| mysql_index_kind(index), mysql_index_kind), + old_name: mysql_index_name(old_index.map_or(index, |old_index| old_index))?, + index: mysql_index_definition(index)?, + }), + "DELETE" => { + let old_index = old_index.unwrap_or(index); + indexes.push(MysqlIndexAlter::Delete { + kind: mysql_index_kind(old_index), + name: mysql_index_name(old_index)?, + }); + } + _ => {} + } + } + Ok(MysqlTableAlter { + table, + rename_to, + columns, + indexes, + engine: changed_non_blank(&old_table.engine, &new_table.engine), + charset: changed_non_blank(&old_table.charset, &new_table.charset), + collation: changed_non_blank(&old_table.collate, &new_table.collate), + comment: (old_table.comment != new_table.comment).then(|| new_table.comment.clone()), + auto_increment: if old_table.increment_value == new_table.increment_value { + None + } else { + parse_auto_increment(new_table.increment_value.as_deref())? + }, + }) +} + +fn mysql_column_definition(column: &LegacyColumn) -> LegacyResult { + let type_name = required_name(&column.column_type, "columnList.columnType")?; + Ok(MysqlColumnDefinition { + name: required_name(&column.name, "columnList.name")?, + unsigned: type_name.to_ascii_uppercase().contains("UNSIGNED"), + type_name, + length: positive_u32(column.column_size), + scale: positive_or_zero_u32(column.decimal_digits), + nullable: column.nullable.unwrap_or(1) != 0, + default_value: column.default_value.clone(), + auto_increment: column.auto_increment.unwrap_or(false), + charset: non_blank(&column.char_set_name), + collation: non_blank(&column.collation_name), + comment: non_blank(&column.comment), + enum_values: parse_enum_values(&column.value), + on_update_current_timestamp: column.on_update_current_timestamp.unwrap_or(false), + }) +} + +fn mysql_index_definition(index: &LegacyIndex) -> LegacyResult { + let kind = mysql_index_kind(index); + let columns = index + .column_list + .iter() + .filter(|column| !has_edit_status(column.edit_status.as_deref(), "DELETE")) + .map(|column| { + Ok(MysqlIndexColumn { + name: required_name(&column.column_name, "indexList.columnList.columnName")?, + prefix_length: column.sub_part.and_then(|value| value.try_into().ok()), + order: match column.asc_or_desc.trim().to_ascii_uppercase().as_str() { + "A" | "ASC" => Some(MysqlSortOrder::Asc), + "D" | "DESC" => Some(MysqlSortOrder::Desc), + _ => None, + }, + }) + }) + .collect::>>()?; + Ok(MysqlIndexDefinition { + kind, + name: mysql_index_name(index)?, + columns, + method: match index.method.trim().to_ascii_uppercase().as_str() { + "BTREE" => Some(MysqlIndexMethod::Btree), + "HASH" => Some(MysqlIndexMethod::Hash), + _ => None, + }, + comment: non_blank(&index.comment), + }) +} + +fn mysql_index_kind(index: &LegacyIndex) -> MysqlIndexKind { + if index.name.eq_ignore_ascii_case("PRIMARY") + || index.index_type.eq_ignore_ascii_case("PRIMARY") + { + MysqlIndexKind::Primary + } else if index.index_type.eq_ignore_ascii_case("UNIQUE") || index.unique == Some(true) { + MysqlIndexKind::Unique + } else if index.index_type.eq_ignore_ascii_case("FULLTEXT") { + MysqlIndexKind::Fulltext + } else if index.index_type.eq_ignore_ascii_case("SPATIAL") { + MysqlIndexKind::Spatial + } else { + MysqlIndexKind::Normal + } +} + +fn mysql_index_name(index: &LegacyIndex) -> LegacyResult> { + if mysql_index_kind(index) == MysqlIndexKind::Primary { + Ok(None) + } else { + Ok(Some(required_name(&index.name, "indexList.name")?)) + } +} + +fn mysql_column_position( + active_columns: &[&LegacyColumn], + column: &LegacyColumn, +) -> Option { + let index = active_columns.iter().position(|candidate| { + std::ptr::eq(*candidate, column) || candidate.name.eq_ignore_ascii_case(&column.name) + })?; + if index == 0 { + Some(MysqlColumnPosition::First) + } else { + Some(MysqlColumnPosition::After( + active_columns[index - 1].name.clone(), + )) + } +} + +fn mysql_view_definition( + request: &LegacyViewOperationRequest, +) -> LegacyResult { + if request.use_if_not_exists { + return Err(LegacyFailure::invalid( + "invalid_mysql_ddl", + "MySQL does not support CREATE VIEW IF NOT EXISTS", + )); + } + let view_name = first_non_blank(&request.view_name, &request.table_name); + Ok(MysqlViewDefinition { + name: mysql_qualified_name(&request.database_name, &request.schema_name, &view_name), + columns: request.view_attributes.clone(), + use_or_replace: request.use_or_replace || request.is_modify.unwrap_or(false), + algorithm: match request.algorithm.trim().to_ascii_uppercase().as_str() { + "" => None, + "UNDEFINED" => Some(MysqlViewAlgorithm::Undefined), + "MERGE" => Some(MysqlViewAlgorithm::Merge), + "TEMPTABLE" => Some(MysqlViewAlgorithm::Temptable), + _ => { + return Err(LegacyFailure::invalid( + "invalid_mysql_ddl", + "algorithm must be UNDEFINED, MERGE, or TEMPTABLE", + )); + } + }, + definer: parse_view_definer(&request.definer)?, + sql_security: match request.security.trim().to_ascii_uppercase().as_str() { + "" => None, + "DEFINER" => Some(MysqlViewSecurity::Definer), + "INVOKER" => Some(MysqlViewSecurity::Invoker), + _ => { + return Err(LegacyFailure::invalid( + "invalid_mysql_ddl", + "security must be DEFINER or INVOKER", + )); + } + }, + check_option: match request.check_option.trim().to_ascii_uppercase().as_str() { + "" | "NONE" => None, + "CASCADED" => Some(MysqlViewCheckOption::Cascaded), + "LOCAL" => Some(MysqlViewCheckOption::Local), + _ => { + return Err(LegacyFailure::invalid( + "invalid_mysql_ddl", + "checkOption must be CASCADED or LOCAL", + )); + } + }, + body: required_name(&request.view_body, "viewBody")?, + }) +} + +fn parse_view_definer(value: &str) -> LegacyResult> { + let value = value.trim(); + if value.is_empty() { + return Ok(None); + } + let Some((user, host)) = value.split_once('@') else { + return Err(LegacyFailure::invalid( + "invalid_mysql_ddl", + "definer must use the user@host form", + )); + }; + let trim_part = |part: &str| part.trim().trim_matches(['\'', '"', '`']).to_owned(); + Ok(Some(MysqlViewDefiner { + user: required_name(&trim_part(user), "definer user")?, + host: required_name(&trim_part(host), "definer host")?, + })) +} + +async fn execute_generated_action( + application: &Application, + data_source_id: LegacyIdentifier, + database_name: &str, + schema_name: &str, + table_name: &str, + sql: String, +) -> LegacyResult<()> { + let result = execute_ddl( + application, + &LegacySqlExecuteRequest { + data_source_id, + data_source_name: String::new(), + database_name: database_name.to_owned(), + schema_name: schema_name.to_owned(), + database_type: "MYSQL".to_owned(), + table_name: table_name.to_owned(), + sql, + single: true, + page_no: DEFAULT_PAGE_NO, + page_size: DEFAULT_SQL_PAGE_SIZE, + page_size_all: false, + console_id: None, + apply_id: None, + result_set_id: None, + error_continue: Some(false), + explain: false, + }, + ) + .await?; + if result.success { + Ok(()) + } else { + Err(LegacyFailure { + code: "mysql_ddl_execution_failed".to_owned(), + message: if result.message.trim().is_empty() { + "The MySQL DDL statement failed".to_owned() + } else { + result.message + }, + }) + } +} + +fn validate_delete_confirmation(expected: &str, actual: &str) -> LegacyResult<()> { + if expected == actual.trim() { + Ok(()) + } else { + Err(LegacyFailure::invalid( + "database_object_delete_confirmation_mismatch", + "confirmName must exactly match the database object name", + )) + } +} + +fn required_name(value: &str, field: &'static str) -> LegacyResult { + let value = value.trim(); + if value.is_empty() { + Err(LegacyFailure { + code: "invalid_legacy_request".to_owned(), + message: format!("{field} is required"), + }) + } else { + Ok(value.to_owned()) + } +} + +fn first_non_blank(primary: &str, fallback: &str) -> String { + if primary.trim().is_empty() { + fallback.trim().to_owned() + } else { + primary.trim().to_owned() + } +} + +fn has_edit_status(status: Option<&str>, expected: &str) -> bool { + status.is_some_and(|status| status.trim().eq_ignore_ascii_case(expected)) +} + +fn changed_non_blank(old: &str, new: &str) -> Option { + (old != new) + .then(|| new.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +fn positive_u32(value: Option) -> Option { + value + .filter(|value| *value > 0) + .and_then(|value| value.try_into().ok()) +} + +fn positive_or_zero_u32(value: Option) -> Option { + value + .filter(|value| *value >= 0) + .and_then(|value| value.try_into().ok()) +} + +fn parse_auto_increment(value: Option<&str>) -> LegacyResult> { + value + .filter(|value| !value.trim().is_empty()) + .map(|value| { + value.trim().parse::().map_err(|_| { + LegacyFailure::invalid( + "invalid_mysql_ddl", + "incrementValue must be a positive integer", + ) + }) + }) + .transpose() +} + +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() +} + fn paginate(items: Vec, page_no: u32, page_size: u32) -> LegacyPage { let page_no = page_no.max(1); let page_size = page_size.max(1); @@ -3530,8 +5191,15 @@ fn full_page(items: Vec) -> LegacyPage { /// /// Tauri IPC can pass its `requestUrl`, `method`, and `message` fields here and /// return the resulting JSON value unchanged. +pub fn dispatch( + application: &Application, + request: LegacyDispatchRequest, +) -> impl Future + Send + '_ { + Box::pin(dispatch_inner(application, request)) +} + #[allow(clippy::too_many_lines)] -pub async fn dispatch( +async fn dispatch_inner( application: &Application, request: LegacyDispatchRequest, ) -> serde_json::Value { @@ -3644,6 +5312,18 @@ pub async fn dispatch( Ok(query) => serialized(list_schemas(application, &query).await), Err(error) => Err(error), }, + ("get", "/api/rdb/table/table_meta") => { + match decode::(request.message) { + Ok(query) => serialized(table_editor_meta(application, &query).await), + Err(error) => Err(error), + } + } + ("get", "/api/rdb/table/query") => { + match decode::(request.message) { + Ok(query) => serialized(Box::pin(get_editable_table(application, &query)).await), + 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), @@ -3683,6 +5363,16 @@ pub async fn dispatch( Err(error) => Err(error), } } + ("get", "/api/rdb/view/query") => match decode::(request.message) { + Ok(query) => serialized(Box::pin(get_editable_view(application, &query)).await), + Err(error) => Err(error), + }, + ("get", "/api/rdb/view/view_meta") => { + match decode::(request.message) { + Ok(query) => serialized(Box::pin(view_editor_meta(application, &query)).await), + Err(error) => Err(error), + } + } ("get", "/api/rdb/function/list") => { match decode::(request.message) { Ok(query) => serialized(list_functions(application, &query).await), @@ -3729,12 +5419,108 @@ pub async fn dispatch( Err(error) => Err(error), } } - ("post" | "put", "/api/rdb/dml/execute_ddl") => { + ("post" | "put", "/api/rdb/dml/execute_ddl" | "/api/rdb/dml/execute_update") => { match decode::(request.message) { Ok(body) => serialized(execute_ddl(application, &body).await), Err(error) => Err(error), } } + ("post" | "put", "/api/rdb/dml/get_update_sql") => { + match decode::(request.message) { + Ok(body) => serialized(build_grid_update_sql(application, &body).await), + Err(error) => Err(error), + } + } + ("post" | "put", "/api/rdb/dml/copy_update_sql") => { + match decode::(request.message) { + Ok(body) => serialized(build_grid_copy_sql(application, &body).await), + Err(error) => Err(error), + } + } + ("post" | "put", "/api/rdb/dml/copy_in_values_sql") => { + match decode::(request.message) { + Ok(body) => serialized(build_grid_in_values(application, &body).await), + Err(error) => Err(error), + } + } + ("post" | "put", "/api/rdb/dml/count") => { + match decode::(request.message) { + Ok(body) => serialized(count_mysql_rows(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/table/modify/sql") => { + match decode::(request.message) { + Ok(body) => serialized(build_table_modify_sql(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/ddl/delete") => { + match decode::(request.message) { + Ok(body) => serialized(drop_table(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/table/truncate") => { + match decode::(request.message) { + Ok(body) => serialized(truncate_table(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/table/copy") => { + match decode::(request.message) { + Ok(body) => serialized(copy_table(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/database/create_database_sql") => { + match decode::(request.message) { + Ok(body) => serialized(build_create_database_sql(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/schema/create_schema_sql") => { + match decode::(request.message) { + Ok(body) => serialized(build_create_schema_sql(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/delete/database/prepare") => { + match decode::(request.message) { + Ok(body) => serialized(prepare_database_delete(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/delete/database/execute") => { + match decode::(request.message) { + Ok(body) => serialized(execute_database_delete(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/delete/schema/prepare") => { + match decode::(request.message) { + Ok(body) => serialized(prepare_schema_delete(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/delete/schema/execute") => { + match decode::(request.message) { + Ok(body) => serialized(execute_schema_delete(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/view/modify/sql") => { + match decode::(request.message) { + Ok(body) => serialized(build_view_modify_sql(application, &body).await), + Err(error) => Err(error), + } + } + ("post", "/api/rdb/view/drop") => { + match decode::(request.message) { + Ok(body) => serialized(drop_view(application, &body).await), + Err(error) => Err(error), + } + } ("post", "/api/rdb/cell/value") => { match decode::(request.message) { Ok(body) => serialized(read_large_cell_value(application, &body)), @@ -3781,8 +5567,15 @@ const LEGACY_PATHS: &[&str] = &[ "/api/operation/log", "/api/namespaces/tree_list", "/api/rdb/database/list", + "/api/rdb/database/create_database_sql", "/api/rdb/schema/list", + "/api/rdb/schema/create_schema_sql", "/api/rdb/table/list", + "/api/rdb/table/table_meta", + "/api/rdb/table/query", + "/api/rdb/table/modify/sql", + "/api/rdb/table/truncate", + "/api/rdb/table/copy", "/api/rdb/table/table_list", "/api/rdb/table/column_list", "/api/rdb/table/index_list", @@ -3790,9 +5583,18 @@ const LEGACY_PATHS: &[&str] = &[ "/api/rdb/ddl/column_list", "/api/rdb/ddl/index_list", "/api/rdb/ddl/key_list", + "/api/rdb/ddl/delete", + "/api/rdb/delete/database/prepare", + "/api/rdb/delete/database/execute", + "/api/rdb/delete/schema/prepare", + "/api/rdb/delete/schema/execute", "/api/rdb/view/list", "/api/rdb/view/column_list", "/api/rdb/view/detail", + "/api/rdb/view/query", + "/api/rdb/view/view_meta", + "/api/rdb/view/modify/sql", + "/api/rdb/view/drop", "/api/rdb/function/list", "/api/rdb/function/detail", "/api/rdb/procedure/list", @@ -3801,6 +5603,11 @@ const LEGACY_PATHS: &[&str] = &[ "/api/rdb/trigger/detail", "/api/rdb/dml/execute", "/api/rdb/dml/execute_ddl", + "/api/rdb/dml/execute_update", + "/api/rdb/dml/get_update_sql", + "/api/rdb/dml/copy_update_sql", + "/api/rdb/dml/copy_in_values_sql", + "/api/rdb/dml/count", "/api/rdb/dml/execute_table", "/api/rdb/cell/value", "/api/rdb/cell/download", @@ -3869,6 +5676,7 @@ fn counted_envelope_value(result: LegacyResult) -> serde_json }) } +#[allow(clippy::too_many_lines)] pub(crate) fn routes() -> Router { Router::new() .route("/api/system", get(system_handler)) @@ -3918,8 +5726,21 @@ pub(crate) fn routes() -> Router { .route("/api/operation/log", get(get_operation_log_handler)) .route("/api/namespaces/tree_list", get(namespace_tree_handler)) .route("/api/rdb/database/list", get(database_list_handler)) + .route( + "/api/rdb/database/create_database_sql", + post(create_database_sql_handler), + ) .route("/api/rdb/schema/list", get(schema_list_handler)) + .route( + "/api/rdb/schema/create_schema_sql", + post(create_schema_sql_handler), + ) .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/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)) .route("/api/rdb/table/table_list", get(simple_table_list_handler)) .route("/api/rdb/table/column_list", get(table_column_list_handler)) .route("/api/rdb/table/index_list", get(table_index_list_handler)) @@ -3927,9 +5748,30 @@ 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/delete", post(table_drop_handler)) + .route( + "/api/rdb/delete/database/prepare", + post(database_delete_prepare_handler), + ) + .route( + "/api/rdb/delete/database/execute", + post(database_delete_execute_handler), + ) + .route( + "/api/rdb/delete/schema/prepare", + post(schema_delete_prepare_handler), + ) + .route( + "/api/rdb/delete/schema/execute", + post(schema_delete_execute_handler), + ) .route("/api/rdb/view/list", get(view_list_handler)) .route("/api/rdb/view/column_list", get(view_column_list_handler)) .route("/api/rdb/view/detail", get(view_detail_handler)) + .route("/api/rdb/view/query", get(view_query_handler)) + .route("/api/rdb/view/view_meta", get(view_meta_handler)) + .route("/api/rdb/view/modify/sql", post(view_modify_sql_handler)) + .route("/api/rdb/view/drop", post(view_drop_handler)) .route("/api/rdb/function/list", get(function_list_handler)) .route("/api/rdb/function/detail", get(function_detail_handler)) .route("/api/rdb/procedure/list", get(procedure_list_handler)) @@ -3944,6 +5786,26 @@ pub(crate) fn routes() -> Router { "/api/rdb/dml/execute_ddl", post(sql_execute_ddl_handler).put(sql_execute_ddl_handler), ) + .route( + "/api/rdb/dml/execute_update", + post(sql_execute_ddl_handler).put(sql_execute_ddl_handler), + ) + .route( + "/api/rdb/dml/get_update_sql", + post(grid_update_sql_handler).put(grid_update_sql_handler), + ) + .route( + "/api/rdb/dml/copy_update_sql", + post(grid_copy_sql_handler).put(grid_copy_sql_handler), + ) + .route( + "/api/rdb/dml/copy_in_values_sql", + post(grid_in_values_handler).put(grid_in_values_handler), + ) + .route( + "/api/rdb/dml/count", + post(sql_count_handler).put(sql_count_handler), + ) .route( "/api/rdb/dml/execute_table", post(table_preview_handler).put(table_preview_handler), @@ -3965,7 +5827,10 @@ fn envelope(result: LegacyResult) -> Json> { } async fn legacy_bad_request_envelope(response: Response) -> Response { - if response.status() != StatusCode::BAD_REQUEST { + if !matches!( + response.status(), + StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY + ) { return response; } ( @@ -4122,6 +5987,13 @@ async fn database_list_handler( envelope(list_databases(&application, &query).await) } +async fn create_database_sql_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(build_create_database_sql(&application, &request).await) +} + async fn schema_list_handler( State(application): State, Query(query): Query, @@ -4129,6 +6001,83 @@ async fn schema_list_handler( envelope(list_schemas(&application, &query).await) } +async fn create_schema_sql_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(build_create_schema_sql(&application, &request).await) +} + +async fn table_meta_handler( + State(application): State, + Query(query): Query, +) -> Json> { + envelope(table_editor_meta(&application, &query).await) +} + +async fn table_query_handler( + State(application): State, + Query(query): Query, +) -> Json> { + envelope(Box::pin(get_editable_table(&application, &query)).await) +} + +async fn table_modify_sql_handler( + State(application): State, + Json(request): Json, +) -> Json>> { + envelope(build_table_modify_sql(&application, &request).await) +} + +async fn table_drop_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(drop_table(&application, &request).await) +} + +async fn table_truncate_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(truncate_table(&application, &request).await) +} + +async fn table_copy_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(copy_table(&application, &request).await) +} + +async fn database_delete_prepare_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(prepare_database_delete(&application, &request).await) +} + +async fn database_delete_execute_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(execute_database_delete(&application, &request).await) +} + +async fn schema_delete_prepare_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(prepare_schema_delete(&application, &request).await) +} + +async fn schema_delete_execute_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(execute_schema_delete(&application, &request).await) +} + async fn table_list_handler( State(application): State, Query(query): Query, @@ -4206,6 +6155,34 @@ async fn view_detail_handler( envelope(get_view(&application, &query).await) } +async fn view_query_handler( + State(application): State, + Query(query): Query, +) -> Json> { + envelope(Box::pin(get_editable_view(&application, &query)).await) +} + +async fn view_meta_handler( + State(application): State, + Query(request): Query, +) -> Json> { + envelope(Box::pin(view_editor_meta(&application, &request)).await) +} + +async fn view_modify_sql_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(build_view_modify_sql(&application, &request).await) +} + +async fn view_drop_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(drop_view(&application, &request).await) +} + async fn function_list_handler( State(application): State, Query(query): Query, @@ -4269,6 +6246,34 @@ async fn sql_execute_ddl_handler( envelope(execute_ddl(&application, &request).await) } +async fn grid_update_sql_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(build_grid_update_sql(&application, &request).await) +} + +async fn grid_copy_sql_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(build_grid_copy_sql(&application, &request).await) +} + +async fn grid_in_values_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(build_grid_in_values(&application, &request).await) +} + +async fn sql_count_handler( + State(application): State, + Json(request): Json, +) -> Json> { + envelope(count_mysql_rows(&application, &request).await) +} + async fn large_cell_value_handler( State(application): State, Json(request): Json, @@ -4342,6 +6347,35 @@ mod tests { "/api/rdb/trigger/detail", ]; + const REQUIRED_EDITABLE_PATHS: &[(&str, &str)] = &[ + ("get", "/api/rdb/table/table_meta"), + ("get", "/api/rdb/table/query"), + ("post", "/api/rdb/table/modify/sql"), + ("post", "/api/rdb/ddl/delete"), + ("post", "/api/rdb/table/truncate"), + ("post", "/api/rdb/table/copy"), + ("post", "/api/rdb/database/create_database_sql"), + ("post", "/api/rdb/schema/create_schema_sql"), + ("post", "/api/rdb/delete/database/prepare"), + ("post", "/api/rdb/delete/database/execute"), + ("post", "/api/rdb/delete/schema/prepare"), + ("post", "/api/rdb/delete/schema/execute"), + ("get", "/api/rdb/view/query"), + ("get", "/api/rdb/view/view_meta"), + ("post", "/api/rdb/view/modify/sql"), + ("post", "/api/rdb/view/drop"), + ("post", "/api/rdb/dml/get_update_sql"), + ("put", "/api/rdb/dml/get_update_sql"), + ("post", "/api/rdb/dml/copy_update_sql"), + ("put", "/api/rdb/dml/copy_update_sql"), + ("post", "/api/rdb/dml/copy_in_values_sql"), + ("put", "/api/rdb/dml/copy_in_values_sql"), + ("post", "/api/rdb/dml/count"), + ("put", "/api/rdb/dml/count"), + ("post", "/api/rdb/dml/execute_update"), + ("put", "/api/rdb/dml/execute_update"), + ]; + fn metadata_message(path: &str) -> serde_json::Value { let mut message = serde_json::json!({ "dataSourceId": 1, @@ -4512,6 +6546,215 @@ mod tests { assert!(body["data"].get("total").is_none()); } + #[test] + fn editable_preview_prepends_page_aware_row_numbers_and_column_metadata() { + let mut headers = vec![LegacyResultHeader { + data_type: "NUMERIC".to_owned(), + name: "id".to_owned(), + column_name: "id".to_owned(), + column_type: "BIGINT".to_owned(), + table_name: Some("items".to_owned()), + database_name: Some("inventory".to_owned()), + schema_name: None, + primary_key: false, + comment: None, + default_value: None, + auto_increment: None, + nullable: true, + column_size: None, + decimal_digits: None, + editor_type: None, + }]; + let metadata = vec![CommunityTableColumn { + database_name: "inventory".to_owned(), + table_name: "items".to_owned(), + name: "id".to_owned(), + column_type: "BIGINT".to_owned(), + default_value: Some("0".to_owned()), + auto_increment: Some(true), + comment: "primary id".to_owned(), + primary_key: Some(true), + column_size: Some(20), + decimal_digits: Some(0), + nullable: Some(0), + ..CommunityTableColumn::default() + }]; + assert!(enrich_direct_table_headers( + &mut headers, + &metadata, + "items" + )); + let mut rows = vec![vec![LegacyResultCell { + value: Some("7".to_owned()), + large_value: false, + large_value_id: None, + value_type: "UNKNOWN".to_owned(), + sql_type: -5, + column_type: "BIGINT".to_owned(), + size_bytes: None, + size_chars: None, + loaded_bytes: None, + loaded_chars: None, + truncated: false, + unsupported_reason: None, + }]]; + prepend_synthetic_row_numbers(&mut headers, &mut rows, 20); + + assert_eq!(headers[0].name, "CHAT2DB_ROW_NUMBER"); + assert_eq!(headers[0].data_type, "CHAT2DB_ROW_NUMBER"); + assert_eq!(rows[0][0].value.as_deref(), Some("21")); + assert_eq!(rows[0][1].value.as_deref(), Some("7")); + assert!(headers[1].primary_key); + assert_eq!(headers[1].default_value.as_deref(), Some("0")); + assert_eq!(headers[1].auto_increment, Some(1)); + assert!(!headers[1].nullable); + assert_eq!(headers[1].comment.as_deref(), Some("primary id")); + assert_eq!(headers[1].editor_type.as_deref(), Some("TEXT")); + } + + #[tokio::test] + async fn grid_update_mapping_builds_sql_and_rejects_partial_large_values() { + let request: LegacyGridUpdateRequest = serde_json::from_value(serde_json::json!({ + "dataSourceId": "mysql-local", + "databaseType": "MYSQL", + "databaseName": "inventory", + "schemaName": "", + "tableName": "items", + "headerList": [ + { "name": "CHAT2DB_ROW_NUMBER", "columnType": "BIGINT" }, + { "name": "id", "columnType": "BIGINT", "primaryKey": true, "autoIncrement": 1 }, + { "name": "label", "columnType": "VARCHAR" } + ], + "operations": [{ + "type": "UPDATE", + "dataList": ["1", "7", "new label"], + "oldDataList": ["1", "7", "old label"] + }] + })) + .expect("frontend grid request must deserialize"); + let sql = build_grid_update_sql(&Application::new(), &request) + .await + .expect("grid SQL must build without opening a datasource"); + assert_eq!( + sql, + "UPDATE `inventory`.`items` SET `label` = 'new label' WHERE `id` = 7;" + ); + + let mut partial = request; + partial.operations[0].old_data_list[2] = + Some("CHAT2DB_LARGE_VALUE_PREVIEW:PARTIAL".to_owned()); + let error = build_grid_update_sql(&Application::new(), &partial) + .await + .expect_err("partial large-value previews must never enter DML"); + assert_eq!(error.code, "mysql_partial_large_value_rejected"); + } + + #[tokio::test] + async fn table_and_view_editor_payloads_map_to_core_builders() { + let table_request: LegacyTableModifyRequest = serde_json::from_value(serde_json::json!({ + "dataSourceId": "mysql-local", + "databaseType": "MYSQL", + "databaseName": "inventory", + "newTable": { + "name": "items", + "comment": "stock", + "engine": "InnoDB", + "charset": "utf8mb4", + "columnList": [ + { + "name": "id", + "columnType": "BIGINT", + "nullable": 0, + "autoIncrement": true, + "primaryKey": true + }, + { + "name": "label", + "columnType": "VARCHAR", + "columnSize": 255, + "nullable": 0 + } + ], + "indexList": [] + } + })) + .expect("table editor request must deserialize"); + let table_sql = build_table_modify_sql(&Application::new(), &table_request) + .await + .expect("table SQL must build"); + assert_eq!(table_sql.len(), 1); + assert!( + table_sql[0] + .sql + .starts_with("CREATE TABLE `inventory`.`items`") + ); + assert!(table_sql[0].sql.contains("PRIMARY KEY (`id`) USING BTREE")); + assert!(table_sql[0].sql.contains("`label` VARCHAR(255) NOT NULL")); + + let view_request: LegacyViewOperationRequest = serde_json::from_value(serde_json::json!({ + "dataSourceId": "mysql-local", + "databaseType": "MYSQL", + "databaseName": "inventory", + "viewName": "active_items", + "viewBody": "SELECT id FROM items WHERE active = 1", + "useOrReplace": true, + "algorithm": "MERGE", + "definer": "reporter@localhost", + "security": "INVOKER", + "checkOption": "LOCAL" + })) + .expect("view editor request must deserialize"); + let view_sql = build_view_modify_sql(&Application::new(), &view_request) + .await + .expect("view SQL must build"); + assert!(view_sql.starts_with("CREATE OR REPLACE ALGORITHM = MERGE")); + assert!(view_sql.contains("DEFINER = 'reporter'@'localhost'")); + assert!(view_sql.ends_with("WITH LOCAL CHECK OPTION")); + } + + #[tokio::test] + async fn editable_paths_are_registered_for_dispatch_and_axum() { + let router = routes().with_state(Application::new()); + for (method, path) in REQUIRED_EDITABLE_PATHS { + assert!(LEGACY_PATHS.contains(path), "missing dispatch path: {path}"); + let response = dispatch( + &Application::new(), + LegacyDispatchRequest { + request_url: (*path).to_owned(), + method: (*method).to_owned(), + message: serde_json::Value::Null, + }, + ) + .await; + assert_eq!( + response["errorCode"], "invalid_legacy_request", + "missing desktop dispatch branch: {method} {path}" + ); + + let http_method = method + .to_ascii_uppercase() + .parse::() + .expect("method must be valid"); + let mut builder = Request::builder().method(http_method).uri(*path); + let body = if *method == "get" { + Body::empty() + } else { + builder = builder.header("content-type", "application/json"); + Body::from("null") + }; + let response = router + .clone() + .oneshot(builder.body(body).expect("request must build")) + .await + .expect("router must respond"); + assert_eq!( + response.status(), + StatusCode::OK, + "missing Axum route: {method} {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 new file mode 100644 index 0000000..77295b9 --- /dev/null +++ b/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs @@ -0,0 +1,834 @@ +use std::panic::AssertUnwindSafe; + +use axum::{ + Router, + body::Body, + http::{Method, Request, StatusCode}, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chat2db_contract::{ + ComponentState, CreateDatasourceRequest, DatasourceConnection, DatasourceConnectionProperty, +}; +use chat2db_core::{Application, RuntimeConfig, RuntimeHost}; +use chat2db_java_bridge::{EngineCommand, EngineConfig}; +use futures_util::FutureExt as _; +use http_body_util::BodyExt as _; +use mysql_async::{Conn, Opts, OptsBuilder, prelude::Queryable as _}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tower::ServiceExt as _; +use uuid::Uuid; + +const REQUIRED_MYSQL_ENV: [&str; 4] = [ + "MYSQL_TEST_HOST", + "MYSQL_TEST_PORT", + "MYSQL_TEST_USER", + "MYSQL_TEST_PASSWORD", +]; + +struct MysqlTestConfig { + host: String, + port: u16, + user: String, + password: String, +} + +impl MysqlTestConfig { + fn from_environment() -> Option { + let configured = REQUIRED_MYSQL_ENV + .iter() + .filter(|name| std::env::var_os(name).is_some()) + .count(); + if configured == 0 { + eprintln!("skipping editable MySQL Web test; MYSQL_TEST_* variables are absent"); + return None; + } + assert_eq!(configured, REQUIRED_MYSQL_ENV.len()); + Some(Self { + host: required_env("MYSQL_TEST_HOST"), + port: required_env("MYSQL_TEST_PORT") + .parse() + .expect("MYSQL_TEST_PORT must be valid"), + user: required_env("MYSQL_TEST_USER"), + password: required_env("MYSQL_TEST_PASSWORD"), + }) + } + + fn options(&self) -> Opts { + OptsBuilder::default() + .ip_or_hostname(self.host.clone()) + .tcp_port(self.port) + .user(Some(self.user.clone())) + .pass(Some(self.password.clone())) + .prefer_socket(Some(false)) + .into() + } + + fn connection(&self, database_name: Option<&str>) -> DatasourceConnection { + let host = if self.host.contains(':') + && !(self.host.starts_with('[') && self.host.ends_with(']')) + { + format!("[{}]", self.host) + } else { + self.host.clone() + }; + DatasourceConnection { + jdbc_url: format!( + "jdbc:mysql://{host}:{}/{database}?useSSL=false&serverTimezone=UTC", + self.port, + database = database_name.unwrap_or_default() + ), + properties: vec![ + DatasourceConnectionProperty { + key: "user".to_owned(), + value: self.user.clone(), + sensitive: false, + }, + DatasourceConnectionProperty { + key: "password".to_owned(), + value: self.password.clone(), + sensitive: true, + }, + ], + read_only: false, + } + } +} + +#[tokio::test] +#[ignore = "requires an external MySQL service"] +async fn community_http_editable_grid_and_ddl_keep_java_dormant() { + let Some(config) = MysqlTestConfig::from_environment() else { + return; + }; + let database_name = format!("chat2db_web_it_{}", Uuid::new_v4().simple()); + let verification = AssertUnwindSafe(verify_product_vertical(&config, &database_name)) + .catch_unwind() + .await; + let cleanup = cleanup_database(&config, &database_name).await; + if let Err(payload) = verification { + if let Err(error) = cleanup { + eprintln!("editable MySQL cleanup also failed: {error}"); + } + std::panic::resume_unwind(payload); + } + cleanup.expect("editable MySQL fixture must be removed"); +} + +#[allow(clippy::too_many_lines)] +async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) { + let directory = TempDir::new().expect("temporary Web runtime"); + let runtime = RuntimeConfig::new(EngineConfig::new(EngineCommand::new( + directory.path().join("missing-java"), + ))) + .with_data_dir(directory.path().join("data")) + .with_vault_master_key_base64(STANDARD.encode([0x75; 32])); + let mut host = RuntimeHost::open(runtime) + .await + .expect("native MySQL Web runtime must open"); + let application = host.application(); + let router = chat2db_web::router(application.clone()); + assert_java_dormant(&application); + + let root = create_datasource(&application, config, None, "MySQL root").await; + let create_database = post( + &router, + "/api/rdb/database/create_database_sql", + json!({ + "dataSourceId": root, + "databaseType": "MYSQL", + "databaseName": database_name, + "charset": "utf8mb4", + "collation": "utf8mb4_0900_ai_ci" + }), + ) + .await; + execute_ddl( + &router, + &root, + "", + "", + create_database["sql"].as_str().expect("database SQL"), + ) + .await; + assert_eq!( + database_options(config, database_name).await, + ("utf8mb4".to_owned(), "utf8mb4_0900_ai_ci".to_owned()) + ); + assert_java_dormant(&application); + + let datasource = + create_datasource(&application, config, Some(database_name), "MySQL editable").await; + let editor_meta = get( + &router, + &format!("/api/rdb/table/table_meta?dataSourceId={datasource}&databaseType=MYSQL"), + ) + .await; + assert!( + editor_meta["columnTypes"] + .as_array() + .expect("column type inventory") + .iter() + .any(|item| item["typeName"] == "VARCHAR") + ); + assert!( + editor_meta["engineTypes"] + .as_array() + .expect("engine inventory") + .iter() + .any(|item| item["name"] == "InnoDB") + ); + let table_sql = post( + &router, + "/api/rdb/table/modify/sql", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "newTable": { + "name": "items", + "databaseName": database_name, + "type": "TABLE", + "engine": "InnoDB", + "charset": "utf8mb4", + "collate": "utf8mb4_0900_ai_ci", + "columnList": [ + {"name": "id", "columnType": "BIGINT", "nullable": 0, + "autoIncrement": true, "primaryKey": true}, + {"name": "label", "columnType": "VARCHAR", "columnSize": 128, + "nullable": 0, "comment": "editable label"}, + {"name": "score", "columnType": "INT", "nullable": 0, + "defaultValue": "7"} + ], + "indexList": [] + } + }), + ) + .await; + execute_ddl( + &router, + &datasource, + database_name, + "items", + table_sql[0]["sql"].as_str().expect("table SQL"), + ) + .await; + + let old_table = get( + &router, + &format!( + "/api/rdb/table/query?dataSourceId={datasource}&databaseType=MYSQL&databaseName={database_name}&tableName=items" + ), + ) + .await; + let mut new_table = old_table.clone(); + new_table["columnList"] + .as_array_mut() + .expect("table columns") + .push(json!({ + "name": "note", + "columnType": "TEXT", + "nullable": 1, + "comment": "nullable note", + "editStatus": "ADD" + })); + let alter_sql = post( + &router, + "/api/rdb/table/modify/sql", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "oldTable": old_table, + "newTable": new_table + }), + ) + .await; + execute_ddl( + &router, + &datasource, + database_name, + "items", + alter_sql[0]["sql"].as_str().expect("alter table SQL"), + ) + .await; + assert!(column_exists(config, database_name, "items", "note").await); + assert_java_dormant(&application); + + let empty_preview = preview(&router, &datasource, database_name, "items").await; + assert_eq!(empty_preview["canEdit"], true); + assert_eq!( + empty_preview["headerList"][0]["dataType"], + "CHAT2DB_ROW_NUMBER" + ); + assert_eq!(empty_preview["headerList"][1]["primaryKey"], true); + assert_eq!(empty_preview["headerList"][1]["autoIncrement"], 1); + let headers = empty_preview["headerList"].clone(); + + let insert_sql = grid_sql( + &router, + "/api/rdb/dml/get_update_sql", + &datasource, + database_name, + headers.clone(), + json!([{ + "type": "CREATE", + "dataList": ["new-row", "CHAT2DB_UPDATE_TABLE_DATA_USER_FILLED_GENERATED", + "O'Reilly\\path", "CHAT2DB_UPDATE_TABLE_DATA_USER_FILLED_DEFAULT", + null] + }]), + ) + .await; + execute_update(&router, &datasource, database_name, &insert_sql).await; + assert_java_dormant(&application); + + let inserted_preview = preview(&router, &datasource, database_name, "items").await; + let old_row = cell_values(&inserted_preview["dataList"][0]); + assert_eq!(old_row, json!(["1", "1", "O'Reilly\\path", "7", null])); + let mut updated_row = old_row.as_array().expect("row values").clone(); + updated_row[2] = json!("更新后的值"); + updated_row[3] = json!(9); + updated_row[4] = json!("引号 ' 与反斜杠 \\ 都保留"); + let update_sql = grid_sql( + &router, + "/api/rdb/dml/get_update_sql", + &datasource, + database_name, + headers.clone(), + json!([{ + "type": "UPDATE", + "dataList": updated_row, + "oldDataList": old_row + }]), + ) + .await; + execute_update(&router, &datasource, database_name, &update_sql).await; + assert_eq!( + read_item(config, database_name).await, + ( + "更新后的值".to_owned(), + 9, + Some("引号 ' 与反斜杠 \\ 都保留".to_owned()) + ) + ); + + let count = post( + &router, + "/api/rdb/dml/count", + sql_request(&datasource, database_name, "items", "SELECT * FROM `items`"), + ) + .await; + assert_eq!(count, 1); + + let updated_preview = preview(&router, &datasource, database_name, "items").await; + let updated_values = cell_values(&updated_preview["dataList"][0]); + let copy_update = grid_sql( + &router, + "/api/rdb/dml/copy_update_sql", + &datasource, + database_name, + headers.clone(), + json!([{"type": "UPDATE_COPY", "dataList": updated_values, "selectCols": [2]}]), + ) + .await; + assert!(copy_update.contains("SET `label` = '更新后的值' WHERE `id` = 1")); + let in_values = post( + &router, + "/api/rdb/dml/copy_in_values_sql", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "headerList": headers, + "sourceType": "RESULT_SET", + "operations": [{ + "type": "WHERE", + "dataList": cell_values(&updated_preview["dataList"][0]), + "selectCols": [2], + "selectedCell": updated_preview["dataList"][0][2] + }] + }), + ) + .await; + assert_eq!(in_values, "('更新后的值')"); + assert_java_dormant(&application); + + post( + &router, + "/api/rdb/table/copy", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "tableName": "items", + "newName": "items_copy", + "copyData": true + }), + ) + .await; + assert_eq!(scalar_count(config, database_name, "items_copy").await, 1); + post( + &router, + "/api/rdb/table/truncate", + object_request(&datasource, database_name, "items_copy"), + ) + .await; + assert_eq!(scalar_count(config, database_name, "items_copy").await, 0); + + let view_sql = post( + &router, + "/api/rdb/view/modify/sql", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "viewName": "item_labels", + "viewBody": "SELECT id, label FROM items", + "useOrReplace": true, + "algorithm": "MERGE", + "security": "INVOKER" + }), + ) + .await; + execute_ddl( + &router, + &datasource, + database_name, + "item_labels", + view_sql.as_str().expect("view SQL"), + ) + .await; + assert_eq!(scalar_count(config, database_name, "item_labels").await, 1); + let view_detail = get( + &router, + &format!( + "/api/rdb/view/query?dataSourceId={datasource}&databaseType=MYSQL&databaseName={database_name}&tableName=item_labels" + ), + ) + .await; + assert_eq!(view_detail["name"], "item_labels"); + assert!( + view_detail["ddl"] + .as_str() + .expect("view DDL") + .to_ascii_uppercase() + .contains("SELECT") + ); + let replace_view_sql = post( + &router, + "/api/rdb/view/modify/sql", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "viewName": "item_labels", + "viewBody": "SELECT id, label FROM items WHERE score > 100", + "useOrReplace": true, + "algorithm": "MERGE", + "security": "INVOKER" + }), + ) + .await; + execute_ddl( + &router, + &datasource, + database_name, + "item_labels", + replace_view_sql.as_str().expect("replace view SQL"), + ) + .await; + assert_eq!(scalar_count(config, database_name, "item_labels").await, 0); + post( + &router, + "/api/rdb/view/drop", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "viewName": "item_labels" + }), + ) + .await; + assert!(!object_exists(config, database_name, "item_labels").await); + assert_java_dormant(&application); + + let delete_sql = grid_sql( + &router, + "/api/rdb/dml/get_update_sql", + &datasource, + database_name, + updated_preview["headerList"].clone(), + json!([{ + "type": "DELETE", + "oldDataList": cell_values(&updated_preview["dataList"][0]) + }]), + ) + .await; + 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"] { + post( + &router, + "/api/rdb/ddl/delete", + object_request(&datasource, database_name, table), + ) + .await; + } + let prepared = post( + &router, + "/api/rdb/delete/database/prepare", + json!({ + "dataSourceId": root, + "databaseType": "MYSQL", + "databaseName": database_name + }), + ) + .await; + assert_eq!(prepared["confirmName"], database_name); + let delete_error = post_failure( + &router, + "/api/rdb/delete/database/execute", + json!({ + "dataSourceId": root, + "databaseType": "MYSQL", + "databaseName": database_name, + "confirmName": "wrong-name" + }), + ) + .await; + assert_eq!( + delete_error["errorCode"], + "database_object_delete_confirmation_mismatch" + ); + assert!(database_exists(config, database_name).await); + post( + &router, + "/api/rdb/delete/database/execute", + json!({ + "dataSourceId": root, + "databaseType": "MYSQL", + "databaseName": database_name, + "confirmName": database_name + }), + ) + .await; + assert!(!database_exists(config, database_name).await); + assert_java_dormant(&application); + + host.shutdown() + .await + .expect("native-only Web runtime must shut down"); +} + +async fn create_datasource( + application: &Application, + config: &MysqlTestConfig, + database_name: Option<&str>, + name: &str, +) -> String { + application + .create_datasource(CreateDatasourceRequest { + name: name.to_owned(), + driver_id: "mysql".to_owned(), + connection: Some(config.connection(database_name)), + }) + .await + .expect("native MySQL datasource must create") + .id +} + +async fn post(router: &Router, path: &str, payload: Value) -> Value { + let envelope = request(router, Method::POST, path, Some(payload)).await; + assert_eq!( + envelope["success"], true, + "route failed: {path}: {envelope}" + ); + envelope["data"].clone() +} + +async fn get(router: &Router, path: &str) -> Value { + let envelope = request(router, Method::GET, path, None).await; + assert_eq!( + envelope["success"], true, + "route failed: {path}: {envelope}" + ); + envelope["data"].clone() +} + +async fn post_failure(router: &Router, path: &str, payload: Value) -> Value { + let envelope = request(router, Method::POST, path, Some(payload)).await; + assert_eq!( + envelope["success"], false, + "route unexpectedly succeeded: {path}: {envelope}" + ); + envelope +} + +async fn request(router: &Router, method: Method, path: &str, payload: Option) -> Value { + let request = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(payload.map_or_else(Body::empty, |payload| { + Body::from(serde_json::to_vec(&payload).expect("request must encode")) + })) + .expect("request must build"); + let response = router + .clone() + .oneshot(request) + .await + .expect("Web route must respond"); + assert_eq!(response.status(), StatusCode::OK, "route failed: {path}"); + let body = response + .into_body() + .collect() + .await + .expect("response body must collect") + .to_bytes(); + let envelope: Value = serde_json::from_slice(&body).expect("response must be JSON"); + envelope +} + +async fn execute_ddl( + router: &Router, + datasource_id: &str, + database_name: &str, + table_name: &str, + sql: &str, +) { + let result = post( + router, + "/api/rdb/dml/execute_ddl", + sql_request(datasource_id, database_name, table_name, sql), + ) + .await; + assert_eq!(result["success"], true, "DDL failed: {result}"); +} + +async fn execute_update(router: &Router, datasource_id: &str, database_name: &str, sql: &str) { + let result = post( + router, + "/api/rdb/dml/execute_update", + sql_request(datasource_id, database_name, "items", sql), + ) + .await; + assert_eq!(result["success"], true, "grid update failed: {result}"); + assert_eq!( + result["updateCount"], 1, + "unexpected update count: {result}" + ); +} + +async fn grid_sql( + router: &Router, + path: &str, + datasource_id: &str, + database_name: &str, + headers: Value, + operations: Value, +) -> String { + post( + router, + path, + json!({ + "dataSourceId": datasource_id, + "databaseType": "MYSQL", + "databaseName": database_name, + "tableName": "items", + "headerList": headers, + "operations": operations + }), + ) + .await + .as_str() + .expect("grid SQL must be a string") + .to_owned() +} + +async fn preview( + router: &Router, + datasource_id: &str, + database_name: &str, + table_name: &str, +) -> Value { + post( + router, + "/api/rdb/dml/execute_table", + json!({ + "dataSourceId": datasource_id, + "databaseType": "MYSQL", + "databaseName": database_name, + "tableName": table_name, + "pageNo": 1, + "pageSize": 20 + }), + ) + .await[0] + .clone() +} + +fn sql_request(datasource_id: &str, database_name: &str, table_name: &str, sql: &str) -> Value { + json!({ + "dataSourceId": datasource_id, + "databaseType": "MYSQL", + "databaseName": database_name, + "tableName": table_name, + "sql": sql, + "single": true, + "pageNo": 1, + "pageSize": 20 + }) +} + +fn object_request(datasource_id: &str, database_name: &str, table_name: &str) -> Value { + json!({ + "dataSourceId": datasource_id, + "databaseType": "MYSQL", + "databaseName": database_name, + "tableName": table_name + }) +} + +fn cell_values(row: &Value) -> Value { + Value::Array( + row.as_array() + .expect("preview row must be an array") + .iter() + .map(|cell| cell["value"].clone()) + .collect(), + ) +} + +async fn scalar_count(config: &MysqlTestConfig, database_name: &str, object_name: &str) -> u64 { + let mut conn = Conn::new(config.options()) + .await + .expect("verification connection must open"); + let value = conn + .query_first::(format!( + "SELECT COUNT(*) FROM `{database_name}`.`{object_name}`" + )) + .await + .expect("verification count must execute") + .expect("verification count must return"); + conn.disconnect() + .await + .expect("verification connection must close"); + value +} + +async fn database_options(config: &MysqlTestConfig, database_name: &str) -> (String, String) { + let mut conn = Conn::new(config.options()) + .await + .expect("database options connection must open"); + let options = conn + .exec_first::<(String, String), _, _>( + "SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME \ + FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?", + (database_name,), + ) + .await + .expect("database options query must execute") + .expect("created database must exist"); + conn.disconnect() + .await + .expect("database options connection must close"); + options +} + +async fn column_exists( + config: &MysqlTestConfig, + database_name: &str, + table_name: &str, + column_name: &str, +) -> bool { + let mut conn = Conn::new(config.options()) + .await + .expect("column probe must connect"); + let count = conn + .exec_first::( + "SELECT COUNT(*) FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?", + (database_name, table_name, column_name), + ) + .await + .expect("column probe must execute") + .unwrap_or_default(); + conn.disconnect().await.expect("column probe must close"); + count > 0 +} + +async fn object_exists(config: &MysqlTestConfig, database_name: &str, object_name: &str) -> bool { + let mut conn = Conn::new(config.options()) + .await + .expect("object probe must connect"); + let count = conn + .exec_first::( + "SELECT COUNT(*) FROM information_schema.TABLES \ + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", + (database_name, object_name), + ) + .await + .expect("object probe must execute") + .unwrap_or_default(); + conn.disconnect().await.expect("object probe must close"); + count > 0 +} + +async fn read_item(config: &MysqlTestConfig, database_name: &str) -> (String, i32, Option) { + let mut conn = Conn::new(config.options()) + .await + .expect("row verification connection must open"); + let row = conn + .query_first::<(String, i32, Option), _>(format!( + "SELECT `label`, `score`, `note` FROM `{database_name}`.`items` WHERE `id` = 1" + )) + .await + .expect("row verification query must execute") + .expect("updated row must exist"); + conn.disconnect() + .await + .expect("row verification connection must close"); + row +} + +async fn database_exists(config: &MysqlTestConfig, database_name: &str) -> bool { + let mut conn = Conn::new(config.options()) + .await + .expect("database probe must connect"); + let count = conn + .exec_first::( + "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?", + (database_name,), + ) + .await + .expect("database probe must execute") + .unwrap_or_default(); + conn.disconnect().await.expect("database probe must close"); + count > 0 +} + +async fn cleanup_database(config: &MysqlTestConfig, database_name: &str) -> Result<(), String> { + let mut conn = Conn::new(config.options()) + .await + .map_err(|error| error.to_string())?; + conn.query_drop(format!("DROP DATABASE IF EXISTS `{database_name}`")) + .await + .map_err(|error| error.to_string())?; + conn.disconnect().await.map_err(|error| error.to_string()) +} + +fn assert_java_dormant(application: &Application) { + let engine = application + .health() + .components + .into_iter() + .find(|component| component.id == "database-engine") + .expect("database engine health must exist"); + assert_eq!(engine.state, ComponentState::Ready); + assert_eq!(engine.detail, "Available on demand; Java is not running"); +} + +fn required_env(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("{name} must be configured")) +} diff --git a/crates/chat2db-core/src/lib.rs b/crates/chat2db-core/src/lib.rs index 73c8ec4..e748164 100644 --- a/crates/chat2db-core/src/lib.rs +++ b/crates/chat2db-core/src/lib.rs @@ -8,6 +8,7 @@ mod driver_pack; mod engine_manager; mod error; mod large_value; +pub mod mysql_ddl; mod native_mysql; mod operation; mod query; diff --git a/crates/chat2db-core/src/mysql_ddl.rs b/crates/chat2db-core/src/mysql_ddl.rs new file mode 100644 index 0000000..6f9aa47 --- /dev/null +++ b/crates/chat2db-core/src/mysql_ddl.rs @@ -0,0 +1,2881 @@ +//! MySQL-specific structured SQL builders used by the retained Community API. + +use std::fmt::Write as _; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; + +use crate::AppError; + +pub const MYSQL_RESULT_DEFAULT_PLACEHOLDER: &str = "CHAT2DB_UPDATE_TABLE_DATA_USER_FILLED_DEFAULT"; +pub const MYSQL_RESULT_GENERATED_PLACEHOLDER: &str = + "CHAT2DB_UPDATE_TABLE_DATA_USER_FILLED_GENERATED"; +pub const MYSQL_PARTIAL_LARGE_VALUE_PREFIX: &str = "CHAT2DB_LARGE_VALUE_PREVIEW:"; + +const MAX_IDENTIFIER_CHARS: usize = 64; +const MAX_VIEW_BODY_BYTES: usize = 1024 * 1024; +const MAX_COMMENT_BYTES: usize = 2048; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlQualifiedName { + #[serde(default)] + pub database_name: Option, + #[serde(default)] + pub schema_name: Option, + pub name: String, +} + +impl MysqlQualifiedName { + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { + database_name: None, + schema_name: None, + name: name.into(), + } + } + + #[must_use] + pub fn in_database(database_name: impl Into, name: impl Into) -> Self { + Self { + database_name: Some(database_name.into()), + schema_name: None, + name: name.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlResultGridHeader { + pub name: String, + #[serde(default)] + pub column_type: String, + #[serde(default)] + pub data_type: String, + #[serde(default)] + pub primary_key: bool, + #[serde(default)] + pub auto_increment: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MysqlResultGridOperationType { + Create, + Update, + Delete, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlResultGridOperation { + #[serde(rename = "type")] + pub operation_type: MysqlResultGridOperationType, + #[serde(default)] + pub data_list: Vec>, + #[serde(default)] + pub old_data_list: Vec>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MysqlResultGridCopyOperationType { + Create, + UpdateCopy, + Where, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlResultGridCopyOperation { + #[serde(rename = "type")] + pub operation_type: MysqlResultGridCopyOperationType, + #[serde(default)] + pub data_list: Vec>, + #[serde(default)] + pub select_cols: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlDatabaseDefinition { + pub name: String, + #[serde(default)] + pub if_not_exists: bool, + #[serde(default)] + pub charset: Option, + #[serde(default)] + pub collation: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(clippy::struct_excessive_bools)] +pub struct MysqlColumnDefinition { + pub name: String, + pub type_name: String, + #[serde(default)] + pub length: Option, + #[serde(default)] + pub scale: Option, + #[serde(default)] + pub unsigned: bool, + #[serde(default)] + pub nullable: bool, + #[serde(default)] + pub default_value: Option, + #[serde(default)] + pub auto_increment: bool, + #[serde(default)] + pub charset: Option, + #[serde(default)] + pub collation: Option, + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub enum_values: Vec, + #[serde(default)] + pub on_update_current_timestamp: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MysqlSortOrder { + Asc, + Desc, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MysqlIndexMethod { + Btree, + Hash, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MysqlIndexKind { + Primary, + Normal, + Unique, + Fulltext, + Spatial, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlIndexColumn { + pub name: String, + #[serde(default)] + pub prefix_length: Option, + #[serde(default)] + pub order: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlIndexDefinition { + pub kind: MysqlIndexKind, + #[serde(default)] + pub name: Option, + pub columns: Vec, + #[serde(default)] + pub method: Option, + #[serde(default)] + pub comment: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlTableDefinition { + pub name: MysqlQualifiedName, + #[serde(default)] + pub if_not_exists: bool, + pub columns: Vec, + #[serde(default)] + pub indexes: Vec, + #[serde(default)] + pub engine: Option, + #[serde(default)] + pub charset: Option, + #[serde(default)] + pub collation: Option, + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub auto_increment: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MysqlColumnPosition { + First, + After(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "operation", + rename_all = "SCREAMING_SNAKE_CASE", + rename_all_fields = "camelCase" +)] +pub enum MysqlColumnAlter { + Add { + column: MysqlColumnDefinition, + #[serde(default)] + position: Option, + }, + Modify { + old_name: String, + column: MysqlColumnDefinition, + #[serde(default)] + position: Option, + }, + Delete { + name: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "operation", + rename_all = "SCREAMING_SNAKE_CASE", + rename_all_fields = "camelCase" +)] +pub enum MysqlIndexAlter { + Add { + index: MysqlIndexDefinition, + }, + Modify { + old_kind: MysqlIndexKind, + #[serde(default)] + old_name: Option, + index: MysqlIndexDefinition, + }, + Delete { + kind: MysqlIndexKind, + #[serde(default)] + name: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlTableAlter { + pub table: MysqlQualifiedName, + #[serde(default)] + pub rename_to: Option, + #[serde(default)] + pub columns: Vec, + #[serde(default)] + pub indexes: Vec, + #[serde(default)] + pub engine: Option, + #[serde(default)] + pub charset: Option, + #[serde(default)] + pub collation: Option, + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub auto_increment: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlTableCopy { + pub source: MysqlQualifiedName, + pub target: MysqlQualifiedName, + #[serde(default)] + pub if_not_exists: bool, + #[serde(default)] + pub copy_data: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlViewDefinition { + pub name: MysqlQualifiedName, + #[serde(default)] + pub columns: Vec, + #[serde(default)] + pub use_or_replace: bool, + #[serde(default)] + pub algorithm: Option, + #[serde(default)] + pub definer: Option, + #[serde(default)] + pub sql_security: Option, + #[serde(default)] + pub check_option: Option, + pub body: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MysqlViewAlgorithm { + Undefined, + Merge, + Temptable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MysqlViewSecurity { + Definer, + Invoker, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlViewDefiner { + pub user: String, + pub host: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MysqlViewCheckOption { + Cascaded, + Local, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(clippy::struct_excessive_bools)] +pub struct MysqlEditorColumnType { + pub type_name: String, + pub support_length: bool, + pub support_scale: bool, + pub support_nullable: bool, + pub support_auto_increment: bool, + pub support_charset: bool, + pub support_collation: bool, + pub support_comments: bool, + pub support_default_value: bool, + pub support_extent: bool, + pub support_value: bool, + pub support_unit: bool, + pub support_on_update_current_timestamp: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlEditorCharset { + pub charset_name: String, + pub default_collation_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlEditorCollation { + pub collation_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlEditorIndexType { + pub type_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlEditorDefaultValue { + pub default_value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(clippy::struct_excessive_bools)] +pub struct MysqlEditorEngineType { + pub name: String, + #[serde(rename = "supportTTL")] + pub support_ttl: bool, + pub support_sort_order: bool, + pub support_skipping_indices: bool, + pub support_deduplication: bool, + pub support_settings: bool, + pub support_parallel_insert: bool, + pub support_projections: bool, + pub support_replication: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlTableEditorMeta { + pub column_types: Vec, + pub charsets: Vec, + pub collations: Vec, + pub index_types: Vec, + pub default_values: Vec, + pub engine_types: Vec, +} + +/// Builds one native `MySQL` result-grid mutation statement. +/// +/// # Errors +/// +/// Returns [`AppError`] when identifiers, row shapes, placeholders, or typed values are invalid. +pub fn build_mysql_result_grid_sql( + table: &MysqlQualifiedName, + headers: &[MysqlResultGridHeader], + operation: &MysqlResultGridOperation, +) -> Result { + validate_result_grid(table, headers, operation)?; + let table = quote_qualified_name(table)?; + match operation.operation_type { + MysqlResultGridOperationType::Create => { + build_result_grid_insert(&table, headers, &operation.data_list) + } + MysqlResultGridOperationType::Update => build_result_grid_update( + &table, + headers, + &operation.data_list, + &operation.old_data_list, + ), + MysqlResultGridOperationType::Delete => { + build_result_grid_delete(&table, headers, &operation.old_data_list) + } + } +} + +/// Builds a semicolon-delimited native `MySQL` result-grid mutation script. +/// +/// # Errors +/// +/// Returns [`AppError`] when no operations are supplied or any operation is invalid. +pub fn build_mysql_result_grid_script( + table: &MysqlQualifiedName, + headers: &[MysqlResultGridHeader], + operations: &[MysqlResultGridOperation], +) -> Result { + if operations.is_empty() { + return Err(invalid_grid( + "At least one result-grid operation is required", + )); + } + let statements = operations + .iter() + .map(|operation| build_mysql_result_grid_sql(table, headers, operation)) + .collect::, _>>()?; + Ok(format!("{};", statements.join(";\n"))) +} + +/// Builds SQL copied from selected result-grid rows without executing it. +/// +/// # Errors +/// +/// Returns [`AppError`] when the selection or any typed value is invalid. +pub fn build_mysql_result_grid_copy_sql( + table: &MysqlQualifiedName, + headers: &[MysqlResultGridHeader], + operations: &[MysqlResultGridCopyOperation], +) -> Result { + validate_copy_operations(table, headers, operations)?; + if operations.first().is_some_and(|operation| { + operation.operation_type == MysqlResultGridCopyOperationType::Where + }) { + if operations + .iter() + .any(|operation| operation.operation_type != MysqlResultGridCopyOperationType::Where) + { + return Err(invalid_grid( + "WHERE copy operations cannot be mixed with INSERT or UPDATE operations", + )); + } + return build_copy_where(headers, operations).map(|predicate| format!("WHERE {predicate}")); + } + + let table = quote_qualified_name(table)?; + let statements = operations + .iter() + .map(|operation| match operation.operation_type { + MysqlResultGridCopyOperationType::Create => { + build_copy_insert(&table, headers, operation) + } + MysqlResultGridCopyOperationType::UpdateCopy => { + build_copy_update(&table, headers, operation) + } + MysqlResultGridCopyOperationType::Where => Err(invalid_grid( + "WHERE copy operations cannot be mixed with INSERT or UPDATE operations", + )), + }) + .collect::, _>>()?; + Ok(format!("{};", statements.join(";\n"))) +} + +/// Builds a typed SQL `IN` value list from one selected result-grid column. +/// +/// # Errors +/// +/// Returns [`AppError`] for an empty, mixed-column, binary, partial, or malformed selection. +pub fn build_mysql_result_grid_in_values( + headers: &[MysqlResultGridHeader], + operations: &[MysqlResultGridCopyOperation], +) -> Result { + validate_copy_headers(headers)?; + if operations.is_empty() { + return Err(invalid_grid("At least one selected value is required")); + } + let mut selected_index = None; + let mut values = Vec::new(); + for operation in operations { + require_row_length("dataList", &operation.data_list, headers.len())?; + reject_partial_large_values(&operation.data_list)?; + let [index] = operation.select_cols.as_slice() else { + return Err(invalid_grid( + "SQL IN values require exactly one selected column per row", + )); + }; + validate_selected_index(*index, headers.len())?; + if selected_index + .replace(*index) + .is_some_and(|current| current != *index) + { + return Err(invalid_grid( + "SQL IN values must come from the same result column", + )); + } + let header = &headers[*index]; + if is_binary_type(base_type_name(&result_grid_type_name(header))) { + return Err(invalid_grid( + "Binary and large-value columns cannot be copied as SQL IN values", + )); + } + let value = serialize_mysql_value(header, operation.data_list[*index].as_deref())?; + if !values.contains(&value) { + values.push(value); + } + } + Ok(format!("({})", values.join(", "))) +} + +/// Builds a quoted SQL `IN` value list from external clipboard text. +/// +/// # Errors +/// +/// Returns [`AppError`] when no non-blank values are supplied. +pub fn build_mysql_external_in_values(values: &[String]) -> Result { + let mut quoted = Vec::new(); + for value in values { + let value = value.trim(); + if value.is_empty() { + continue; + } + let value = quote_mysql_string(value); + if !quoted.contains(&value) { + quoted.push(value); + } + } + if quoted.is_empty() { + return Err(invalid_grid( + "At least one non-blank clipboard value is required", + )); + } + Ok(format!("({})", quoted.join(", "))) +} + +/// Wraps exactly one `MySQL` read statement in a bounded count query. +/// +/// # Errors +/// +/// Returns [`AppError`] when the source is empty, contains multiple statements, +/// or is not a `SELECT`/CTE read candidate. +pub fn build_mysql_count_query(sql: &str) -> Result { + let statements = crate::native_mysql::split_mysql_script(sql)?; + let [source] = statements.as_slice() else { + return Err(invalid_grid( + "The row-count source must contain exactly one SELECT statement", + )); + }; + if !crate::native_mysql::is_native_read_candidate(source)? { + return Err(invalid_grid( + "The row-count source must be a SELECT or CTE query", + )); + } + Ok(format!( + "SELECT COUNT(*) AS `CHAT2DB_COUNT` FROM ({source}) AS `CHAT2DB_COUNT_SOURCE`" + )) +} + +/// Builds a `MySQL` `CREATE DATABASE` statement. +/// +/// # Errors +/// +/// Returns [`AppError`] when the name or namespace options are invalid. +pub fn build_mysql_create_database(database: &MysqlDatabaseDefinition) -> Result { + build_create_namespace(database) +} + +/// Builds a `MySQL` `CREATE DATABASE` statement for the schema alias. +/// +/// # Errors +/// +/// Returns [`AppError`] when the name or namespace options are invalid. +pub fn build_mysql_create_schema(schema: &MysqlDatabaseDefinition) -> Result { + build_create_namespace(schema) +} + +/// Builds a `MySQL` `DROP DATABASE` statement. +/// +/// # Errors +/// +/// Returns [`AppError`] when the database name is invalid. +pub fn build_mysql_drop_database(name: &str, if_exists: bool) -> Result { + build_drop_namespace(name, if_exists) +} + +/// Builds a `MySQL` `DROP DATABASE` statement for the schema alias. +/// +/// # Errors +/// +/// Returns [`AppError`] when the schema name is invalid. +pub fn build_mysql_drop_schema(name: &str, if_exists: bool) -> Result { + build_drop_namespace(name, if_exists) +} + +/// Builds a structured `MySQL` `CREATE TABLE` statement. +/// +/// # Errors +/// +/// Returns [`AppError`] when the table, columns, indexes, or options are invalid. +pub fn build_mysql_create_table(table: &MysqlTableDefinition) -> Result { + if table.columns.is_empty() { + return Err(invalid_ddl("A table must contain at least one column")); + } + let name = quote_qualified_name(&table.name)?; + let mut definitions = table + .columns + .iter() + .map(build_column_definition) + .collect::, _>>()?; + definitions.extend( + table + .indexes + .iter() + .map(build_index_definition) + .collect::, _>>()?, + ); + let mut sql = format!( + "CREATE TABLE {}{} (\n {}\n)", + if table.if_not_exists { + "IF NOT EXISTS " + } else { + "" + }, + name, + definitions.join(",\n ") + ); + append_table_options( + &mut sql, + table.engine.as_deref(), + table.charset.as_deref(), + table.collation.as_deref(), + table.comment.as_deref(), + table.auto_increment, + )?; + Ok(sql) +} + +/// Builds one structured `MySQL` `ALTER TABLE` statement. +/// +/// # Errors +/// +/// Returns [`AppError`] when a change is missing or any structured field is invalid. +pub fn build_mysql_alter_table(alter: &MysqlTableAlter) -> Result { + let table = quote_qualified_name(&alter.table)?; + let mut clauses = Vec::new(); + for change in &alter.columns { + clauses.push(build_column_alter(change)?); + } + for change in &alter.indexes { + clauses.extend(build_index_alter(change)?); + } + if let Some(engine) = alter.engine.as_deref() { + clauses.push(format!( + "ENGINE = {}", + validate_option_token("engine", engine)? + )); + } + if let Some(charset) = alter.charset.as_deref() { + clauses.push(format!( + "DEFAULT CHARACTER SET = {}", + validate_option_token("charset", charset)? + )); + } + if let Some(collation) = alter.collation.as_deref() { + clauses.push(format!( + "COLLATE = {}", + validate_option_token("collation", collation)? + )); + } + if let Some(comment) = alter.comment.as_deref() { + validate_comment(comment)?; + clauses.push(format!("COMMENT = {}", quote_mysql_string(comment))); + } + if let Some(auto_increment) = alter.auto_increment { + if auto_increment == 0 { + return Err(invalid_ddl("AUTO_INCREMENT must be greater than zero")); + } + clauses.push(format!("AUTO_INCREMENT = {auto_increment}")); + } + if let Some(rename_to) = &alter.rename_to { + clauses.push(format!("RENAME TO {}", quote_qualified_name(rename_to)?)); + } + if clauses.is_empty() { + return Err(invalid_ddl("ALTER TABLE requires at least one change")); + } + Ok(format!("ALTER TABLE {table}\n {}", clauses.join(",\n "))) +} + +/// Builds a `MySQL` `DROP TABLE` statement. +/// +/// # Errors +/// +/// Returns [`AppError`] when the qualified table name is invalid. +pub fn build_mysql_drop_table( + table: &MysqlQualifiedName, + if_exists: bool, +) -> Result { + Ok(format!( + "DROP TABLE {}{}", + if if_exists { "IF EXISTS " } else { "" }, + quote_qualified_name(table)? + )) +} + +/// Builds a `MySQL` `TRUNCATE TABLE` statement. +/// +/// # Errors +/// +/// Returns [`AppError`] when the qualified table name is invalid. +pub fn build_mysql_truncate_table(table: &MysqlQualifiedName) -> Result { + Ok(format!("TRUNCATE TABLE {}", quote_qualified_name(table)?)) +} + +/// Builds ordered statements for a `MySQL` table copy. +/// +/// # Errors +/// +/// Returns [`AppError`] when either table name is invalid or source and target are equal. +pub fn build_mysql_copy_table(copy: &MysqlTableCopy) -> Result, AppError> { + let source = quote_qualified_name(©.source)?; + let target = quote_qualified_name(©.target)?; + if source == target { + return Err(invalid_ddl("Source and target tables must be different")); + } + let mut statements = vec![format!( + "CREATE TABLE {}{target} LIKE {source}", + if copy.if_not_exists { + "IF NOT EXISTS " + } else { + "" + } + )]; + if copy.copy_data { + statements.push(format!("INSERT INTO {target} SELECT * FROM {source}")); + } + Ok(statements) +} + +/// Builds a structured `MySQL` `CREATE OR REPLACE VIEW` statement. +/// +/// # Errors +/// +/// Returns [`AppError`] when identifiers or the bounded view body are invalid. +pub fn build_mysql_create_or_replace_view(view: &MysqlViewDefinition) -> Result { + build_mysql_view(view, true) +} + +/// Builds a structured `MySQL` `CREATE VIEW` statement and honors `useOrReplace`. +/// +/// # Errors +/// +/// Returns [`AppError`] when identifiers or the bounded view body are invalid. +pub fn build_mysql_create_view(view: &MysqlViewDefinition) -> Result { + build_mysql_view(view, view.use_or_replace) +} + +fn build_mysql_view(view: &MysqlViewDefinition, use_or_replace: bool) -> Result { + let body = validate_view_body(&view.body)?; + let name = quote_qualified_name(&view.name)?; + let columns = if view.columns.is_empty() { + String::new() + } else { + let columns = view + .columns + .iter() + .map(|column| quote_identifier(column)) + .collect::, _>>()?; + format!(" ({})", columns.join(", ")) + }; + let algorithm = view.algorithm.map_or(String::new(), |algorithm| { + format!( + " ALGORITHM = {}", + match algorithm { + MysqlViewAlgorithm::Undefined => "UNDEFINED", + MysqlViewAlgorithm::Merge => "MERGE", + MysqlViewAlgorithm::Temptable => "TEMPTABLE", + } + ) + }); + let definer = view.definer.as_ref().map_or(Ok(String::new()), |definer| { + validate_view_definer(definer).map(|()| { + format!( + " DEFINER = {}@{}", + quote_mysql_string(&definer.user), + quote_mysql_string(&definer.host) + ) + }) + })?; + let security = view.sql_security.map_or(String::new(), |security| { + format!( + " SQL SECURITY {}", + match security { + MysqlViewSecurity::Definer => "DEFINER", + MysqlViewSecurity::Invoker => "INVOKER", + } + ) + }); + let check_option = view.check_option.map_or(String::new(), |check_option| { + format!( + " WITH {} CHECK OPTION", + match check_option { + MysqlViewCheckOption::Cascaded => "CASCADED", + MysqlViewCheckOption::Local => "LOCAL", + } + ) + }); + let create = if use_or_replace { + "CREATE OR REPLACE" + } else { + "CREATE" + }; + Ok(format!( + "{create}{algorithm}{definer}{security} VIEW {name}{columns} AS {body}{check_option}" + )) +} + +/// Builds a `MySQL` `DROP VIEW` statement. +/// +/// # Errors +/// +/// Returns [`AppError`] when the qualified view name is invalid. +pub fn build_mysql_drop_view( + view: &MysqlQualifiedName, + if_exists: bool, +) -> Result { + Ok(format!( + "DROP VIEW {}{}", + if if_exists { "IF EXISTS " } else { "" }, + quote_qualified_name(view)? + )) +} + +#[must_use] +pub fn mysql_table_editor_meta() -> MysqlTableEditorMeta { + MysqlTableEditorMeta { + column_types: MYSQL_COLUMN_TYPES + .iter() + .map(|type_name| editor_column_type(type_name)) + .collect(), + charsets: MYSQL_CHARSETS + .iter() + .map( + |(charset_name, default_collation_name)| MysqlEditorCharset { + charset_name: (*charset_name).to_owned(), + default_collation_name: (*default_collation_name).to_owned(), + }, + ) + .collect(), + collations: MYSQL_COLLATIONS + .iter() + .map(|collation_name| MysqlEditorCollation { + collation_name: (*collation_name).to_owned(), + }) + .collect(), + index_types: ["Primary", "Normal", "Unique", "Fulltext", "Spatial"] + .into_iter() + .map(|type_name| MysqlEditorIndexType { + type_name: type_name.to_owned(), + }) + .collect(), + default_values: ["EMPTY_STRING", "NULL", "CURRENT_TIMESTAMP"] + .into_iter() + .map(|default_value| MysqlEditorDefaultValue { + default_value: default_value.to_owned(), + }) + .collect(), + engine_types: [ + "InnoDB", + "MyISAM", + "MEMORY", + "CSV", + "ARCHIVE", + "BLACKHOLE", + "FEDERATED", + "MRG_MYISAM", + "NDB", + ] + .into_iter() + .map(|name| MysqlEditorEngineType { + name: name.to_owned(), + support_ttl: false, + support_sort_order: false, + support_skipping_indices: false, + support_deduplication: false, + support_settings: false, + support_parallel_insert: false, + support_projections: false, + support_replication: false, + }) + .collect(), + } +} + +fn invalid_grid(message: impl Into) -> AppError { + AppError::invalid("invalid_mysql_result_grid", message) +} + +fn invalid_ddl(message: impl Into) -> AppError { + AppError::invalid("invalid_mysql_ddl", message) +} + +fn validate_result_grid( + table: &MysqlQualifiedName, + headers: &[MysqlResultGridHeader], + operation: &MysqlResultGridOperation, +) -> Result<(), AppError> { + quote_qualified_name(table)?; + if headers.len() < 2 { + return Err(invalid_grid( + "Result-grid headers must include the row-number column and at least one data column", + )); + } + for header in &headers[1..] { + quote_identifier(&header.name) + .map_err(|error| invalid_grid(format!("Invalid result-grid column: {error}")))?; + } + reject_partial_large_values(&operation.data_list)?; + reject_partial_large_values(&operation.old_data_list)?; + match operation.operation_type { + MysqlResultGridOperationType::Create => { + require_row_length("dataList", &operation.data_list, headers.len())?; + if !operation.old_data_list.is_empty() { + require_row_length("oldDataList", &operation.old_data_list, headers.len())?; + } + } + MysqlResultGridOperationType::Update => { + require_row_length("dataList", &operation.data_list, headers.len())?; + require_row_length("oldDataList", &operation.old_data_list, headers.len())?; + } + MysqlResultGridOperationType::Delete => { + require_row_length("oldDataList", &operation.old_data_list, headers.len())?; + if !operation.data_list.is_empty() { + require_row_length("dataList", &operation.data_list, headers.len())?; + } + } + } + Ok(()) +} + +fn require_row_length( + label: &str, + row: &[Option], + expected: usize, +) -> Result<(), AppError> { + if row.len() != expected { + return Err(invalid_grid(format!( + "{label} contains {} values but {expected} headers were supplied", + row.len() + ))); + } + Ok(()) +} + +fn reject_partial_large_values(row: &[Option]) -> Result<(), AppError> { + if row.iter().flatten().any(|value| { + value + .to_ascii_uppercase() + .starts_with(MYSQL_PARTIAL_LARGE_VALUE_PREFIX) + }) { + return Err(AppError::invalid( + "mysql_partial_large_value_rejected", + "Partial large-value previews cannot be written back", + )); + } + Ok(()) +} + +fn build_result_grid_insert( + table: &str, + headers: &[MysqlResultGridHeader], + row: &[Option], +) -> Result { + let mut columns = Vec::new(); + let mut values = Vec::new(); + for (header, value) in headers[1..].iter().zip(&row[1..]) { + if is_generated_placeholder(value.as_deref()) { + if header.auto_increment { + continue; + } + return Err(invalid_grid( + "The generated-value placeholder is only valid for auto-increment columns", + )); + } + columns.push(quote_identifier(&header.name)?); + values.push(serialize_assignment_value(header, value.as_deref())?); + } + if columns.is_empty() { + return Ok(format!("INSERT INTO {table} () VALUES ()")); + } + Ok(format!( + "INSERT INTO {table} ({}) VALUES ({})", + columns.join(", "), + values.join(", ") + )) +} + +fn build_result_grid_update( + table: &str, + headers: &[MysqlResultGridHeader], + row: &[Option], + old_row: &[Option], +) -> Result { + let mut assignments = Vec::new(); + for index in 1..headers.len() { + let header = &headers[index]; + let value = &row[index]; + if value == &old_row[index] { + continue; + } + if is_generated_placeholder(value.as_deref()) { + if header.auto_increment { + continue; + } + return Err(invalid_grid( + "The generated-value placeholder is only valid for auto-increment columns", + )); + } + assignments.push(format!( + "{} = {}", + quote_identifier(&header.name)?, + serialize_assignment_value(header, value.as_deref())? + )); + } + if assignments.is_empty() { + return Err(invalid_grid("UPDATE does not contain any changed values")); + } + let (where_clause, uses_primary_key) = build_result_grid_where(headers, old_row)?; + Ok(format!( + "UPDATE {table} SET {} WHERE {where_clause}{}", + assignments.join(", "), + if uses_primary_key { "" } else { " LIMIT 1" } + )) +} + +fn build_result_grid_delete( + table: &str, + headers: &[MysqlResultGridHeader], + old_row: &[Option], +) -> Result { + let (where_clause, uses_primary_key) = build_result_grid_where(headers, old_row)?; + Ok(format!( + "DELETE FROM {table} WHERE {where_clause}{}", + if uses_primary_key { "" } else { " LIMIT 1" } + )) +} + +fn build_result_grid_where( + headers: &[MysqlResultGridHeader], + old_row: &[Option], +) -> Result<(String, bool), AppError> { + let primary_indexes = (1..headers.len()) + .filter(|index| headers[*index].primary_key) + .collect::>(); + let uses_primary_key = !primary_indexes.is_empty(); + let indexes = if uses_primary_key { + primary_indexes + } else { + (1..headers.len()).collect() + }; + let predicates = indexes + .into_iter() + .map(|index| { + let header = &headers[index]; + let value = &old_row[index]; + reject_where_placeholder(value.as_deref())?; + let column = quote_identifier(&header.name)?; + if value.is_none() { + Ok(format!("{column} IS NULL")) + } else { + Ok(format!( + "{column} = {}", + serialize_mysql_value(header, value.as_deref())? + )) + } + }) + .collect::, AppError>>()?; + Ok((predicates.join(" AND "), uses_primary_key)) +} + +fn validate_copy_headers(headers: &[MysqlResultGridHeader]) -> Result<(), AppError> { + if headers.len() < 2 { + return Err(invalid_grid( + "Result-grid headers must include the row-number column and at least one data column", + )); + } + for header in &headers[1..] { + quote_identifier(&header.name) + .map_err(|error| invalid_grid(format!("Invalid result-grid column: {error}")))?; + } + Ok(()) +} + +fn validate_copy_operations( + table: &MysqlQualifiedName, + headers: &[MysqlResultGridHeader], + operations: &[MysqlResultGridCopyOperation], +) -> Result<(), AppError> { + quote_qualified_name(table)?; + validate_copy_headers(headers)?; + if operations.is_empty() { + return Err(invalid_grid("At least one copy operation is required")); + } + for operation in operations { + require_row_length("dataList", &operation.data_list, headers.len())?; + reject_partial_large_values(&operation.data_list)?; + if operation.select_cols.is_empty() { + return Err(invalid_grid("At least one result column must be selected")); + } + let mut selected = operation.select_cols.clone(); + selected.sort_unstable(); + if selected.windows(2).any(|indexes| indexes[0] == indexes[1]) { + return Err(invalid_grid("Selected result columns must be unique")); + } + for index in selected { + validate_selected_index(index, headers.len())?; + } + } + Ok(()) +} + +fn validate_selected_index(index: usize, header_count: usize) -> Result<(), AppError> { + if index == 0 || index >= header_count { + return Err(invalid_grid( + "Selected result columns cannot include the row number or exceed the header list", + )); + } + Ok(()) +} + +fn build_copy_insert( + table: &str, + headers: &[MysqlResultGridHeader], + operation: &MysqlResultGridCopyOperation, +) -> Result { + let mut columns = Vec::new(); + let mut values = Vec::new(); + for index in &operation.select_cols { + let header = &headers[*index]; + let value = operation.data_list[*index].as_deref(); + if is_generated_placeholder(value) { + if header.auto_increment { + continue; + } + return Err(invalid_grid( + "The generated-value placeholder is only valid for auto-increment columns", + )); + } + columns.push(quote_identifier(&header.name)?); + values.push(serialize_assignment_value(header, value)?); + } + if columns.is_empty() { + return Ok(format!("INSERT INTO {table} () VALUES ()")); + } + Ok(format!( + "INSERT INTO {table} ({}) VALUES ({})", + columns.join(", "), + values.join(", ") + )) +} + +fn build_copy_update( + table: &str, + headers: &[MysqlResultGridHeader], + operation: &MysqlResultGridCopyOperation, +) -> Result { + let assignments = operation + .select_cols + .iter() + .map(|index| { + let header = &headers[*index]; + let value = operation.data_list[*index].as_deref(); + if is_generated_placeholder(value) { + return Err(invalid_grid( + "The generated-value placeholder cannot be copied into an UPDATE", + )); + } + Ok(format!( + "{} = {}", + quote_identifier(&header.name)?, + serialize_assignment_value(header, value)? + )) + }) + .collect::, AppError>>()?; + let (where_clause, uses_primary_key) = build_result_grid_where(headers, &operation.data_list)?; + Ok(format!( + "UPDATE {table} SET {} WHERE {where_clause}{}", + assignments.join(", "), + if uses_primary_key { "" } else { " LIMIT 1" } + )) +} + +fn build_copy_where( + headers: &[MysqlResultGridHeader], + operations: &[MysqlResultGridCopyOperation], +) -> Result { + let same_single_column = operations + .first() + .and_then(|operation| operation.select_cols.first().copied()) + .filter(|_| { + operations.iter().all(|operation| { + operation.select_cols.len() == 1 + && operation.select_cols.first() == operations[0].select_cols.first() + }) + }); + if let Some(index) = same_single_column { + let header = &headers[index]; + let column = quote_identifier(&header.name)?; + let mut values = Vec::>::new(); + for operation in operations { + let value = operation.data_list[index] + .as_deref() + .map(|value| serialize_mysql_value(header, Some(value))) + .transpose()?; + if !values.contains(&value) { + values.push(value); + } + } + if values.len() == 1 { + return values[0].as_ref().map_or_else( + || Ok(format!("{column} IS NULL")), + |value| { + if is_result_grid_string(header) { + Ok(format!("{column} LIKE {value}")) + } else { + Ok(format!("{column} = {value}")) + } + }, + ); + } + let mut predicates = Vec::new(); + if values.iter().any(Option::is_none) { + predicates.push(format!("{column} IS NULL")); + } + let non_null = values.into_iter().flatten().collect::>(); + if !non_null.is_empty() { + predicates.push(format!("{column} IN ({})", non_null.join(", "))); + } + return Ok(predicates.join(" OR ")); + } + + operations + .iter() + .map(|operation| { + let predicates = operation + .select_cols + .iter() + .map(|index| { + let header = &headers[*index]; + let column = quote_identifier(&header.name)?; + operation.data_list[*index].as_deref().map_or_else( + || Ok(format!("{column} IS NULL")), + |value| { + let value = serialize_mysql_value(header, Some(value))?; + Ok(if is_result_grid_string(header) { + format!("{column} LIKE {value}") + } else { + format!("{column} = {value}") + }) + }, + ) + }) + .collect::, AppError>>()?; + Ok(format!("({})", predicates.join(" AND "))) + }) + .collect::, AppError>>() + .map(|predicates| predicates.join(" OR ")) +} + +fn is_result_grid_string(header: &MysqlResultGridHeader) -> bool { + supports_charset(base_type_name(&result_grid_type_name(header))) +} + +fn reject_where_placeholder(value: Option<&str>) -> Result<(), AppError> { + if value.is_some_and(|value| { + value == MYSQL_RESULT_DEFAULT_PLACEHOLDER || value == MYSQL_RESULT_GENERATED_PLACEHOLDER + }) { + return Err(invalid_grid( + "Write placeholders cannot be used to identify an existing row", + )); + } + Ok(()) +} + +fn is_generated_placeholder(value: Option<&str>) -> bool { + value == Some(MYSQL_RESULT_GENERATED_PLACEHOLDER) +} + +fn serialize_assignment_value( + header: &MysqlResultGridHeader, + value: Option<&str>, +) -> Result { + if value == Some(MYSQL_RESULT_DEFAULT_PLACEHOLDER) { + return Ok("DEFAULT".to_owned()); + } + serialize_mysql_value(header, value) +} + +fn serialize_mysql_value( + header: &MysqlResultGridHeader, + value: Option<&str>, +) -> Result { + let Some(value) = value else { + return Ok("NULL".to_owned()); + }; + if value == MYSQL_RESULT_GENERATED_PLACEHOLDER { + return Err(invalid_grid( + "The generated-value placeholder cannot be serialized as data", + )); + } + let type_name = result_grid_type_name(header); + serialize_typed_literal(&type_name, value).map_err(|error| { + invalid_grid(format!( + "Column {} has an invalid value: {error}", + header.name + )) + }) +} + +fn result_grid_type_name(header: &MysqlResultGridHeader) -> String { + let source = if header.column_type.trim().is_empty() { + &header.data_type + } else { + &header.column_type + }; + normalize_type_name(source) +} + +fn serialize_typed_literal(type_name: &str, value: &str) -> Result { + let base_type = base_type_name(type_name); + let trimmed = value.trim(); + if is_integer_type(base_type) || base_type == "BIT" { + if !is_integer_literal(trimmed) { + return Err(invalid_ddl("Expected an integer literal")); + } + if (type_name.contains("UNSIGNED") || base_type == "BIT") && trimmed.starts_with('-') { + return Err(invalid_ddl( + "Unsigned columns cannot contain negative values", + )); + } + return Ok(trimmed.to_owned()); + } + if matches!(base_type, "DECIMAL" | "NUMERIC") { + if !is_decimal_literal(trimmed, false) { + return Err(invalid_ddl("Expected a decimal literal")); + } + if type_name.contains("UNSIGNED") && trimmed.starts_with('-') { + return Err(invalid_ddl( + "Unsigned columns cannot contain negative values", + )); + } + return Ok(trimmed.to_owned()); + } + if matches!(base_type, "FLOAT" | "DOUBLE" | "REAL") { + if !is_decimal_literal(trimmed, true) { + return Err(invalid_ddl("Expected a finite numeric literal")); + } + if type_name.contains("UNSIGNED") && trimmed.starts_with('-') { + return Err(invalid_ddl( + "Unsigned columns cannot contain negative values", + )); + } + return Ok(trimmed.to_owned()); + } + if matches!(base_type, "BOOL" | "BOOLEAN") { + return match trimmed.to_ascii_lowercase().as_str() { + "true" | "1" => Ok("TRUE".to_owned()), + "false" | "0" => Ok("FALSE".to_owned()), + _ => Err(invalid_ddl("Expected a boolean literal")), + }; + } + if is_binary_type(base_type) { + let bytes = STANDARD + .decode(value) + .map_err(|_| invalid_ddl("Expected a Base64-encoded binary value"))?; + let mut literal = String::with_capacity(bytes.len() * 2 + 3); + literal.push_str("X'"); + for byte in bytes { + write!(&mut literal, "{byte:02X}").expect("writing to a String cannot fail"); + } + literal.push('\''); + return Ok(literal); + } + Ok(quote_mysql_string(value)) +} + +fn is_integer_literal(value: &str) -> bool { + let digits = value.strip_prefix(['+', '-']).unwrap_or(value); + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn is_decimal_literal(value: &str, allow_exponent: bool) -> bool { + let value = value.strip_prefix(['+', '-']).unwrap_or(value); + if value.is_empty() { + return false; + } + let (mantissa, exponent) = if allow_exponent { + value.find(['e', 'E']).map_or((value, None), |index| { + (&value[..index], Some(&value[index + 1..])) + }) + } else { + (value, None) + }; + if exponent.is_some_and(|exponent| !is_integer_literal(exponent)) { + return false; + } + let mut parts = mantissa.split('.'); + let whole = parts.next().unwrap_or_default(); + let fraction = parts.next(); + if parts.next().is_some() { + return false; + } + let whole_valid = whole.bytes().all(|byte| byte.is_ascii_digit()); + let fraction_valid = + fraction.is_none_or(|fraction| fraction.bytes().all(|byte| byte.is_ascii_digit())); + whole_valid + && fraction_valid + && (!whole.is_empty() || fraction.is_some_and(|fraction| !fraction.is_empty())) +} + +fn build_create_namespace(database: &MysqlDatabaseDefinition) -> Result { + let mut sql = format!( + "CREATE DATABASE {}{}", + if database.if_not_exists { + "IF NOT EXISTS " + } else { + "" + }, + quote_identifier(&database.name)? + ); + if let Some(charset) = database.charset.as_deref() { + sql.push_str(" DEFAULT CHARACTER SET = "); + sql.push_str(&validate_option_token("charset", charset)?); + } + if let Some(collation) = database.collation.as_deref() { + sql.push_str(" COLLATE = "); + sql.push_str(&validate_option_token("collation", collation)?); + } + Ok(sql) +} + +fn build_drop_namespace(name: &str, if_exists: bool) -> Result { + Ok(format!( + "DROP DATABASE {}{}", + if if_exists { "IF EXISTS " } else { "" }, + quote_identifier(name)? + )) +} + +fn build_column_definition(column: &MysqlColumnDefinition) -> Result { + let name = quote_identifier(&column.name)?; + let normalized_type = normalize_column_type(&column.type_name, column.unsigned)?; + let base_type = base_type_name(&normalized_type); + validate_column_shape(column, base_type)?; + let mut sql = format!( + "{name} {}", + render_column_type(column, &normalized_type, base_type)? + ); + if let Some(charset) = column.charset.as_deref() { + sql.push_str(" CHARACTER SET "); + sql.push_str(&validate_option_token("charset", charset)?); + } + if let Some(collation) = column.collation.as_deref() { + sql.push_str(" COLLATE "); + sql.push_str(&validate_option_token("collation", collation)?); + } + sql.push_str(if column.nullable { + " NULL" + } else { + " NOT NULL" + }); + if let Some(default_value) = column.default_value.as_deref() { + sql.push_str(" DEFAULT "); + sql.push_str(&build_column_default(&normalized_type, default_value)?); + } + if column.on_update_current_timestamp { + sql.push_str(" ON UPDATE CURRENT_TIMESTAMP"); + } + if column.auto_increment { + sql.push_str(" AUTO_INCREMENT"); + } + if let Some(comment) = column.comment.as_deref() { + validate_comment(comment)?; + sql.push_str(" COMMENT "); + sql.push_str("e_mysql_string(comment)); + } + Ok(sql) +} + +fn normalize_column_type(type_name: &str, unsigned: bool) -> Result { + let normalized = normalize_type_name(type_name); + if normalized.is_empty() || normalized.contains(['(', ')', ',', '\'', '"', '`', ';']) { + return Err(invalid_ddl( + "Column type must be a structured MySQL type name", + )); + } + let base = base_type_name(&normalized); + if !MYSQL_BASE_COLUMN_TYPES.contains(&base) { + return Err(invalid_ddl(format!( + "Unsupported MySQL column type: {type_name}" + ))); + } + let already_unsigned = normalized.ends_with(" UNSIGNED"); + if normalized.split_whitespace().count() > usize::from(already_unsigned) + 1 { + return Err(invalid_ddl("Column type contains unsupported modifiers")); + } + if (unsigned || already_unsigned) && !supports_unsigned(base) { + return Err(invalid_ddl(format!("{base} does not support UNSIGNED"))); + } + Ok(if unsigned && !already_unsigned { + format!("{base} UNSIGNED") + } else { + normalized + }) +} + +fn validate_column_shape(column: &MysqlColumnDefinition, base_type: &str) -> Result<(), AppError> { + if matches!(base_type, "CHAR" | "VARCHAR" | "BINARY" | "VARBINARY") + && column.length.is_none_or(|length| length == 0) + { + return Err(invalid_ddl(format!( + "{} requires a positive length", + column.type_name + ))); + } + if base_type == "BIT" + && column + .length + .is_some_and(|length| !(1..=64).contains(&length)) + { + return Err(invalid_ddl("BIT length must be between 1 and 64")); + } + if matches!(base_type, "DECIMAL" | "NUMERIC") { + if column + .length + .is_some_and(|precision| !(1..=65).contains(&precision)) + { + return Err(invalid_ddl("DECIMAL precision must be between 1 and 65")); + } + if column.scale.is_some_and(|scale| scale > 30) { + return Err(invalid_ddl("DECIMAL scale must be at most 30")); + } + if column + .length + .zip(column.scale) + .is_some_and(|(precision, scale)| scale > precision) + { + return Err(invalid_ddl("DECIMAL scale cannot exceed its precision")); + } + } else if column.scale.is_some() { + return Err(invalid_ddl( + "Scale is only supported for DECIMAL and NUMERIC", + )); + } + if is_temporal_fraction_type(base_type) && column.length.is_some_and(|precision| precision > 6) + { + return Err(invalid_ddl( + "Temporal fractional precision must be at most 6", + )); + } + if !matches!( + base_type, + "BIT" | "CHAR" | "VARCHAR" | "BINARY" | "VARBINARY" | "DECIMAL" | "NUMERIC" + ) && !is_temporal_fraction_type(base_type) + && column.length.is_some() + { + return Err(invalid_ddl(format!( + "{base_type} does not support a length" + ))); + } + if matches!(base_type, "ENUM" | "SET") && column.enum_values.is_empty() { + return Err(invalid_ddl(format!( + "{base_type} requires at least one value" + ))); + } + if !matches!(base_type, "ENUM" | "SET") && !column.enum_values.is_empty() { + return Err(invalid_ddl("enumValues are only valid for ENUM and SET")); + } + if column.auto_increment && !is_integer_type(base_type) { + return Err(invalid_ddl("AUTO_INCREMENT requires an integer column")); + } + if column.on_update_current_timestamp && !matches!(base_type, "DATETIME" | "TIMESTAMP") { + return Err(invalid_ddl( + "ON UPDATE CURRENT_TIMESTAMP requires DATETIME or TIMESTAMP", + )); + } + if column.charset.is_some() && !supports_charset(base_type) { + return Err(invalid_ddl(format!( + "{base_type} does not support a charset" + ))); + } + if column.collation.is_some() && !supports_charset(base_type) { + return Err(invalid_ddl(format!( + "{base_type} does not support a collation" + ))); + } + Ok(()) +} + +fn render_column_type( + column: &MysqlColumnDefinition, + normalized_type: &str, + base_type: &str, +) -> Result { + let suffix = if normalized_type.ends_with(" UNSIGNED") { + " UNSIGNED" + } else { + "" + }; + if matches!(base_type, "ENUM" | "SET") { + let values = column + .enum_values + .iter() + .map(|value| quote_mysql_string(value)) + .collect::>() + .join(", "); + return Ok(format!("{base_type}({values})")); + } + let dimensions = match (column.length, column.scale) { + (Some(length), Some(scale)) => format!("({length},{scale})"), + (Some(length), None) => format!("({length})"), + (None, Some(scale)) if matches!(base_type, "DECIMAL" | "NUMERIC") => { + format!("(10,{scale})") + } + (None, Some(_)) => return Err(invalid_ddl("Scale requires a precision")), + (None, None) => String::new(), + }; + Ok(format!("{base_type}{dimensions}{suffix}")) +} + +fn build_column_default(type_name: &str, default_value: &str) -> Result { + let trimmed = default_value.trim(); + if trimmed.eq_ignore_ascii_case("EMPTY_STRING") { + return Ok("''".to_owned()); + } + if trimmed.eq_ignore_ascii_case("NULL") { + return Ok("NULL".to_owned()); + } + if trimmed.eq_ignore_ascii_case("CURRENT_TIMESTAMP") || trimmed.eq_ignore_ascii_case("NOW()") { + if matches!(base_type_name(type_name), "DATETIME" | "TIMESTAMP") { + return Ok("CURRENT_TIMESTAMP".to_owned()); + } + return Err(invalid_ddl( + "CURRENT_TIMESTAMP is only valid for DATETIME and TIMESTAMP defaults", + )); + } + serialize_typed_literal(type_name, default_value) +} + +fn build_index_definition(index: &MysqlIndexDefinition) -> Result { + if index.columns.is_empty() { + return Err(invalid_ddl("An index must contain at least one column")); + } + let keyword = match index.kind { + MysqlIndexKind::Primary => "PRIMARY KEY", + MysqlIndexKind::Normal => "INDEX", + MysqlIndexKind::Unique => "UNIQUE INDEX", + MysqlIndexKind::Fulltext => "FULLTEXT INDEX", + MysqlIndexKind::Spatial => "SPATIAL INDEX", + }; + let name = if index.kind == MysqlIndexKind::Primary { + String::new() + } else { + let name = index + .name + .as_deref() + .ok_or_else(|| invalid_ddl("Non-primary indexes require a name"))?; + format!(" {}", quote_identifier(name)?) + }; + let columns = index + .columns + .iter() + .map(|column| { + let mut sql = quote_identifier(&column.name)?; + if let Some(prefix_length) = column.prefix_length { + if prefix_length == 0 { + return Err(invalid_ddl("Index prefix length must be greater than zero")); + } + write!(&mut sql, "({prefix_length})").expect("writing to a String cannot fail"); + } + if let Some(order) = &column.order { + sql.push(' '); + sql.push_str(match order { + MysqlSortOrder::Asc => "ASC", + MysqlSortOrder::Desc => "DESC", + }); + } + Ok(sql) + }) + .collect::, AppError>>()?; + let mut sql = format!("{keyword}{name} ({})", columns.join(", ")); + if let Some(method) = &index.method { + if matches!( + index.kind, + MysqlIndexKind::Fulltext | MysqlIndexKind::Spatial + ) { + return Err(invalid_ddl( + "FULLTEXT and SPATIAL indexes do not accept an index method", + )); + } + sql.push_str(match method { + MysqlIndexMethod::Btree => " USING BTREE", + MysqlIndexMethod::Hash => " USING HASH", + }); + } + if let Some(comment) = index.comment.as_deref() { + validate_comment(comment)?; + sql.push_str(" COMMENT "); + sql.push_str("e_mysql_string(comment)); + } + Ok(sql) +} + +fn build_column_alter(change: &MysqlColumnAlter) -> Result { + match change { + MysqlColumnAlter::Add { column, position } => Ok(format!( + "ADD COLUMN {}{}", + build_column_definition(column)?, + build_column_position(position.as_ref())? + )), + MysqlColumnAlter::Modify { + old_name, + column, + position, + } => { + let definition = build_column_definition(column)?; + let operation = if old_name == &column.name { + format!("MODIFY COLUMN {definition}") + } else { + format!("CHANGE COLUMN {} {definition}", quote_identifier(old_name)?) + }; + Ok(format!( + "{operation}{}", + build_column_position(position.as_ref())? + )) + } + MysqlColumnAlter::Delete { name } => Ok(format!("DROP COLUMN {}", quote_identifier(name)?)), + } +} + +fn build_column_position(position: Option<&MysqlColumnPosition>) -> Result { + match position { + None => Ok(String::new()), + Some(MysqlColumnPosition::First) => Ok(" FIRST".to_owned()), + Some(MysqlColumnPosition::After(column)) => { + Ok(format!(" AFTER {}", quote_identifier(column)?)) + } + } +} + +fn build_index_alter(change: &MysqlIndexAlter) -> Result, AppError> { + match change { + MysqlIndexAlter::Add { index } => { + Ok(vec![format!("ADD {}", build_index_definition(index)?)]) + } + MysqlIndexAlter::Modify { + old_kind, + old_name, + index, + } => Ok(vec![ + build_drop_index_clause(*old_kind, old_name.as_deref())?, + format!("ADD {}", build_index_definition(index)?), + ]), + MysqlIndexAlter::Delete { kind, name } => { + Ok(vec![build_drop_index_clause(*kind, name.as_deref())?]) + } + } +} + +fn build_drop_index_clause(kind: MysqlIndexKind, name: Option<&str>) -> Result { + if kind == MysqlIndexKind::Primary { + Ok("DROP PRIMARY KEY".to_owned()) + } else { + let name = name.ok_or_else(|| invalid_ddl("Dropping an index requires its name"))?; + Ok(format!("DROP INDEX {}", quote_identifier(name)?)) + } +} + +fn append_table_options( + sql: &mut String, + engine: Option<&str>, + charset: Option<&str>, + collation: Option<&str>, + comment: Option<&str>, + auto_increment: Option, +) -> Result<(), AppError> { + if let Some(engine) = engine { + sql.push_str(" ENGINE = "); + sql.push_str(&validate_option_token("engine", engine)?); + } + if let Some(charset) = charset { + sql.push_str(" DEFAULT CHARACTER SET = "); + sql.push_str(&validate_option_token("charset", charset)?); + } + if let Some(collation) = collation { + sql.push_str(" COLLATE = "); + sql.push_str(&validate_option_token("collation", collation)?); + } + if let Some(auto_increment) = auto_increment { + if auto_increment == 0 { + return Err(invalid_ddl("AUTO_INCREMENT must be greater than zero")); + } + write!(sql, " AUTO_INCREMENT = {auto_increment}").expect("writing to a String cannot fail"); + } + if let Some(comment) = comment { + validate_comment(comment)?; + sql.push_str(" COMMENT = "); + sql.push_str("e_mysql_string(comment)); + } + Ok(()) +} + +fn validate_view_body(body: &str) -> Result { + if body.trim().is_empty() { + return Err(invalid_ddl("View body cannot be empty")); + } + if body.len() > MAX_VIEW_BODY_BYTES { + return Err(invalid_ddl(format!( + "View body exceeds the {MAX_VIEW_BODY_BYTES}-byte limit" + ))); + } + if body.contains('\0') { + return Err(invalid_ddl("View body cannot contain NUL bytes")); + } + let statements = crate::native_mysql::split_mysql_script(body)?; + let [statement] = statements.as_slice() else { + return Err(invalid_ddl( + "View body must contain exactly one SELECT or CTE statement", + )); + }; + if !crate::native_mysql::is_native_read_candidate(statement)? { + return Err(invalid_ddl("View body must be a SELECT or CTE statement")); + } + Ok(statement.clone()) +} + +fn validate_view_definer(definer: &MysqlViewDefiner) -> Result<(), AppError> { + for (label, value, max_chars) in [ + ("user", definer.user.as_str(), 32), + ("host", definer.host.as_str(), 255), + ] { + if value.is_empty() + || value.chars().count() > max_chars + || value.chars().any(char::is_control) + { + return Err(invalid_ddl(format!("Invalid MySQL view definer {label}"))); + } + } + Ok(()) +} + +fn validate_comment(comment: &str) -> Result<(), AppError> { + if comment.len() > MAX_COMMENT_BYTES { + return Err(invalid_ddl(format!( + "Comment exceeds the {MAX_COMMENT_BYTES}-byte limit" + ))); + } + if comment.contains('\0') { + return Err(invalid_ddl("Comments cannot contain NUL bytes")); + } + Ok(()) +} + +fn quote_qualified_name(name: &MysqlQualifiedName) -> Result { + let database = non_empty_option(name.database_name.as_deref()); + let schema = non_empty_option(name.schema_name.as_deref()); + if database + .zip(schema) + .is_some_and(|(database, schema)| database != schema) + { + return Err(invalid_ddl( + "MySQL databaseName and schemaName must identify the same namespace", + )); + } + let namespace = database.or(schema); + let object = quote_identifier(&name.name)?; + namespace.map_or(Ok(object.clone()), |namespace| { + Ok(format!("{}.{object}", quote_identifier(namespace)?)) + }) +} + +fn non_empty_option(value: Option<&str>) -> Option<&str> { + value.filter(|value| !value.trim().is_empty()) +} + +fn quote_identifier(identifier: &str) -> Result { + let identifier = identifier.trim(); + if identifier.is_empty() { + return Err(invalid_ddl("MySQL identifiers cannot be empty")); + } + if identifier.chars().count() > MAX_IDENTIFIER_CHARS { + return Err(invalid_ddl(format!( + "MySQL identifiers cannot exceed {MAX_IDENTIFIER_CHARS} characters" + ))); + } + if identifier.chars().any(char::is_control) { + return Err(invalid_ddl( + "MySQL identifiers cannot contain control characters", + )); + } + Ok(format!("`{}`", identifier.replace('`', "``"))) +} + +fn quote_mysql_string(value: &str) -> String { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('\''); + for character in value.chars() { + match character { + '\0' => escaped.push_str("\\0"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + '\u{0008}' => escaped.push_str("\\b"), + '\u{001a}' => escaped.push_str("\\Z"), + '\\' => escaped.push_str("\\\\"), + '\'' => escaped.push_str("''"), + _ => escaped.push(character), + } + } + escaped.push('\''); + escaped +} + +fn validate_option_token(label: &str, value: &str) -> Result { + let value = value.trim(); + if value.is_empty() + || value.len() > 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(invalid_ddl(format!("Invalid MySQL {label}"))); + } + Ok(value.to_owned()) +} + +fn normalize_type_name(type_name: &str) -> String { + type_name + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_uppercase() +} + +fn base_type_name(type_name: &str) -> &str { + type_name.split(['(', ' ']).next().unwrap_or_default() +} + +fn is_integer_type(type_name: &str) -> bool { + matches!( + type_name, + "TINYINT" | "SMALLINT" | "MEDIUMINT" | "INT" | "INTEGER" | "BIGINT" + ) +} + +fn supports_unsigned(type_name: &str) -> bool { + is_integer_type(type_name) + || matches!( + type_name, + "DECIMAL" | "NUMERIC" | "FLOAT" | "DOUBLE" | "REAL" + ) +} + +fn supports_charset(type_name: &str) -> bool { + matches!( + type_name, + "CHAR" | "VARCHAR" | "TINYTEXT" | "TEXT" | "MEDIUMTEXT" | "LONGTEXT" | "ENUM" | "SET" + ) +} + +fn is_binary_type(type_name: &str) -> bool { + matches!( + type_name, + "BINARY" | "VARBINARY" | "TINYBLOB" | "BLOB" | "MEDIUMBLOB" | "LONGBLOB" + ) +} + +fn is_temporal_fraction_type(type_name: &str) -> bool { + matches!(type_name, "TIME" | "DATETIME" | "TIMESTAMP") +} + +fn editor_column_type(type_name: &str) -> MysqlEditorColumnType { + let base_type = base_type_name(type_name); + MysqlEditorColumnType { + type_name: type_name.to_owned(), + support_length: matches!( + base_type, + "BIT" + | "DECIMAL" + | "NUMERIC" + | "FLOAT" + | "DOUBLE" + | "REAL" + | "TIME" + | "DATETIME" + | "TIMESTAMP" + | "CHAR" + | "VARCHAR" + | "BINARY" + | "VARBINARY" + ), + support_scale: matches!( + base_type, + "DECIMAL" | "NUMERIC" | "FLOAT" | "DOUBLE" | "REAL" + ), + support_nullable: true, + support_auto_increment: is_integer_type(base_type), + support_charset: supports_charset(base_type), + support_collation: supports_charset(base_type), + support_comments: true, + support_default_value: !matches!( + base_type, + "TINYBLOB" + | "BLOB" + | "MEDIUMBLOB" + | "LONGBLOB" + | "TINYTEXT" + | "TEXT" + | "MEDIUMTEXT" + | "LONGTEXT" + | "GEOMETRY" + | "POINT" + | "LINESTRING" + | "POLYGON" + | "MULTIPOINT" + | "MULTILINESTRING" + | "MULTIPOLYGON" + | "GEOMETRYCOLLECTION" + | "JSON" + ), + support_extent: matches!(base_type, "ENUM" | "SET"), + support_value: matches!(base_type, "ENUM" | "SET"), + support_unit: false, + support_on_update_current_timestamp: matches!(base_type, "DATETIME" | "TIMESTAMP"), + } +} + +const MYSQL_BASE_COLUMN_TYPES: &[&str] = &[ + "BIT", + "TINYINT", + "SMALLINT", + "MEDIUMINT", + "INT", + "INTEGER", + "BIGINT", + "DECIMAL", + "NUMERIC", + "FLOAT", + "DOUBLE", + "REAL", + "BOOL", + "BOOLEAN", + "DATE", + "DATETIME", + "TIMESTAMP", + "TIME", + "YEAR", + "CHAR", + "VARCHAR", + "BINARY", + "VARBINARY", + "TINYBLOB", + "BLOB", + "MEDIUMBLOB", + "LONGBLOB", + "TINYTEXT", + "TEXT", + "MEDIUMTEXT", + "LONGTEXT", + "ENUM", + "SET", + "JSON", + "GEOMETRY", + "POINT", + "LINESTRING", + "POLYGON", + "MULTIPOINT", + "MULTILINESTRING", + "MULTIPOLYGON", + "GEOMETRYCOLLECTION", +]; + +const MYSQL_COLUMN_TYPES: &[&str] = &[ + "BIT", + "TINYINT", + "TINYINT UNSIGNED", + "SMALLINT", + "SMALLINT UNSIGNED", + "MEDIUMINT", + "MEDIUMINT UNSIGNED", + "INT", + "INT UNSIGNED", + "BIGINT", + "BIGINT UNSIGNED", + "DECIMAL", + "DECIMAL UNSIGNED", + "NUMERIC", + "NUMERIC UNSIGNED", + "FLOAT", + "FLOAT UNSIGNED", + "DOUBLE", + "DOUBLE UNSIGNED", + "REAL", + "REAL UNSIGNED", + "BOOL", + "BOOLEAN", + "DATE", + "DATETIME", + "TIMESTAMP", + "TIME", + "YEAR", + "CHAR", + "VARCHAR", + "BINARY", + "VARBINARY", + "TINYBLOB", + "BLOB", + "MEDIUMBLOB", + "LONGBLOB", + "TINYTEXT", + "TEXT", + "MEDIUMTEXT", + "LONGTEXT", + "ENUM", + "SET", + "JSON", + "GEOMETRY", + "POINT", + "LINESTRING", + "POLYGON", + "MULTIPOINT", + "MULTILINESTRING", + "MULTIPOLYGON", + "GEOMETRYCOLLECTION", +]; + +const MYSQL_CHARSETS: &[(&str, &str)] = &[ + ("utf8mb4", "utf8mb4_0900_ai_ci"), + ("utf8mb3", "utf8mb3_general_ci"), + ("latin1", "latin1_swedish_ci"), + ("ascii", "ascii_general_ci"), + ("binary", "binary"), + ("gbk", "gbk_chinese_ci"), + ("gb18030", "gb18030_chinese_ci"), +]; + +const MYSQL_COLLATIONS: &[&str] = &[ + "utf8mb4_0900_ai_ci", + "utf8mb4_0900_as_ci", + "utf8mb4_0900_bin", + "utf8mb4_general_ci", + "utf8mb4_unicode_ci", + "utf8mb4_bin", + "utf8mb3_general_ci", + "latin1_swedish_ci", + "latin1_general_ci", + "ascii_general_ci", + "binary", + "gbk_chinese_ci", + "gb18030_chinese_ci", +]; + +#[cfg(test)] +mod tests { + use super::*; + + fn table(name: &str) -> MysqlQualifiedName { + MysqlQualifiedName::in_database("app`db", name) + } + + fn row_header( + name: &str, + column_type: &str, + primary_key: bool, + auto_increment: bool, + ) -> MysqlResultGridHeader { + MysqlResultGridHeader { + name: name.to_owned(), + column_type: column_type.to_owned(), + data_type: column_type.to_owned(), + primary_key, + auto_increment, + } + } + + fn headers(with_primary_key: bool) -> Vec { + vec![ + row_header("#", "INTEGER", false, false), + row_header("id", "BIGINT UNSIGNED", with_primary_key, true), + row_header("display`name", "VARCHAR", false, false), + row_header("score", "DECIMAL", false, false), + row_header("note", "TEXT", false, false), + ] + } + + fn column(name: &str, type_name: &str) -> MysqlColumnDefinition { + MysqlColumnDefinition { + name: name.to_owned(), + type_name: type_name.to_owned(), + length: None, + scale: None, + unsigned: false, + nullable: false, + default_value: None, + auto_increment: false, + charset: None, + collation: None, + comment: None, + enum_values: Vec::new(), + on_update_current_timestamp: false, + } + } + + fn index(kind: MysqlIndexKind, name: Option<&str>, columns: &[&str]) -> MysqlIndexDefinition { + MysqlIndexDefinition { + kind, + name: name.map(str::to_owned), + columns: columns + .iter() + .map(|name| MysqlIndexColumn { + name: (*name).to_owned(), + prefix_length: None, + order: None, + }) + .collect(), + method: None, + comment: None, + } + } + + #[test] + fn qualified_names_escape_every_identifier_segment() { + let name = MysqlQualifiedName { + database_name: Some("sales`prod".to_owned()), + schema_name: Some("sales`prod".to_owned()), + name: "order`line".to_owned(), + }; + assert_eq!( + quote_qualified_name(&name).expect("qualified name"), + "`sales``prod`.`order``line`" + ); + + let mismatched = MysqlQualifiedName { + database_name: Some("one".to_owned()), + schema_name: Some("two".to_owned()), + name: "items".to_owned(), + }; + assert!(quote_qualified_name(&mismatched).is_err()); + } + + #[test] + fn grid_insert_ignores_row_number_and_generated_value() { + let operation = MysqlResultGridOperation { + operation_type: MysqlResultGridOperationType::Create, + data_list: vec![ + Some("99".to_owned()), + Some(MYSQL_RESULT_GENERATED_PLACEHOLDER.to_owned()), + Some("O'Reilly\\notes\nnext".to_owned()), + Some(MYSQL_RESULT_DEFAULT_PLACEHOLDER.to_owned()), + None, + ], + old_data_list: Vec::new(), + }; + let sql = build_mysql_result_grid_sql(&table("people"), &headers(true), &operation) + .expect("insert SQL"); + assert_eq!( + sql, + "INSERT INTO `app``db`.`people` (`display``name`, `score`, `note`) VALUES ('O''Reilly\\\\notes\\nnext', DEFAULT, NULL)" + ); + } + + #[test] + fn grid_insert_with_only_generated_column_uses_empty_row_syntax() { + let headers = vec![ + row_header("#", "INTEGER", false, false), + row_header("id", "BIGINT", true, true), + ]; + let operation = MysqlResultGridOperation { + operation_type: MysqlResultGridOperationType::Create, + data_list: vec![ + Some("1".to_owned()), + Some(MYSQL_RESULT_GENERATED_PLACEHOLDER.to_owned()), + ], + old_data_list: Vec::new(), + }; + assert_eq!( + build_mysql_result_grid_sql(&table("people"), &headers, &operation) + .expect("insert SQL"), + "INSERT INTO `app``db`.`people` () VALUES ()" + ); + } + + #[test] + fn grid_update_prefers_primary_key_and_omits_limit() { + let old = vec![ + Some("1".to_owned()), + Some("42".to_owned()), + Some("old".to_owned()), + Some("1.5".to_owned()), + None, + ]; + let mut new = old.clone(); + new[2] = Some("new".to_owned()); + new[4] = Some("memo".to_owned()); + let sql = build_mysql_result_grid_sql( + &table("people"), + &headers(true), + &MysqlResultGridOperation { + operation_type: MysqlResultGridOperationType::Update, + data_list: new, + old_data_list: old, + }, + ) + .expect("update SQL"); + assert_eq!( + sql, + "UPDATE `app``db`.`people` SET `display``name` = 'new', `note` = 'memo' WHERE `id` = 42" + ); + } + + #[test] + fn grid_update_without_primary_key_uses_all_old_values_and_limit() { + let old = vec![ + Some("1".to_owned()), + Some("42".to_owned()), + Some("old".to_owned()), + Some("1.5".to_owned()), + None, + ]; + let mut new = old.clone(); + new[2] = Some("new".to_owned()); + let sql = build_mysql_result_grid_sql( + &table("people"), + &headers(false), + &MysqlResultGridOperation { + operation_type: MysqlResultGridOperationType::Update, + data_list: new, + old_data_list: old, + }, + ) + .expect("update SQL"); + assert_eq!( + sql, + "UPDATE `app``db`.`people` SET `display``name` = 'new' WHERE `id` = 42 AND `display``name` = 'old' AND `score` = 1.5 AND `note` IS NULL LIMIT 1" + ); + } + + #[test] + fn grid_delete_without_primary_key_is_single_row_safe() { + let old = vec![ + Some("1".to_owned()), + Some("42".to_owned()), + Some("old".to_owned()), + Some("1.5".to_owned()), + None, + ]; + let sql = build_mysql_result_grid_sql( + &table("people"), + &headers(false), + &MysqlResultGridOperation { + operation_type: MysqlResultGridOperationType::Delete, + data_list: Vec::new(), + old_data_list: old, + }, + ) + .expect("delete SQL"); + assert!(sql.ends_with("`note` IS NULL LIMIT 1")); + } + + #[test] + fn grid_rejects_mismatched_rows_partial_values_and_numeric_injection() { + let mismatched = build_mysql_result_grid_sql( + &table("people"), + &headers(true), + &MysqlResultGridOperation { + operation_type: MysqlResultGridOperationType::Create, + data_list: vec![None], + old_data_list: Vec::new(), + }, + ) + .expect_err("mismatched row"); + assert_eq!(mismatched.api_error().code, "invalid_mysql_result_grid"); + + let partial = build_mysql_result_grid_sql( + &table("people"), + &headers(true), + &MysqlResultGridOperation { + operation_type: MysqlResultGridOperationType::Create, + data_list: vec![ + None, + Some(MYSQL_RESULT_GENERATED_PLACEHOLDER.to_owned()), + Some("CHAT2DB_LARGE_VALUE_PREVIEW:PARTIAL".to_owned()), + Some("1".to_owned()), + None, + ], + old_data_list: Vec::new(), + }, + ) + .expect_err("partial value"); + assert_eq!( + partial.api_error().code, + "mysql_partial_large_value_rejected" + ); + + let injection = build_mysql_result_grid_sql( + &table("people"), + &headers(true), + &MysqlResultGridOperation { + operation_type: MysqlResultGridOperationType::Create, + data_list: vec![ + None, + Some("1 OR 1=1".to_owned()), + Some("safe".to_owned()), + Some("1.0".to_owned()), + None, + ], + old_data_list: Vec::new(), + }, + ) + .expect_err("numeric injection"); + assert_eq!(injection.api_error().code, "invalid_mysql_result_grid"); + } + + #[test] + fn grid_binary_values_are_base64_decoded_to_hex_literals() { + let headers = vec![ + row_header("#", "INTEGER", false, false), + row_header("payload", "BLOB", false, false), + ]; + let operation = MysqlResultGridOperation { + operation_type: MysqlResultGridOperationType::Create, + data_list: vec![None, Some("AP8Q".to_owned())], + old_data_list: Vec::new(), + }; + assert_eq!( + build_mysql_result_grid_sql(&table("files"), &headers, &operation) + .expect("binary insert"), + "INSERT INTO `app``db`.`files` (`payload`) VALUES (X'00FF10')" + ); + + let invalid = MysqlResultGridOperation { + data_list: vec![None, Some("not base64!".to_owned())], + ..operation + }; + assert!(build_mysql_result_grid_sql(&table("files"), &headers, &invalid).is_err()); + } + + #[test] + fn empty_grid_update_is_rejected_without_panicking() { + let row = vec![ + None, + Some("42".to_owned()), + Some("same".to_owned()), + Some("1".to_owned()), + None, + ]; + let error = build_mysql_result_grid_sql( + &table("people"), + &headers(true), + &MysqlResultGridOperation { + operation_type: MysqlResultGridOperationType::Update, + data_list: row.clone(), + old_data_list: row, + }, + ) + .expect_err("no changes"); + assert_eq!(error.api_error().code, "invalid_mysql_result_grid"); + } + + #[test] + fn grid_copy_builds_selected_insert_update_and_where_sql() { + let row = vec![ + Some("1".to_owned()), + Some("42".to_owned()), + Some("O'Reilly".to_owned()), + Some("1.5".to_owned()), + None, + ]; + let insert = MysqlResultGridCopyOperation { + operation_type: MysqlResultGridCopyOperationType::Create, + data_list: row.clone(), + select_cols: vec![2, 3], + }; + assert_eq!( + build_mysql_result_grid_copy_sql(&table("people"), &headers(true), &[insert]) + .expect("copy insert"), + "INSERT INTO `app``db`.`people` (`display``name`, `score`) VALUES ('O''Reilly', 1.5);" + ); + + let update = MysqlResultGridCopyOperation { + operation_type: MysqlResultGridCopyOperationType::UpdateCopy, + data_list: row.clone(), + select_cols: vec![2], + }; + assert_eq!( + build_mysql_result_grid_copy_sql(&table("people"), &headers(true), &[update]) + .expect("copy update"), + "UPDATE `app``db`.`people` SET `display``name` = 'O''Reilly' WHERE `id` = 42;" + ); + + let where_operation = MysqlResultGridCopyOperation { + operation_type: MysqlResultGridCopyOperationType::Where, + data_list: row, + select_cols: vec![2], + }; + assert_eq!( + build_mysql_result_grid_copy_sql(&table("people"), &headers(true), &[where_operation]) + .expect("copy where"), + "WHERE `display``name` LIKE 'O''Reilly'" + ); + } + + #[test] + fn grid_copy_in_values_is_typed_distinct_and_single_column() { + let first = MysqlResultGridCopyOperation { + operation_type: MysqlResultGridCopyOperationType::Where, + data_list: vec![None, Some("42".to_owned()), None, None, None], + select_cols: vec![1], + }; + let duplicate = first.clone(); + let null = MysqlResultGridCopyOperation { + data_list: vec![None, None, None, None, None], + ..first.clone() + }; + assert_eq!( + build_mysql_result_grid_in_values( + &headers(true), + &[first.clone(), duplicate, null.clone()] + ) + .expect("typed IN values"), + "(42, NULL)" + ); + assert_eq!( + build_mysql_external_in_values(&[ + " alpha ".to_owned(), + "alpha".to_owned(), + "O'Reilly".to_owned(), + ]) + .expect("external IN values"), + "('alpha', 'O''Reilly')" + ); + + let mixed = MysqlResultGridCopyOperation { + select_cols: vec![2], + ..first + }; + assert!(build_mysql_result_grid_in_values(&headers(true), &[mixed, null]).is_err()); + } + + #[test] + fn count_query_accepts_one_read_and_rejects_statement_injection() { + assert_eq!( + build_mysql_count_query("SELECT ';' AS marker FROM items;") + .expect("single count source"), + "SELECT COUNT(*) AS `CHAT2DB_COUNT` FROM (SELECT ';' AS marker FROM items) AS `CHAT2DB_COUNT_SOURCE`" + ); + assert!(build_mysql_count_query("SELECT 1; DROP TABLE items").is_err()); + assert!(build_mysql_count_query("DELETE FROM items").is_err()); + } + + #[test] + fn database_and_schema_are_mysql_namespace_aliases() { + let definition = MysqlDatabaseDefinition { + name: "tenant`one".to_owned(), + if_not_exists: true, + charset: Some("utf8mb4".to_owned()), + collation: Some("utf8mb4_0900_ai_ci".to_owned()), + }; + let expected = "CREATE DATABASE IF NOT EXISTS `tenant``one` DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci"; + assert_eq!( + build_mysql_create_database(&definition).expect("database SQL"), + expected + ); + assert_eq!( + build_mysql_create_schema(&definition).expect("schema SQL"), + expected + ); + assert_eq!( + build_mysql_drop_schema("tenant`one", true).expect("drop schema SQL"), + "DROP DATABASE IF EXISTS `tenant``one`" + ); + + let mut invalid = definition; + invalid.charset = Some("utf8mb4; DROP DATABASE prod".to_owned()); + assert!(build_mysql_create_database(&invalid).is_err()); + } + + #[test] + fn create_table_builds_structured_columns_indexes_and_options() { + let mut id = column("id", "BIGINT"); + id.unsigned = true; + id.auto_increment = true; + + let mut label = column("label", "VARCHAR"); + label.length = Some(255); + label.nullable = true; + label.default_value = Some("O'Reilly".to_owned()); + label.charset = Some("utf8mb4".to_owned()); + label.collation = Some("utf8mb4_0900_ai_ci".to_owned()); + label.comment = Some("display\\name".to_owned()); + + let mut state = column("state", "ENUM"); + state.enum_values = vec!["new".to_owned(), "in'flight".to_owned()]; + state.default_value = Some("new".to_owned()); + + let definition = MysqlTableDefinition { + name: table("order`items"), + if_not_exists: true, + columns: vec![id, label, state], + indexes: vec![ + index(MysqlIndexKind::Primary, None, &["id"]), + index(MysqlIndexKind::Unique, Some("uq`label"), &["label"]), + ], + engine: Some("InnoDB".to_owned()), + charset: Some("utf8mb4".to_owned()), + collation: Some("utf8mb4_0900_ai_ci".to_owned()), + comment: Some("orders' current".to_owned()), + auto_increment: Some(100), + }; + let sql = build_mysql_create_table(&definition).expect("create table SQL"); + assert!(sql.starts_with( + "CREATE TABLE IF NOT EXISTS `app``db`.`order``items` (\n `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" + )); + assert!(sql.contains( + "`label` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'O''Reilly' COMMENT 'display\\\\name'" + )); + assert!(sql.contains("`state` ENUM('new', 'in''flight') NOT NULL DEFAULT 'new'")); + assert!(sql.contains("PRIMARY KEY (`id`)")); + assert!(sql.contains("UNIQUE INDEX `uq``label` (`label`)")); + assert!(sql.ends_with( + "ENGINE = InnoDB DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci AUTO_INCREMENT = 100 COMMENT = 'orders'' current'" + )); + } + + #[test] + fn create_table_rejects_raw_types_and_invalid_properties() { + let mut raw = column("id", "INT); DROP TABLE users; --"); + let mut definition = MysqlTableDefinition { + name: table("items"), + if_not_exists: false, + columns: vec![raw.clone()], + indexes: Vec::new(), + engine: None, + charset: None, + collation: None, + comment: None, + auto_increment: None, + }; + assert!(build_mysql_create_table(&definition).is_err()); + + raw.type_name = "VARCHAR".to_owned(); + definition.columns = vec![raw]; + assert!(build_mysql_create_table(&definition).is_err()); + + let mut decimal = column("amount", "DECIMAL"); + decimal.length = Some(4); + decimal.scale = Some(5); + definition.columns = vec![decimal]; + assert!(build_mysql_create_table(&definition).is_err()); + } + + #[test] + fn alter_table_supports_column_index_options_and_rename() { + let mut added = column("created_at", "TIMESTAMP"); + added.nullable = false; + added.default_value = Some("now()".to_owned()); + added.on_update_current_timestamp = true; + + let mut modified = column("title", "VARCHAR"); + modified.length = Some(300); + modified.nullable = true; + + let alter = MysqlTableAlter { + table: table("items"), + rename_to: Some(table("renamed`items")), + columns: vec![ + MysqlColumnAlter::Add { + column: added, + position: Some(MysqlColumnPosition::First), + }, + MysqlColumnAlter::Modify { + old_name: "old_title".to_owned(), + column: modified, + position: Some(MysqlColumnPosition::After("created_at".to_owned())), + }, + MysqlColumnAlter::Delete { + name: "obsolete".to_owned(), + }, + ], + indexes: vec![ + MysqlIndexAlter::Modify { + old_kind: MysqlIndexKind::Normal, + old_name: Some("idx_old".to_owned()), + index: index(MysqlIndexKind::Unique, Some("idx_new"), &["title"]), + }, + MysqlIndexAlter::Delete { + kind: MysqlIndexKind::Primary, + name: None, + }, + ], + engine: Some("InnoDB".to_owned()), + charset: Some("utf8mb4".to_owned()), + collation: Some("utf8mb4_0900_ai_ci".to_owned()), + comment: Some(String::new()), + auto_increment: Some(10), + }; + let sql = build_mysql_alter_table(&alter).expect("alter table SQL"); + assert!(sql.contains( + "ADD COLUMN `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP FIRST" + )); + assert!( + sql.contains("CHANGE COLUMN `old_title` `title` VARCHAR(300) NULL AFTER `created_at`") + ); + assert!(sql.contains("DROP COLUMN `obsolete`")); + assert!(sql.contains("DROP INDEX `idx_old`")); + assert!(sql.contains("ADD UNIQUE INDEX `idx_new` (`title`)")); + assert!(sql.contains("DROP PRIMARY KEY")); + assert!(sql.contains("COMMENT = ''")); + assert!(sql.ends_with("RENAME TO `app``db`.`renamed``items`")); + } + + #[test] + fn table_drop_truncate_and_copy_quote_names() { + assert_eq!( + build_mysql_drop_table(&table("items"), true).expect("drop table SQL"), + "DROP TABLE IF EXISTS `app``db`.`items`" + ); + assert_eq!( + build_mysql_truncate_table(&table("items")).expect("truncate table SQL"), + "TRUNCATE TABLE `app``db`.`items`" + ); + assert_eq!( + build_mysql_copy_table(&MysqlTableCopy { + source: table("items"), + target: table("items_copy"), + if_not_exists: true, + copy_data: true, + }) + .expect("copy table SQL"), + vec![ + "CREATE TABLE IF NOT EXISTS `app``db`.`items_copy` LIKE `app``db`.`items`", + "INSERT INTO `app``db`.`items_copy` SELECT * FROM `app``db`.`items`", + ] + ); + } + + #[test] + fn view_builder_preserves_body_and_uses_closed_options() { + let view = MysqlViewDefinition { + name: table("active`users"), + columns: vec!["user`id".to_owned(), "name".to_owned()], + use_or_replace: false, + algorithm: Some(MysqlViewAlgorithm::Merge), + definer: Some(MysqlViewDefiner { + user: "app'user".to_owned(), + host: "localhost".to_owned(), + }), + sql_security: Some(MysqlViewSecurity::Invoker), + check_option: Some(MysqlViewCheckOption::Cascaded), + body: "SELECT id, name\nFROM users WHERE active = 1".to_owned(), + }; + assert_eq!( + build_mysql_create_or_replace_view(&view).expect("view SQL"), + "CREATE OR REPLACE ALGORITHM = MERGE DEFINER = 'app''user'@'localhost' SQL SECURITY INVOKER VIEW `app``db`.`active``users` (`user``id`, `name`) AS SELECT id, name\nFROM users WHERE active = 1 WITH CASCADED CHECK OPTION" + ); + assert_eq!( + build_mysql_create_view(&view).expect("plain view SQL"), + "CREATE ALGORITHM = MERGE DEFINER = 'app''user'@'localhost' SQL SECURITY INVOKER VIEW `app``db`.`active``users` (`user``id`, `name`) AS SELECT id, name\nFROM users WHERE active = 1 WITH CASCADED CHECK OPTION" + ); + assert_eq!( + build_mysql_drop_view(&view.name, true).expect("drop view SQL"), + "DROP VIEW IF EXISTS `app``db`.`active``users`" + ); + + let empty = MysqlViewDefinition { + body: " ".to_owned(), + ..view.clone() + }; + assert!(build_mysql_create_or_replace_view(&empty).is_err()); + + let oversized = MysqlViewDefinition { + body: "x".repeat(MAX_VIEW_BODY_BYTES + 1), + ..view + }; + assert!(build_mysql_create_or_replace_view(&oversized).is_err()); + + let injected = MysqlViewDefinition { + body: "SELECT 1; DROP TABLE users".to_owned(), + ..oversized + }; + assert!(build_mysql_create_or_replace_view(&injected).is_err()); + } + + #[test] + fn table_editor_meta_covers_common_mysql_84_options() { + let meta = mysql_table_editor_meta(); + for type_name in [ + "BIGINT UNSIGNED", + "DECIMAL", + "DATETIME", + "VARCHAR", + "JSON", + "GEOMETRY", + ] { + assert!( + meta.column_types + .iter() + .any(|column_type| column_type.type_name == type_name) + ); + } + assert!( + meta.charsets + .iter() + .any(|charset| charset.charset_name == "utf8mb4") + ); + assert!( + meta.collations + .iter() + .any(|collation| collation.collation_name == "utf8mb4_0900_ai_ci") + ); + assert_eq!( + meta.index_types + .iter() + .map(|index_type| index_type.type_name.as_str()) + .collect::>(), + ["Primary", "Normal", "Unique", "Fulltext", "Spatial"] + ); + assert!( + meta.engine_types + .iter() + .any(|engine| engine.name == "InnoDB") + ); + let json = serde_json::to_value(meta).expect("serialize table editor meta"); + assert!(json["columnTypes"].is_array()); + assert!(json["defaultValues"].is_array()); + assert!(json["engineTypes"].is_array()); + } +} diff --git a/crates/chat2db-core/src/native_mysql.rs b/crates/chat2db-core/src/native_mysql.rs index 8924be5..f830012 100644 --- a/crates/chat2db-core/src/native_mysql.rs +++ b/crates/chat2db-core/src/native_mysql.rs @@ -1592,7 +1592,7 @@ enum ScriptState { BlockComment, } -fn split_mysql_script(script: &str) -> Result, AppError> { +pub(crate) fn split_mysql_script(script: &str) -> Result, AppError> { let bytes = script.as_bytes(); let mut delimiter = b";".to_vec(); let mut statements = Vec::new(); diff --git a/docs/architecture.md b/docs/architecture.md index 1eb0398..84979b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,8 +16,9 @@ without embedding H2 in the compatibility-engine JAR. The Web and Tauri hosts open the production vault, SQLite storage, and verified driver catalog before exposing a shared `Application`; they do not start Java -during host bootstrap. Native MySQL connection, object metadata, preview, and -Console reads/writes/scripts do not acquire a Java lease. The Core +during host bootstrap. Native MySQL connection, object metadata, editable +result-grid operations, database/table/view DDL, preview, and Console +reads/writes/scripts do not acquire a Java lease. The Core `EngineManager` starts one Java generation on the first JDBC-only database, parser, formatter, completion, builder, or advanced metadata request. It shares that single-flight startup across concurrent callers and issues generation-scoped @@ -45,12 +46,14 @@ workbench. The build exports the unmodified Community frontend tree pinned by contract through Axum; desktop maps the existing `window.javaQuery` contract through one Tauri `legacy_request` command. Both paths call the same Rust legacy dispatcher. The implemented product slice covers native MySQL connection -testing, datasource CRUD/tree, relational object metadata, read-only table -preview, and bounded unparameterized Console reads, writes, scripts, -transactions, cancellation, history, and large values. 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. +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. +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. Runtime-tested: yes for the Stage 7M MySQL product vertical. On 2026-07-27 the complete stored-datasource path passed against MySQL 8.4, from real Community @@ -58,8 +61,10 @@ identifier/DQL/page-limit generation through forced-read-only JDBC execution 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 -complete repository `make verify` gate and an explicit real-MySQL rerun passed -after preserving the disabled-engine error contract. +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. ## Ownership @@ -72,7 +77,7 @@ after preserving the disabled-engine error contract. | Durable state | Rust | SQLite, retained-result files, and a mandatory injected secret-vault contract | | AI agent | Rust | Provider adapters, tool loop, limits, compaction, and cancellation | | MCP and CLI | Rust | Adapters around the same product services and policy | -| Native MySQL product slice | Rust / `mysql_async` | Connection, object metadata, Community-compatible routes/envelopes, preview, unparameterized Console reads/writes/scripts, transactions, paging, limits, cancellation, large values, and durable history | +| Native MySQL product slice | Rust / `mysql_async` | Connection, object metadata, Community-compatible routes/envelopes, editable result-grid DML, database/table/view DDL, preview, unparameterized Console reads/writes/scripts, transactions, paging, limits, cancellation, large values, and durable history | | Compatibility databases and remaining MySQL operations | Java 17 | Existing SPI/plugins, JDBC bind parameters, SQL builders, parsing, formatting, completion, unmapped MySQL features, and non-MySQL metadata | | SQL parsing, formatting, and completion | Java 17 | Existing Java ANTLR grammars, parser behavior, formatter behavior, and completion | | Rust-to-Java IPC | Shared Protobuf contract | Length-prefixed frames over private stdin/stdout | @@ -90,7 +95,7 @@ React in system WebView React in browser <- owner-only local attachment <- rmcp stdio server <- MCP client -> SQLite and result store -> AI agent runtime - -> native MySQL connection / metadata / Console + -> native MySQL connection / metadata / editable DDL / Console -> Java process supervisor -> Protobuf stdin/stdout -> Java database compatibility engine @@ -130,10 +135,11 @@ cross-language acceptance gates pass. Java/JDBC remains the compatibility implementation for other databases and for unmigrated MySQL operations. The native route uses upstream -`mysql_async 0.37.0` for MySQL connection testing, object metadata, preview, -and unparameterized Console execution. Core selects this backend before -requesting an `EngineLease`; unrecognized drivers cannot enter it. Console -bind parameters remain unsupported rather than silently starting Java. +`mysql_async 0.37.0` for MySQL connection testing, object metadata, editable +result-grid execution, database/table/view DDL, preview, and unparameterized +Console execution. Core selects this backend before requesting an +`EngineLease`; unrecognized drivers cannot enter it. Console bind parameters +remain unsupported rather than silently starting Java. The native MySQL baseline implements: @@ -141,6 +147,12 @@ The native MySQL baseline implements: 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; +- 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; +- 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; - 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 fa77811..e3bbe9a 100644 --- a/docs/mysql-community-parity.md +++ b/docs/mysql-community-parity.md @@ -19,7 +19,12 @@ expects. Native metadata and Console execution keep Java dormant. Paged table name/comment search, complete-list filtering, page-size validation, HTTP binding-error envelopes, and nullable column defaults match the locked - Community baseline. + Community baseline. The original Web and Tauri contracts now also expose + editable table previews, create/update/delete SQL generation and execution, + 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. - Complete parity: not implemented. This file is the acceptance contract for MySQL work. Community frontend routes @@ -49,11 +54,11 @@ route reaches it and a real MySQL product test covers the behavior. | SSH and JDBC driver management | `/api/connection/ssh/pre_connect`, `/api/jdbc/driver/download`, `/upload`, `/save`, `/delete` | Not implemented | Match Community SSH testing and local driver lifecycle without exposing secrets. | | 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}` | Builder-only modern contracts | Generate previews through Community builders and execute only after the same explicit frontend command. | -| Table inventory and detail | `/api/rdb/table/list`, `/table_list`, `/table_meta`, `/column_list`, `/index_list`, `/key_list`, `/query` | List, compact list, column, index, and key routes implemented with native MySQL metadata; nullable defaults and legacy envelopes match Community; `table_meta` and `/query` remain | Add the remaining table-meta and query projections without regressing the native routes. | -| Table data operations | `/api/rdb/dml/execute_table`, `/execute_update`, `/get_update_sql`, `/copy_update_sql`, `/copy_in_values_sql`, `/count` | Read-only preview only; closed typed DML generation exists behind modern APIs | Support editable rows, insert/update/delete SQL, counts, copy helpers, optimistic predicates, and bounded execution. | -| Table DDL | `/api/rdb/ddl/*`, `/api/rdb/table/modify/sql`, `/delete`, `/truncate`, `/copy`, create/update examples, DDL export | Partial builder infrastructure only | Match create/alter/drop/truncate/copy preview and execution, including columns, indexes, keys, charset, collation, comments, and MySQL types. | -| Views | `/api/rdb/view/list`, `/column_list`, `/detail`, `/query`, `/view_meta`, `/modify/sql`, `/delete`, `/drop` | Native list, column list, and `SHOW CREATE VIEW` detail are mapped to the legacy routes | Add data query, metadata, preview, create/alter, and drop flows. | +| 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. | | 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. | @@ -75,7 +80,9 @@ route reaches it and a real MySQL product test covers the behavior. multi-result handling, writes, transactions, history, cancellation, and large-cell retrieval. Bind parameters and remaining exact Community edge-case conformance are still required for complete parity. -3. Table data editing plus database/schema/table/view DDL preview and execution. +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. 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. @@ -95,10 +102,12 @@ the complete repository verification gate, and all GitHub Actions jobs. - `third_party/chat2db-community/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/controller/` - `third_party/chat2db-community/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/` - `crates/chat2db-core/src/native_mysql.rs` +- `crates/chat2db-core/src/mysql_ddl.rs` - `crates/chat2db-core/src/large_value.rs` - `crates/chat2db-core/tests/native_mysql_console_docker.rs` - `crates/chat2db-storage/src/operation_log.rs` - `crates/chat2db-storage/migrations/004_operation_log.sql` - `crates/chat2db-core/tests/native_mysql_product.rs` +- `apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs` - `crates/chat2db-core/src/community.rs` - `apps/chat2db-web/src/legacy.rs` diff --git a/docs/stages.md b/docs/stages.md index 5990428..02c2c03 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -12,7 +12,7 @@ in runtime health until then. | 4 | Complete | Product and result storage foundation | SQLite migration/integrity gates, mandatory vault boundary, revisioned datasource records, durable result frames, bounded paging/quota, expiry, writer cleanup and recovery tests | | 5 | Complete | Product transports | Generated OpenAPI/TypeScript contract, Axum JSON/SSE, Tauri 2 commands/channels, shared SQL workbench, product H2 tests | | 6 | Complete | Agent, MCP, and CLI | Direct providers, durable bounded tool loop, SQL tools/permissions, compaction, Web/Tauri run transports, owner-only local attachment, read-query CLI, and bounded `rmcp` stdio tools | -| 7 | In progress | Chat2DB compatibility estate | 7A managed JDBC packs through 7M bounded table preview are implemented; native `mysql_async` now owns the MySQL browser and unparameterized Console data plane while Java starts on demand for unmigrated compatibility work; the current product retains the original Community layout with historical HTTP/Tauri compatibility for MySQL browsing, saved Consoles, scripts, writes, transactions, history, cancellation, and large values | +| 7 | In progress | Chat2DB compatibility estate | 7A managed JDBC packs through 7M bounded table preview are implemented; native `mysql_async` now owns the MySQL browser, editable result-grid and DDL lifecycle, and unparameterized Console data plane while Java starts on demand for unmigrated compatibility work; the current product retains the original Community layout with historical HTTP/Tauri compatibility for MySQL browsing, object editing, saved Consoles, scripts, writes, transactions, history, cancellation, and large values | | 8 | Planned | Packaging and release | License authorization, NOTICE/SBOM, jlink runtime, Tauri installers, signed product/engine/driver manifests, atomic update and rollback, size measurement | Stage 3 completion means the versioned Rust-Java bridge can load an external @@ -348,6 +348,24 @@ vertical rerun after the final compatibility fix. The broad Community compatibility operations and other database types remain on the lazy Java/JDBC path. +The editable-grid and DDL follow-up adds structured MySQL insert/update/delete +generation and native execution, copy-as-SQL and bounded count helpers, table +editor metadata, database/schema create and confirmed delete, table +create/alter/drop/truncate/copy, and view query/create-or-replace/drop. Axum and +desktop `legacy_request` use the same historical dispatcher, so the unchanged +Community frontend reaches one Rust implementation on both transports. The SQL +builders validate identifier segments, closed type/options, values, and view +bodies; updates prefer primary keys and otherwise match the complete old row +with `LIMIT 1`. + +Runtime-tested: yes. On 2026-07-29 the local Docker MySQL 8.4 gate passed the +Core product, native Console, and historical Web editable-grid/DDL verticals. +The Web vertical exercised database/table/view creation and deletion, table +alter/copy/truncate, row insert/update/delete, copy/count helpers, automatic +fixture cleanup, and a dormant Java assertion after every product operation. +Core and Web focused tests, strict workspace Clippy, formatting, whitespace, +and the complete repository `make verify` gate passed. + Stage 7 remains incomplete. Complete MySQL type conformance, native bind parameters, remaining exact Community Console edge cases, data import/export, non-relational behavior, remaining builder operations and plugin inventory,