pub mod table_structure_sql; pub use dbx_drivers::metadata::sqlite_ddl; use crate::agent_recovery::{RecoveryDecision, RecoveryPolicy, RecoveryScope}; use crate::connection::{ connection_url_for_endpoint, database_connection_config, gaussdb_uses_m_jdbc_driver, task_client_session_id, uses_metadata_gate, AppState, MysqlMode, PoolKind, METADATA_POOL_ACQUIRE_TIMEOUT, }; use crate::db; use crate::models::connection::{ConnectionConfig, DatabaseType}; use crate::query::{agent_execute_query_params, should_discard_pool_after_error, QueryExecutionOptions}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::future::Future; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; mod kingbase; macro_rules! extract_pool { ($pool:expr, $variant:ident) => { $pool.and_then(|v| match v { PoolKind::$variant(val) => Some(val.clone()), _ => None, }) }; } macro_rules! dispatch_mysql { ($p:expr, $mode:expr, $mysql:path, $ob:path $(, $arg:expr)*) => { if *$mode == MysqlMode::OceanBaseOracle { $ob($p $(, $arg)*).await } else { $mysql($p $(, $arg)*).await } }; } async fn clone_metadata_pool(state: &AppState, pool_key: &str) -> Option { state.pool_handle(pool_key).await } struct EphemeralAgentMetadataSession { client_session_id: Option, cleanup_guard: Option, } impl EphemeralAgentMetadataSession { async fn open(state: &AppState, connection_id: &str, database: Option<&str>, task_kind: &str) -> Self { let db_config = connection_config(state, connection_id).await; let client_session_id = ephemeral_agent_metadata_session_id(db_config.as_ref(), task_kind); let cleanup_guard = match client_session_id.as_deref() { Some(client_session_id) => { state.metadata_session_pool_cleanup_guard(connection_id, database, client_session_id).await } None => None, }; Self { client_session_id, cleanup_guard } } fn client_session_id(&self) -> Option<&str> { self.client_session_id.as_deref() } async fn finish(mut self, state: &AppState, connection_id: &str, database: Option<&str>) { if close_ephemeral_agent_metadata_session(state, connection_id, database, self.client_session_id()).await { if let Some(cleanup_guard) = self.cleanup_guard.as_mut() { cleanup_guard.disarm(); } } } } macro_rules! try_sqlserver { ($pool:expr, $method:ident $(, $arg:expr)*) => { if let Some(client) = extract_pool!($pool.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::$method(&mut client $(, $arg)*).await; } }; } async fn lock_sqlserver_metadata_client<'a>( client: &'a Arc>, ) -> Result, String> { lock_metadata_mutex_with_timeout(client, METADATA_POOL_ACQUIRE_TIMEOUT).await } async fn lock_metadata_mutex_with_timeout( client: &Arc>, timeout: Duration, ) -> Result, String> { tokio::time::timeout(timeout, client.lock()).await.map_err(|_| crate::query::METADATA_POOL_BUSY_ERROR.to_string()) } const ORACLE_TABLE_COMMENT_BATCH_SIZE: usize = 500; const TDENGINE_COMMENT_SEARCH_TIMEOUT: Duration = Duration::from_secs(5); const TDENGINE_COMMENT_SEARCH_CACHE_TTL: Duration = Duration::from_secs(10); const TDENGINE_LIKE_PATTERN_MAX_BYTES: usize = 100; pub use dbx_types::metadata_filter::{ sql_like_pattern_matches_case_insensitive, table_name_filter_matches, TableNameFilter, }; fn clickhouse_metadata_database<'a>(database: &'a str, schema: &'a str) -> &'a str { if schema.trim().is_empty() { database } else { schema } } fn agent_metadata_timeout(config: Option<&ConnectionConfig>) -> Option { let Some(config) = config else { return Some(Duration::from_secs(60)); }; match config.effective_query_timeout_secs() { 0 => None, seconds => Some(Duration::from_secs(seconds.max(60))), } } fn mysql_database_list_timeout(config: Option<&ConnectionConfig>) -> Duration { config .map(|config| Duration::from_secs(config.effective_connect_timeout_secs())) .unwrap_or_else(db::connection_timeout) } pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Result, String> { retry_metadata_connection(state, connection_id, None, || list_databases_once(state, connection_id)).await } /// Lists XuguDB tablespaces and their physical data files for the selected /// database. This is deliberately a no-op for every other database type so /// the common schema surface and non-Xugu drivers remain untouched. pub async fn list_xugu_tablespaces_core( state: &AppState, connection_id: &str, database: Option<&str>, ) -> Result, String> { let config = connection_config(state, connection_id).await; if !config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Xugu) { return Ok(Vec::new()); } retry_metadata_connection(state, connection_id, database.filter(|database| !database.is_empty()), || async { let pool_key = state .get_or_create_metadata_pool_for_session( connection_id, database.filter(|database| !database.is_empty()), None, ) .await?; let config = connection_config(state, connection_id).await; let pool_handle = state.pool_handle(&pool_key).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let mut client = client.lock().await; return client .list_xugu_tablespaces::>(database, agent_metadata_timeout(config.as_ref())) .await; } Ok(Vec::new()) }) .await } /// Loads the more expensive database-level properties needed only by the /// connection resource browser. General metadata paths keep using /// `list_databases_core`, which only enumerates names. pub async fn list_database_metadata_core( state: &AppState, connection_id: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, None, || list_database_metadata_once(state, connection_id)).await } pub async fn list_database_storage_core( state: &AppState, connection_id: &str, database_names: &[String], ) -> Result, String> { retry_metadata_connection(state, connection_id, None, || { list_database_storage_once(state, connection_id, database_names) }) .await } async fn list_database_storage_once( state: &AppState, connection_id: &str, database_names: &[String], ) -> Result, String> { const DATABASE_STORAGE_TIMEOUT: Duration = Duration::from_secs(5); const MAX_DATABASE_STORAGE_NAMES: usize = 2048; if database_names.is_empty() { return Ok(Vec::new()); } let config = connection_config(state, connection_id).await; if !config.as_ref().is_some_and(|config| { config.db_type == DatabaseType::Postgres && config.driver_profile.as_deref() != Some("cockroachdb") }) { return Ok(Vec::new()); } let pool = { let pool_handle = state.pool_handle(connection_id).await; match pool_handle.as_ref() { Some(PoolKind::Postgres(pool)) => pool.clone(), _ => return Ok(Vec::new()), } }; let mut seen = std::collections::HashSet::new(); let requested = database_names .iter() .filter(|name| !name.is_empty() && seen.insert((*name).clone())) .take(MAX_DATABASE_STORAGE_NAMES) .cloned() .collect::>(); if requested.is_empty() { return Ok(Vec::new()); } match tokio::time::timeout(DATABASE_STORAGE_TIMEOUT, db::postgres::list_database_storage(&pool, &requested)).await { Ok(result) => result, Err(_) => { log::warn!( "[list_database_storage:timeout] connection_id={} database_count={} timeout_ms={}", connection_id, requested.len(), DATABASE_STORAGE_TIMEOUT.as_millis() ); Ok(Vec::new()) } } } pub async fn list_sqlserver_linked_servers_core( state: &AppState, connection_id: &str, ) -> Result, String> { let _metadata_permit = state.acquire_metadata_permit(connection_id, None, DatabaseType::SqlServer, None).await?; let pool_handle = state.pool_handle(connection_id).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::list_linked_servers(&mut client).await; } Ok(vec![]) } pub async fn get_sqlserver_completion_context_core( state: &AppState, connection_id: &str, database: &str, ) -> Result { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; let db_config = connection_config(state, connection_id).await; let completion_context_sql = db::sqlserver::completion_context_sql_for_profile( db_config.as_ref().and_then(|config| config.driver_profile.as_deref()), ); let pool_handle = state.pool_handle(&pool_key).await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { let config = config.clone(); let session = session.clone(); let result: db::QueryResult = session .invoke_with_timeout( "executeQuery", serde_json::json!({ "connection": config.as_ref(), "database": database, "sql": completion_context_sql, "maxRows": 1 }), agent_metadata_timeout(Some(config.as_ref())), ) .await?; return db::sqlserver::completion_context_from_query_result(result); } try_sqlserver!(pool_handle, get_completion_context); if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let mut client = client.lock().await; let result = client .execute_query_with_timeout::( agent_execute_query_params( completion_context_sql, if database.is_empty() { None } else { Some(database) }, None, QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, ), agent_metadata_timeout(db_config.as_ref()), ) .await?; return db::sqlserver::completion_context_from_query_result(result); } Err("SQL Server completion context requires a SQL Server connection".to_string()) }) .await } pub async fn list_sqlserver_linked_server_catalogs_core( state: &AppState, connection_id: &str, server: &str, ) -> Result, String> { let _metadata_permit = state.acquire_metadata_permit(connection_id, None, DatabaseType::SqlServer, None).await?; let pool_handle = state.pool_handle(connection_id).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::list_linked_server_catalogs(&mut client, server).await; } Ok(vec![]) } pub async fn list_sqlserver_linked_server_schemas_core( state: &AppState, connection_id: &str, server: &str, catalog: &str, ) -> Result, String> { let _metadata_permit = state.acquire_metadata_permit(connection_id, None, DatabaseType::SqlServer, None).await?; let pool_handle = state.pool_handle(connection_id).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::list_linked_server_schemas(&mut client, server, catalog).await; } Ok(vec![]) } pub async fn list_sqlserver_linked_server_tables_core( state: &AppState, connection_id: &str, server: &str, catalog: &str, schema: &str, filter: Option<&str>, limit: Option, offset: Option, ) -> Result, String> { let _metadata_permit = state.acquire_metadata_permit(connection_id, None, DatabaseType::SqlServer, None).await?; let pool_handle = state.pool_handle(connection_id).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::list_linked_server_tables(&mut client, server, catalog, schema, filter, limit, offset) .await; } Ok(vec![]) } // --------------------------------------------------------------------------- // Doris / StarRocks multi-catalog federation. // // These engines expose external catalogs (iceberg, hive, jdbc, ...) alongside // the native `internal` catalog via `SHOW CATALOGS`. The functions below browse // a specific catalog's databases/tables and read table metadata using 3-part // qualified names (`..`), which the engines accept // directly. The native `internal` catalog continues to use the existing // `list_databases_core` / `list_tables_core` paths. // --------------------------------------------------------------------------- /// `SHOW CATALOGS` → catalogs visible to the current user. Returns an empty /// list when the connection pool is not a MySQL pool (Doris/StarRocks always /// use the MySQL protocol, so this is a defensive no-op); the caller's /// flat-sidebar fallback then renders the standard database list. pub async fn list_doris_catalogs_core(state: &AppState, connection_id: &str) -> Result, String> { retry_metadata_connection(state, connection_id, None, || list_doris_catalogs_once(state, connection_id)).await } async fn list_doris_catalogs_once(state: &AppState, connection_id: &str) -> Result, String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, None, None).await?; let db_config = connection_config(state, connection_id).await; if let Some(PoolKind::Mysql(p, _)) = clone_metadata_pool(state, &pool_key).await { return if db_config.as_ref().is_some_and(db::starrocks::is_config) { db::starrocks::list_catalogs(&p).await } else { db::doris::list_catalogs(&p).await }; } Ok(vec![]) } /// `SHOW DATABASES FROM ` → databases in the given catalog. /// /// For `internal`, system databases are filtered (mirroring `list_databases_core`). /// For external catalogs, permission errors degrade to an empty list (the user /// asked that inaccessible catalogs simply not be shown). pub async fn list_doris_catalog_databases_core( state: &AppState, connection_id: &str, catalog: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, None, || { list_doris_catalog_databases_once(state, connection_id, catalog) }) .await } async fn list_doris_catalog_databases_once( state: &AppState, connection_id: &str, catalog: &str, ) -> Result, String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, None, None).await?; let db_config = connection_config(state, connection_id).await; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; let PoolKind::Mysql(p, _) = &pool else { return Ok(vec![]); }; let databases = if db_config.as_ref().is_some_and(db::starrocks::is_config) { db::starrocks::list_catalog_databases(p, catalog).await } else { db::doris::list_catalog_databases(p, catalog).await }; // External catalogs may reject `SHOW DATABASES FROM ` when the user // lacks permission — surface as an empty list rather than an error. let databases = match databases { Ok(databases) => databases, Err(error) => { log::warn!( "[schema][doris:list_catalog_databases] connection_id={} catalog={} error={}", connection_id, catalog, error ); return Ok(vec![]); } }; if catalog == "internal" || catalog.eq_ignore_ascii_case("default_catalog") { return Ok(filter_mysql_system_databases_for_config(databases, db_config.as_ref())); } Ok(databases) } /// `SHOW TABLES FROM .` → tables in an external catalog. pub async fn list_doris_catalog_tables_core( state: &AppState, connection_id: &str, catalog: &str, database: &str, filter: Option<&str>, limit: Option, offset: Option, object_types: Option<&[String]>, table_name_filter: Option<&TableNameFilter>, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || { list_doris_catalog_tables_once( state, connection_id, catalog, database, filter, limit, offset, object_types, table_name_filter, ) }) .await } async fn list_doris_catalog_tables_once( state: &AppState, connection_id: &str, catalog: &str, database: &str, filter: Option<&str>, limit: Option, offset: Option, object_types: Option<&[String]>, table_name_filter: Option<&TableNameFilter>, ) -> Result, String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, None, None).await?; let db_config = connection_config(state, connection_id).await; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; let PoolKind::Mysql(p, _) = &pool else { return Ok(vec![]); }; let tables = if db_config.as_ref().is_some_and(db::starrocks::is_config) { db::starrocks::list_catalog_tables(p, catalog, database).await } else { db::doris::list_catalog_tables(p, catalog, database).await }?; Ok(filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)) } /// Columns of an external catalog table via `SHOW COLUMNS FROM ..
`. pub async fn get_doris_catalog_columns_core( state: &AppState, connection_id: &str, catalog: &str, database: &str, table: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || { get_doris_catalog_columns_once(state, connection_id, catalog, database, table) }) .await } async fn get_doris_catalog_columns_once( state: &AppState, connection_id: &str, catalog: &str, database: &str, table: &str, ) -> Result, String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, None, None).await?; let db_config = connection_config(state, connection_id).await; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; let PoolKind::Mysql(p, _) = &pool else { return Ok(vec![]); }; let columns = if db_config.as_ref().is_some_and(db::starrocks::is_config) { db::starrocks::get_catalog_columns(p, catalog, database, table).await } else { db::doris::get_catalog_columns(p, catalog, database, table).await }?; Ok(deduplicate_column_infos(columns)) } /// DDL for an external catalog table via `SHOW CREATE TABLE ..
`. pub async fn get_doris_catalog_table_ddl_core( state: &AppState, connection_id: &str, catalog: &str, database: &str, table: &str, ) -> Result { retry_metadata_connection(state, connection_id, Some(database), || { get_doris_catalog_table_ddl_once(state, connection_id, catalog, database, table) }) .await } async fn get_doris_catalog_table_ddl_once( state: &AppState, connection_id: &str, catalog: &str, database: &str, table: &str, ) -> Result { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, None, None).await?; let db_config = connection_config(state, connection_id).await; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; let PoolKind::Mysql(p, _) = &pool else { return Err("DDL not supported for this connection".to_string()); }; if db_config.as_ref().is_some_and(db::starrocks::is_config) { db::starrocks::get_catalog_table_ddl(p, catalog, database, table).await } else { db::doris::get_catalog_table_ddl(p, catalog, database, table).await } } /// Best-effort index listing for an external catalog table (derived from DDL). pub async fn list_doris_catalog_indexes_core( state: &AppState, connection_id: &str, catalog: &str, database: &str, table: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || { list_doris_catalog_indexes_once(state, connection_id, catalog, database, table) }) .await } async fn list_doris_catalog_indexes_once( state: &AppState, connection_id: &str, catalog: &str, database: &str, table: &str, ) -> Result, String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, None, None).await?; let db_config = connection_config(state, connection_id).await; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; let PoolKind::Mysql(p, _) = &pool else { return Ok(vec![]); }; if db_config.as_ref().is_some_and(db::starrocks::is_config) { db::starrocks::list_catalog_indexes(p, catalog, database, table).await } else { db::doris::list_catalog_indexes(p, catalog, database, table).await } } /// Table comment for an external catalog table. Doris does not reliably expose /// comments for external catalog tables, so this returns `None`. pub async fn get_doris_catalog_table_comment_core( _state: &AppState, _connection_id: &str, _catalog: &str, _database: &str, _table: &str, ) -> Result, String> { Ok(None) } /// Foreign keys are not applicable to external catalog tables. pub async fn list_doris_catalog_foreign_keys_core( _state: &AppState, _connection_id: &str, _catalog: &str, _database: &str, _table: &str, ) -> Result, String> { Ok(vec![]) } /// Triggers are not applicable to external catalog tables. pub async fn list_doris_catalog_triggers_core( _state: &AppState, _connection_id: &str, _catalog: &str, _database: &str, _table: &str, ) -> Result, String> { Ok(vec![]) } /// Resolve a non-internal catalog for dispatch to the Doris multi-catalog path. /// Returns `Some(catalog)` only when `catalog` is a non-empty, non-`internal` /// name and the connection is a Doris-family engine that supports /// `SHOW CATALOGS`. Otherwise `None` (caller uses the default metadata path). pub async fn resolve_external_doris_catalog( state: &AppState, connection_id: &str, catalog: Option<&str>, ) -> Option { let catalog = catalog?.trim(); if catalog.is_empty() || catalog.eq_ignore_ascii_case("internal") || catalog.eq_ignore_ascii_case("default_catalog") { return None; } let config = connection_config(state, connection_id).await?; if db::mysql_compatible::supports_external_catalogs(&config) { Some(catalog.to_string()) } else { None } } async fn list_databases_once(state: &AppState, connection_id: &str) -> Result, String> { log::info!("[list_databases] connection_id={connection_id}"); let db_config = connection_config(state, connection_id).await; { let pool_handle = state.pool_handle(connection_id).await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { let config = config.clone(); let session = session.clone(); return session .invoke_with_timeout::>( "listDatabases", serde_json::json!({ "connection": config.as_ref() }), agent_metadata_timeout(Some(config.as_ref())), ) .await; } if let Some(client) = extract_pool!(pool_handle.as_ref(), ClickHouse) { return db::clickhouse_driver::list_databases(&client).await; } if let Some(client) = extract_pool!(pool_handle.as_ref(), InfluxDb) { return db::influxdb_driver::list_databases(&client).await; } if let Some(client) = extract_pool!(pool_handle.as_ref(), InfluxDb3) { return db::influxdb3_driver::list_databases(&client).await; } if let Some(client) = extract_pool!(pool_handle.as_ref(), VictoriaMetrics) { return db::victoriametrics_driver::list_databases(&client).await; } try_sqlserver!(pool_handle, list_databases); if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let is_mongo = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::MongoDb); if is_mongo { let dbs = crate::mongo_ops::mongo_list_databases_core(state, connection_id).await?; return Ok(dbs.into_iter().map(|name| db::DatabaseInfo { name, ..Default::default() }).collect()); } let mut client = client.lock().await; return client.list_databases(agent_metadata_timeout(db_config.as_ref())).await; } } let db_config = connection_config(state, connection_id).await; let mysql_database_list_timeout = mysql_database_list_timeout(db_config.as_ref()); let pool = clone_metadata_pool(state, connection_id).await.ok_or("Connection not found")?; match &pool { PoolKind::Mysql(p, mode) if *mode != MysqlMode::OceanBaseOracle && db_config.as_ref().is_some_and(db::dolt::is_config) => { db::dolt::list_databases(p).await } PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(db::mysql_compatible::uses_show_metadata) => { db::mysql::list_databases_show_with_timeout(p, mysql_database_list_timeout) .await .map(|databases| filter_mysql_system_databases_for_config(databases, db_config.as_ref())) } PoolKind::Mysql(p, mode) if *mode == MysqlMode::OceanBaseOracle => db::ob_oracle::list_databases(p).await, PoolKind::Mysql(p, _) => db::mysql::list_databases_with_timeout(p, mysql_database_list_timeout).await, PoolKind::Postgres(p) if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OpenGauss) => { db::postgres::list_opengauss_databases(p).await } PoolKind::Postgres(p) => db::postgres::list_databases(p).await, PoolKind::Sqlite(p) => db::sqlite::list_databases(p).await, PoolKind::Rqlite(client) => db::rqlite_driver::list_databases(client).await, PoolKind::Turso(client) => db::turso_driver::list_databases(client).await, PoolKind::HBase(client) => db::hbase_driver::list_namespaces(client).await, #[cfg(feature = "duckdb-sidecar")] PoolKind::DuckDbWorker(client) => client.list_databases().await, PoolKind::CloudflareD1(client) => db::cloudflare_d1_driver::list_databases(client).await, _ => Ok(vec![]), } } async fn list_database_metadata_once(state: &AppState, connection_id: &str) -> Result, String> { let config = connection_config(state, connection_id).await; if config .as_ref() .is_some_and(|config| db::dolt::is_config(config) || db::mysql_compatible::uses_show_metadata(config)) { return list_databases_once(state, connection_id).await; } let pool_handle = state.pool_handle(connection_id).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::list_database_metadata(&mut client).await; } if let Some(PoolKind::Mysql(pool, mode)) = pool_handle.as_ref() { let pool = pool.clone(); let mode = *mode; return if mode == MysqlMode::OceanBaseOracle { db::ob_oracle::list_databases(&pool).await } else { db::mysql::list_database_metadata(&pool).await }; } if let Some(pool) = extract_pool!(pool_handle.as_ref(), Postgres) { return db::postgres::list_database_metadata(&pool).await; } list_databases_once(state, connection_id).await } pub async fn list_schemas_core(state: &AppState, connection_id: &str, database: &str) -> Result, String> { list_schemas_core_with_visible_filter(state, connection_id, database, false).await } pub async fn list_schemas_core_with_visible_filter( state: &AppState, connection_id: &str, database: &str, apply_visible_filter: bool, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || { list_schemas_once(state, connection_id, database, apply_visible_filter) }) .await } pub async fn list_schema_infos_core( state: &AppState, connection_id: &str, database: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || { list_schema_infos_once(state, connection_id, database) }) .await } async fn list_schema_infos_once( state: &AppState, connection_id: &str, database: &str, ) -> Result, String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; let show_system_schemas = db_config.as_ref().is_some_and(|config| config.show_system_schemas); if let Some(PoolKind::Postgres(pool)) = clone_metadata_pool(state, &pool_key).await { return db::postgres::list_schema_infos_with_system(&pool, show_system_schemas).await; } let schemas = list_schemas_once(state, connection_id, database, false).await?; Ok(schemas.into_iter().map(|name| db::SchemaInfo { name, comment: None }).collect()) } pub async fn list_data_types_core( state: &AppState, connection_id: &str, database: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; let pool_handle = state.pool_handle(&pool_key).await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { let config = config.clone(); let session = session.clone(); return session .invoke_with_timeout::>( "listDataTypes", serde_json::json!({ "connection": config.as_ref(), "database": database }), agent_metadata_timeout(Some(config.as_ref())), ) .await .map(deduplicate_data_type_names); } if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let mut client = client.lock().await; return client .list_data_types::>(database, agent_metadata_timeout(db_config.as_ref())) .await .map(deduplicate_data_type_names); } Ok(Vec::new()) }) .await } fn deduplicate_data_type_names(names: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); let mut result = Vec::new(); for name in names { let trimmed = name.trim(); if trimmed.is_empty() { continue; } let key = trimmed.to_ascii_lowercase(); if seen.insert(key) { result.push(trimmed.to_string()); } } result } async fn list_schemas_once( state: &AppState, connection_id: &str, database: &str, apply_visible_filter: bool, ) -> Result, String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; let show_system_schemas = db_config.as_ref().is_some_and(|config| config.show_system_schemas); let visible_schema_filter = visible_schema_filter(db_config.as_ref(), database, apply_visible_filter); { let pool_handle = state.pool_handle(&pool_key).await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { let config = config.clone(); let session = session.clone(); return session .invoke_with_timeout::>( "listSchemas", serde_json::json!({ "connection": config.as_ref(), "database": database }), agent_metadata_timeout(Some(config.as_ref())), ) .await; } try_sqlserver!(pool_handle, list_schemas); if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let fallback_config = db_config.clone(); let mut client = client.lock().await; match client .list_schemas_filtered::>( database, visible_schema_filter.as_deref(), show_system_schemas, agent_metadata_timeout(db_config.as_ref()), ) .await { Ok(schemas) if !schemas.is_empty() => { return Ok(filter_visible_schema_names(schemas, visible_schema_filter.as_deref())) } Ok(schemas) => { if let Some(config) = fallback_config.as_ref() { match native_postgres_metadata_pool(state, connection_id, database, config).await { Ok(Some(pool)) => { return db::postgres::list_schemas_with_system(&pool, show_system_schemas).await.map( |schemas| filter_visible_schema_names(schemas, visible_schema_filter.as_deref()), ) } Ok(None) => { return Ok(filter_visible_schema_names(schemas, visible_schema_filter.as_deref())) } Err(error) => { log::warn!( "[schema][agent:list_schemas:fallback-failed] connection_id={} database={} error={}", connection_id, database, error ); } } } return Ok(filter_visible_schema_names(schemas, visible_schema_filter.as_deref())); } Err(agent_error) => { if let Some(config) = fallback_config.as_ref() { if let Some(pool) = native_postgres_metadata_pool(state, connection_id, database, config).await? { return db::postgres::list_schemas_with_system(&pool, show_system_schemas) .await .map(|schemas| filter_visible_schema_names(schemas, visible_schema_filter.as_deref())) .map_err(|fallback_error| { crate::db::agent_driver::append_legacy_error_context( &agent_error, &format!("Native PostgreSQL metadata fallback failed: {fallback_error}"), ) }); } } return Err(agent_error); } } } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, mode) if *mode == MysqlMode::OceanBaseOracle => db::ob_oracle::list_schemas(p) .await .map(|schemas| filter_visible_schema_names(schemas, visible_schema_filter.as_deref())), PoolKind::Postgres(p) => db::postgres::list_schemas_with_system(p, show_system_schemas) .await .map(|schemas| filter_visible_schema_names(schemas, visible_schema_filter.as_deref())), #[cfg(feature = "duckdb-sidecar")] PoolKind::DuckDbWorker(client) => { let database = database.to_string(); client .list_schemas(database) .await .map(|schemas| filter_visible_schema_names(schemas, visible_schema_filter.as_deref())) } _ => Ok(vec![]), } } fn visible_schema_filter( config: Option<&ConnectionConfig>, database: &str, apply_visible_filter: bool, ) -> Option> { if !apply_visible_filter { return None; } config?.visible_schemas.as_ref()?.get(database).cloned() } fn filter_visible_schema_names(schemas: Vec, visible: Option<&[String]>) -> Vec { let Some(visible) = visible else { return schemas; }; let visible: std::collections::HashSet<&str> = visible.iter().map(String::as_str).collect(); schemas.into_iter().filter(|schema| visible.contains(schema.as_str())).collect() } pub async fn list_tables_core( state: &AppState, connection_id: &str, database: &str, schema: &str, filter: Option<&str>, limit: Option, offset: Option, object_types: Option<&[String]>, table_name_filter: Option<&TableNameFilter>, ) -> Result, String> { let metadata_session = EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "tables").await; let result = retry_metadata_connection_for_session( state, connection_id, Some(database), metadata_session.client_session_id(), || { list_tables_once( state, connection_id, database, schema, filter, limit, offset, object_types, table_name_filter, metadata_session.client_session_id(), ) }, ) .await; metadata_session.finish(state, connection_id, Some(database)).await; result } /// List vector database collections, returning structured info (name, id, dimension). /// Only works for PoolKind::VectorDb connections; returns an error for other types. pub async fn list_vector_collections_core( state: &AppState, connection_id: &str, database: &str, ) -> Result, String> { let pool_key = state .get_or_create_metadata_pool_for_session( connection_id, if database.is_empty() { None } else { Some(database) }, None, ) .await?; let client = { let pool_handle = state.pool_handle(&pool_key).await; match pool_handle.as_ref() { Some(PoolKind::VectorDb(client)) => client.clone(), _ => return Err("Not a vector database connection".to_string()), } }; db::vector_driver::list_collections_with_db(&client, database).await } /// Get detailed metadata for a single vector collection (dimension, config, etc). pub async fn get_vector_collection_detail_core( state: &AppState, connection_id: &str, database: &str, collection: &str, ) -> Result { let pool_key = state .get_or_create_metadata_pool_for_session( connection_id, if database.is_empty() { None } else { Some(database) }, None, ) .await?; let client = { let pool_handle = state.pool_handle(&pool_key).await; match pool_handle.as_ref() { Some(PoolKind::VectorDb(client)) => client.clone(), _ => return Err("Not a vector database connection".to_string()), } }; db::vector_driver::get_collection_detail(&client, database, collection).await } pub async fn drop_vector_database_core(state: &AppState, connection_id: &str, database: &str) -> Result<(), String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let client = { let pool_handle = state.pool_handle(&pool_key).await; match pool_handle.as_ref() { Some(PoolKind::VectorDb(client)) => client.clone(), _ => return Err("Not a vector database connection".to_string()), } }; db::vector_driver::drop_database(&client, database).await } pub async fn drop_vector_collection_core( state: &AppState, connection_id: &str, database: &str, collection: &str, ) -> Result<(), String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let client = { let pool_handle = state.pool_handle(&pool_key).await; match pool_handle.as_ref() { Some(PoolKind::VectorDb(client)) => client.clone(), _ => return Err("Not a vector database connection".to_string()), } }; db::vector_driver::drop_collection(&client, database, collection).await } pub async fn rename_vector_collection_core( state: &AppState, connection_id: &str, database: &str, collection: &str, new_name: &str, ) -> Result<(), String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let client = { let pool_handle = state.pool_handle(&pool_key).await; match pool_handle.as_ref() { Some(PoolKind::VectorDb(client)) => client.clone(), _ => return Err("Not a vector database connection".to_string()), } }; db::vector_driver::rename_collection(&client, database, collection, new_name).await } pub async fn get_table_comment_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() { return Err("Table comments are not available for linked server tables".to_string()); } let metadata_session = EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "table-comment").await; let result = get_table_comment_core_for_session( state, connection_id, database, schema, table, metadata_session.client_session_id(), ) .await; metadata_session.finish(state, connection_id, Some(database)).await; result } pub async fn get_mysql_table_auto_increment_core( state: &AppState, connection_id: &str, database: &str, table: &str, ) -> Result, String> { let db_config = connection_config(state, connection_id).await; let native_mysql = db_config.as_ref().is_some_and(|config| { config.db_type == DatabaseType::Mysql && config .driver_profile .as_deref() .map(str::trim) .is_none_or(|profile| profile.is_empty() || profile.eq_ignore_ascii_case("mysql")) }); if !native_mysql { return Err("AUTO_INCREMENT metadata is supported only for native MySQL connections.".to_string()); } retry_metadata_connection_for_session(state, connection_id, Some(database), None, || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(pool, mode) if *mode != MysqlMode::OceanBaseOracle => { db::mysql::get_table_auto_increment(pool, database, table).await } _ => Err("AUTO_INCREMENT metadata is supported only for native MySQL connections.".to_string()), } }) .await } async fn get_table_comment_core_for_session( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, client_session_id: Option<&str>, ) -> Result, String> { retry_metadata_connection_for_session(state, connection_id, Some(database), client_session_id, || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await?; let db_config = connection_config(state, connection_id).await; { let pool_handle = state.pool_handle(&pool_key).await; try_sqlserver!(pool_handle, get_table_comment, schema, table); if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { if is_oracle_external_driver_config(config.as_ref()) { return external_driver_oracle_table_comment( session.clone(), config.as_ref(), database, schema, table, ) .await; } } if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { if db_config.as_ref().is_some_and(|config| { matches!(config.db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle) }) { let sql = oracle_table_comment_sql(schema, table); let timeout = agent_metadata_timeout(db_config.as_ref()); let mut client = client.lock().await; let result = client .execute_query_with_timeout::( agent_execute_query_params( &sql, Some(database), Some(schema), QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, ), timeout, ) .await?; return oracle_table_comment_from_query_result(result); } if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) { let timeout = agent_metadata_timeout(db_config.as_ref()); let mut client = client.lock().await; return client.get_table_comment::>(database, schema, table, timeout).await; } if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Tdengine) { let metadata_database = if schema.trim().is_empty() { database } else { schema }; let sql = tdengine_table_comment_sql(metadata_database, table); let timeout = agent_metadata_timeout(db_config.as_ref()); let mut client = client.lock().await; let result = client .execute_query_with_timeout::( agent_execute_query_params( &sql, Some(database), (!schema.trim().is_empty()).then_some(schema), QueryExecutionOptions { max_rows: Some(2), ..Default::default() }, ), timeout, ) .await?; return oracle_table_comment_from_query_result(result); } } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, mode) if *mode != MysqlMode::OceanBaseOracle && !db_config.as_ref().is_some_and(db::mysql_compatible::uses_show_metadata) && !db_config.as_ref().is_some_and(db::manticoresearch::is_config) => { db::mysql::get_table_comment(p, database, table).await } PoolKind::Postgres(p) if !db_config.as_ref().is_some_and(is_questdb_config) => { db::postgres::get_table_comment(p, schema, table).await } _ => Err("Table comment lookup is not supported for this connection".to_string()), } }) .await } fn oracle_table_comment_sql(schema: &str, table: &str) -> String { format!( "SELECT COMMENTS FROM ALL_TAB_COMMENTS WHERE OWNER = {} AND TABLE_NAME = {} AND TABLE_TYPE IN ('TABLE', 'VIEW')", sql_string(schema), sql_string(table), ) } fn tdengine_table_comment_sql(database: &str, table: &str) -> String { format!( "SELECT table_comment FROM information_schema.ins_stables WHERE db_name = {} AND stable_name = {} \ UNION ALL SELECT table_comment FROM information_schema.ins_tables WHERE db_name = {} AND table_name = {}", sql_string(database), sql_string(table), sql_string(database), sql_string(table), ) } fn tdengine_table_comments_sql(database: &str, filter: &str) -> String { let pattern = tdengine_table_comment_like_pattern(filter); format!( "SELECT stable_name, table_comment FROM information_schema.ins_stables \ WHERE db_name = {database} AND table_comment IS NOT NULL AND LOWER(table_comment) LIKE {pattern} \ UNION ALL SELECT table_name, table_comment FROM information_schema.ins_tables \ WHERE db_name = {database} AND table_comment IS NOT NULL AND LOWER(table_comment) LIKE {pattern}", database = sql_string(database), pattern = sql_string(&pattern), ) } fn tdengine_table_comment_like_pattern(filter: &str) -> String { let normalized_filter = filter.trim().to_lowercase(); if normalized_filter.is_empty() { return "%%".to_string(); } let mut pattern = String::with_capacity(TDENGINE_LIKE_PATTERN_MAX_BYTES); pattern.push('%'); for ch in normalized_filter.chars() { let fragment = match ch { '\\' | '%' | '_' => format!("\\{ch}"), _ => ch.to_string(), }; if pattern.len() + fragment.len() + 1 > TDENGINE_LIKE_PATTERN_MAX_BYTES { break; } pattern.push_str(&fragment); pattern.push('%'); } pattern } fn tdengine_table_comment_cache_key(database: &str, schema: &str, filter: &str) -> String { serde_json::json!([database, schema, filter.trim().to_lowercase()]).to_string() } fn oracle_table_comment_from_query_result(result: db::QueryResult) -> Result, String> { Ok(result .rows .first() .and_then(|row| row.iter().find_map(|value| value.as_str().map(str::to_string))) .filter(|value| !value.trim().is_empty())) } fn oracle_table_comments_sql(schema: &str, table_names: &[String]) -> Option { if table_names.is_empty() { return None; } let names = table_names.iter().map(|name| sql_string(name)).collect::>().join(", "); Some(format!( "SELECT TABLE_NAME, COMMENTS FROM ALL_TAB_COMMENTS WHERE OWNER = {} AND TABLE_NAME IN ({}) AND TABLE_TYPE IN ('TABLE', 'VIEW') AND COMMENTS IS NOT NULL", oracle_owner_filter(schema), names, )) } fn table_comments_from_query_result(result: db::QueryResult) -> HashMap { result .rows .into_iter() .filter_map(|row| { let name = row.first()?.as_str()?.to_string(); let comment = row.get(1)?.as_str()?.trim().to_string(); (!name.is_empty() && !comment.is_empty()).then_some((name, comment)) }) .collect() } // Oracle 11g can spend about a minute evaluating SYS_CONTEXT inside the ALL_SYNONYMS START WITH clause. const ORACLE_CURRENT_SCHEMA_SQL: &str = "SELECT SYS_CONTEXT('USERENV','CURRENT_SCHEMA') AS CURRENT_SCHEMA FROM DUAL"; const ORACLE_SYNONYM_MAX_DEPTH: usize = 16; #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct OracleObjectRef { owner: String, name: String, } struct OracleSynonymResolver { current: OracleObjectRef, visited: HashSet, depth: usize, } impl OracleSynonymResolver { fn new(owner: String, name: String) -> Self { let current = OracleObjectRef { owner, name }; Self { current: current.clone(), visited: HashSet::from([current]), depth: 0 } } fn current(&self) -> &OracleObjectRef { &self.current } fn include_public_synonym(&self) -> bool { self.depth == 0 } fn can_follow(&self) -> bool { self.depth < ORACLE_SYNONYM_MAX_DEPTH } fn follow(&mut self, target: OracleObjectRef) -> bool { if !self.can_follow() || !self.visited.insert(target.clone()) { return false; } self.current = target; self.depth += 1; true } } fn oracle_current_schema_from_query_result(result: db::QueryResult) -> Result { let schema_index = result .columns .iter() .position(|column| column.trim().eq_ignore_ascii_case("CURRENT_SCHEMA")) .ok_or_else(|| "Oracle current schema query did not return CURRENT_SCHEMA".to_string())?; let schema = result .rows .first() .and_then(|row| row.get(schema_index)) .and_then(serde_json::Value::as_str) .ok_or_else(|| "Oracle current schema query returned an empty schema".to_string())?; let schema = schema.trim(); if schema.is_empty() { return Err("Oracle current schema query returned an empty schema".to_string()); } Ok(schema.to_string()) } #[cfg(test)] fn oracle_columns_sql(schema: &str, table: &str) -> Result { let schema = schema.trim(); if schema.is_empty() { return Err("Oracle columns query requires a resolved schema".to_string()); } oracle_columns_sql_for_resolved_owner(&schema.to_uppercase(), table) } fn oracle_columns_sql_for_resolved_owner(owner: &str, table: &str) -> Result { if owner.trim().is_empty() { return Err("Oracle columns query requires a resolved schema".to_string()); } let owner = sql_string(owner); Ok(format!( "SELECT c.COLUMN_NAME, c.DATA_TYPE, c.NULLABLE, c.DATA_DEFAULT, \ c.DATA_LENGTH, c.DATA_PRECISION, c.DATA_SCALE, c.COLUMN_ID, \ CASE WHEN EXISTS ( \ SELECT 1 \ FROM ALL_CONS_COLUMNS cols \ JOIN ALL_CONSTRAINTS con \ ON con.OWNER = cols.OWNER \ AND con.CONSTRAINT_NAME = cols.CONSTRAINT_NAME \ AND con.CONSTRAINT_TYPE = 'P' \ WHERE cols.OWNER = c.OWNER \ AND cols.TABLE_NAME = c.TABLE_NAME \ AND cols.COLUMN_NAME = c.COLUMN_NAME \ ) THEN 1 ELSE 0 END AS IS_PK, \ ( \ SELECT cm.COMMENTS \ FROM ALL_COL_COMMENTS cm \ WHERE cm.OWNER = c.OWNER \ AND cm.TABLE_NAME = c.TABLE_NAME \ AND cm.COLUMN_NAME = c.COLUMN_NAME \ ) AS COMMENTS \ FROM ALL_TAB_COLUMNS c \ WHERE c.OWNER = {owner} \ AND c.TABLE_NAME = {table} \ ORDER BY c.COLUMN_ID", table = sql_string(table), )) } fn oracle_synonym_target_sql(owner: &str, table: &str, include_public: bool) -> Result { if owner.trim().is_empty() { return Err("Oracle synonym query requires a resolved schema".to_string()); } let owner = sql_string(owner); let owner_filter = if include_public { format!("s.OWNER IN ({owner}, 'PUBLIC')") } else { format!("s.OWNER = {owner}") }; Ok(format!( "SELECT s.TABLE_OWNER, s.TABLE_NAME \ FROM ALL_SYNONYMS s \ WHERE s.SYNONYM_NAME = {} \ AND {owner_filter} \ AND s.DB_LINK IS NULL \ ORDER BY CASE WHEN s.OWNER = {owner} THEN 0 ELSE 1 END", sql_string(table), )) } fn oracle_synonym_target_from_query_result(result: db::QueryResult) -> Option { let owner_index = result.columns.iter().position(|column| column.eq_ignore_ascii_case("TABLE_OWNER"))?; let table_index = result.columns.iter().position(|column| column.eq_ignore_ascii_case("TABLE_NAME"))?; let row = result.rows.first()?; Some(OracleObjectRef { owner: query_result_cell_string(row, owner_index)?, name: query_result_cell_string(row, table_index)?, }) } fn oracle_column_type(data_type: &str, precision: Option, scale: Option, length: Option) -> String { match data_type.to_ascii_uppercase().as_str() { "NUMBER" => match (precision, scale) { (Some(precision), Some(scale)) if scale > 0 => format!("NUMBER({precision},{scale})"), (Some(precision), _) => format!("NUMBER({precision})"), _ => "NUMBER".to_string(), }, "VARCHAR2" | "NVARCHAR2" | "CHAR" | "NCHAR" | "RAW" => match length { Some(length) => format!("{data_type}({length})"), None => data_type.to_string(), }, _ => data_type.to_string(), } } fn oracle_columns_from_query_result(result: db::QueryResult) -> Vec { result .rows .into_iter() .filter_map(|row| { let name = query_result_cell_string(&row, 0)?; let data_type = query_result_cell_string(&row, 1).unwrap_or_default(); let nullable = query_result_cell_string(&row, 2).unwrap_or_default(); let default_value = query_result_cell_string(&row, 3) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let length = query_result_cell_i64(&row, 4).and_then(|value| i32::try_from(value).ok()); let precision = query_result_cell_i64(&row, 5).and_then(|value| i32::try_from(value).ok()); let scale = query_result_cell_i64(&row, 6).and_then(|value| i32::try_from(value).ok()); let is_primary_key = query_result_cell_i64(&row, 8).unwrap_or(0) == 1; let comment = query_result_cell_string(&row, 9).filter(|value| !value.trim().is_empty()); Some(db::ColumnInfo { name, data_type: oracle_column_type(&data_type, precision, scale, length), is_nullable: nullable == "Y", column_default: default_value, is_primary_key, extra: None, comment, numeric_precision: precision, numeric_scale: scale, character_maximum_length: length, enum_values: None, ..Default::default() }) }) .collect() } async fn oracle_columns_via_sql( database: &str, schema: &str, table: &str, client: &mut db::agent_driver::AgentDriverClient, timeout_duration: Option, ) -> Result, String> { let request_schema = (!schema.trim().is_empty()).then_some(schema); let owner = if let Some(schema) = request_schema { schema.trim().to_uppercase() } else { let result = client .execute_query_with_timeout::( agent_execute_query_params( ORACLE_CURRENT_SCHEMA_SQL, if database.is_empty() { None } else { Some(database) }, None, QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, ), timeout_duration, ) .await?; oracle_current_schema_from_query_result(result)? }; let mut resolver = OracleSynonymResolver::new(owner, table.to_string()); loop { let object = resolver.current(); let sql = oracle_columns_sql_for_resolved_owner(&object.owner, &object.name)?; let result = client .execute_query_with_timeout::( agent_execute_query_params( &sql, if database.is_empty() { None } else { Some(database) }, request_schema, QueryExecutionOptions { max_rows: Some(10_000), ..Default::default() }, ), timeout_duration, ) .await?; let columns = oracle_columns_from_query_result(result); if !columns.is_empty() { return Ok(deduplicate_column_infos(columns)); } if !resolver.can_follow() { return Ok(Vec::new()); } let object = resolver.current(); let sql = oracle_synonym_target_sql(&object.owner, &object.name, resolver.include_public_synonym())?; let result = client .execute_query_with_timeout::( agent_execute_query_params( &sql, if database.is_empty() { None } else { Some(database) }, request_schema, QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, ), timeout_duration, ) .await?; let Some(target) = oracle_synonym_target_from_query_result(result) else { return Ok(Vec::new()); }; if !resolver.follow(target) { return Ok(Vec::new()); } } } async fn external_driver_oracle_columns_via_sql( session: Arc, config: &ConnectionConfig, database: &str, schema: &str, table: &str, ) -> Result, String> { let request_schema = if schema.trim().is_empty() { "" } else { schema }; let owner = if request_schema.is_empty() { let result: db::QueryResult = session .invoke_with_timeout( "executeQuery", serde_json::json!({ "connection": config, "database": database, "schema": request_schema, "sql": ORACLE_CURRENT_SCHEMA_SQL, "maxRows": 1 }), agent_metadata_timeout(Some(config)), ) .await?; oracle_current_schema_from_query_result(result)? } else { schema.trim().to_uppercase() }; let mut resolver = OracleSynonymResolver::new(owner, table.to_string()); loop { let object = resolver.current(); let sql = oracle_columns_sql_for_resolved_owner(&object.owner, &object.name)?; let result: db::QueryResult = session .invoke_with_timeout( "executeQuery", serde_json::json!({ "connection": config, "database": database, "schema": request_schema, "sql": sql, "maxRows": 10_000 }), agent_metadata_timeout(Some(config)), ) .await?; let columns = oracle_columns_from_query_result(result); if !columns.is_empty() { return Ok(deduplicate_column_infos(columns)); } if !resolver.can_follow() { return Ok(Vec::new()); } let object = resolver.current(); let sql = oracle_synonym_target_sql(&object.owner, &object.name, resolver.include_public_synonym())?; let result: db::QueryResult = session .invoke_with_timeout( "executeQuery", serde_json::json!({ "connection": config, "database": database, "schema": request_schema, "sql": sql, "maxRows": 1 }), agent_metadata_timeout(Some(config)), ) .await?; let Some(target) = oracle_synonym_target_from_query_result(result) else { return Ok(Vec::new()); }; if !resolver.follow(target) { return Ok(Vec::new()); } } } fn should_query_oracle_columns_via_sql_first(db_type: &DatabaseType, client_session_id: Option<&str>) -> bool { *db_type == DatabaseType::Oracle && client_session_id.is_some_and(|session_id| !session_id.trim().is_empty()) } fn oracle_object_statistics_sql(schema: &str) -> String { oracle_object_statistics_owner_segments_sql(schema, "ALL_SEGMENTS") } fn oracle_object_statistics_dba_segments_sql(schema: &str) -> String { oracle_object_statistics_owner_segments_sql(schema, "DBA_SEGMENTS") } fn oracle_object_statistics_owner_segments_sql(schema: &str, segment_view: &str) -> String { format!( "SELECT t.TABLE_NAME, t.OWNER, t.NUM_ROWS, NVL(s.BYTES, 0) AS TOTAL_BYTES \ FROM ALL_TABLES t \ LEFT JOIN ( \ SELECT owner, table_name, SUM(bytes) AS BYTES \ FROM ( \ SELECT s.OWNER, s.SEGMENT_NAME AS TABLE_NAME, s.BYTES \ FROM {segment_view} s \ WHERE s.OWNER = {} AND s.SEGMENT_TYPE IN ('TABLE','TABLE PARTITION','TABLE SUBPARTITION') \ UNION ALL \ SELECT i.TABLE_OWNER AS OWNER, i.TABLE_NAME, s.BYTES \ FROM ALL_INDEXES i \ JOIN {segment_view} s ON s.OWNER = i.OWNER AND s.SEGMENT_NAME = i.INDEX_NAME \ WHERE i.TABLE_OWNER = {} AND s.SEGMENT_TYPE IN ('INDEX','INDEX PARTITION','INDEX SUBPARTITION') \ UNION ALL \ SELECT l.OWNER, l.TABLE_NAME, s.BYTES \ FROM ALL_LOBS l \ JOIN {segment_view} s ON s.OWNER = l.OWNER AND s.SEGMENT_NAME IN (l.SEGMENT_NAME, l.INDEX_NAME) \ WHERE l.OWNER = {} AND s.SEGMENT_TYPE IN ('LOBSEGMENT','LOB PARTITION','LOB SUBPARTITION','LOBINDEX') \ ) \ GROUP BY owner, table_name \ ) s ON s.OWNER = t.OWNER AND s.TABLE_NAME = t.TABLE_NAME \ WHERE t.OWNER = {} AND t.NESTED = 'NO' \ ORDER BY t.TABLE_NAME", oracle_owner_filter(schema), oracle_owner_filter(schema), oracle_owner_filter(schema), oracle_owner_filter(schema), ) } fn oracle_object_statistics_user_segments_sql(schema: &str) -> String { // USER_SEGMENTS exposes objects owned by the login/current user, while DBX // may switch CURRENT_SCHEMA before metadata queries for cross-schema browsing. format!( "SELECT t.TABLE_NAME, t.OWNER, t.NUM_ROWS, NVL(s.BYTES, 0) AS TOTAL_BYTES \ FROM ALL_TABLES t \ LEFT JOIN ( \ SELECT table_name, SUM(bytes) AS BYTES \ FROM ( \ SELECT s.SEGMENT_NAME AS TABLE_NAME, s.BYTES \ FROM USER_SEGMENTS s \ WHERE s.SEGMENT_TYPE IN ('TABLE','TABLE PARTITION','TABLE SUBPARTITION') \ UNION ALL \ SELECT i.TABLE_NAME, s.BYTES \ FROM ALL_INDEXES i \ JOIN USER_SEGMENTS s ON s.SEGMENT_NAME = i.INDEX_NAME \ WHERE i.TABLE_OWNER = {} AND s.SEGMENT_TYPE IN ('INDEX','INDEX PARTITION','INDEX SUBPARTITION') \ UNION ALL \ SELECT l.TABLE_NAME, s.BYTES \ FROM ALL_LOBS l \ JOIN USER_SEGMENTS s ON s.SEGMENT_NAME IN (l.SEGMENT_NAME, l.INDEX_NAME) \ WHERE l.OWNER = {} AND s.SEGMENT_TYPE IN ('LOBSEGMENT','LOB PARTITION','LOB SUBPARTITION','LOBINDEX') \ ) \ GROUP BY table_name \ ) s ON s.TABLE_NAME = t.TABLE_NAME \ WHERE t.OWNER = {} AND t.OWNER = USER AND t.NESTED = 'NO' \ ORDER BY t.TABLE_NAME", oracle_owner_filter(schema), oracle_owner_filter(schema), oracle_owner_filter(schema), ) } fn oracle_object_statistics_rows_only_sql(schema: &str) -> String { format!( "SELECT t.TABLE_NAME, t.OWNER, t.NUM_ROWS, CAST(NULL AS NUMBER) AS TOTAL_BYTES \ FROM ALL_TABLES t \ WHERE t.OWNER = {} AND t.NESTED = 'NO' \ ORDER BY t.TABLE_NAME", oracle_owner_filter(schema), ) } fn dameng_object_statistics_dba_segments_sql(schema: &str) -> String { format!( "SELECT t.TABLE_NAME, t.OWNER, t.NUM_ROWS, NVL(s.BYTES, 0) AS TOTAL_BYTES \ FROM ALL_TABLES t \ LEFT JOIN ( \ SELECT owner, table_name, SUM(bytes) AS BYTES \ FROM ( \ SELECT s.OWNER, s.SEGMENT_NAME AS TABLE_NAME, s.BYTES \ FROM DBA_SEGMENTS s \ WHERE s.OWNER = {} AND s.SEGMENT_TYPE IN ('TABLE','TABLE PARTITION','TABLE SUBPARTITION') \ UNION ALL \ SELECT i.TABLE_OWNER AS OWNER, i.TABLE_NAME, s.BYTES \ FROM ALL_INDEXES i \ JOIN DBA_SEGMENTS s ON s.OWNER = i.OWNER AND s.SEGMENT_NAME = i.INDEX_NAME \ WHERE i.TABLE_OWNER = {} AND s.SEGMENT_TYPE IN ('INDEX','INDEX PARTITION','INDEX SUBPARTITION') \ ) \ GROUP BY owner, table_name \ ) s ON s.OWNER = t.OWNER AND s.TABLE_NAME = t.TABLE_NAME \ WHERE t.OWNER = {} AND (t.NESTED IS NULL OR t.NESTED = 'NO') \ ORDER BY t.TABLE_NAME", oracle_owner_filter(schema), oracle_owner_filter(schema), oracle_owner_filter(schema), ) } fn dameng_object_statistics_user_segments_sql(schema: &str) -> String { format!( "SELECT t.TABLE_NAME, t.OWNER, t.NUM_ROWS, NVL(s.BYTES, 0) AS TOTAL_BYTES \ FROM ALL_TABLES t \ LEFT JOIN ( \ SELECT table_name, SUM(bytes) AS BYTES \ FROM ( \ SELECT s.SEGMENT_NAME AS TABLE_NAME, s.BYTES \ FROM USER_SEGMENTS s \ WHERE s.SEGMENT_TYPE IN ('TABLE','TABLE PARTITION','TABLE SUBPARTITION') \ UNION ALL \ SELECT i.TABLE_NAME, s.BYTES \ FROM ALL_INDEXES i \ JOIN USER_SEGMENTS s ON s.SEGMENT_NAME = i.INDEX_NAME \ WHERE i.TABLE_OWNER = {} AND s.SEGMENT_TYPE IN ('INDEX','INDEX PARTITION','INDEX SUBPARTITION') \ ) \ GROUP BY table_name \ ) s ON s.TABLE_NAME = t.TABLE_NAME \ WHERE t.OWNER = {} AND t.OWNER = USER AND (t.NESTED IS NULL OR t.NESTED = 'NO') \ ORDER BY t.TABLE_NAME", oracle_owner_filter(schema), oracle_owner_filter(schema), ) } fn dameng_object_statistics_rows_only_sql(schema: &str) -> String { format!( "SELECT t.TABLE_NAME, t.OWNER, t.NUM_ROWS, CAST(NULL AS NUMBER) AS TOTAL_BYTES \ FROM ALL_TABLES t \ WHERE t.OWNER = {} AND (t.NESTED IS NULL OR t.NESTED = 'NO') \ ORDER BY t.TABLE_NAME", oracle_owner_filter(schema), ) } /// One attempt of an object-statistics fallback chain: a log label, the SQL to /// run, and whether an empty (but successful) result should be accepted instead /// of falling through to the next attempt. type ObjectStatisticsAttempt = (&'static str, String, bool); fn oracle_object_statistics_query_plan(schema: &str) -> Vec { vec![ ("all-segments", oracle_object_statistics_sql(schema), true), ("dba-segments", oracle_object_statistics_dba_segments_sql(schema), true), ("user-segments", oracle_object_statistics_user_segments_sql(schema), false), ("rows-only", oracle_object_statistics_rows_only_sql(schema), true), ] } fn dameng_object_statistics_query_plan(schema: &str) -> Vec { vec![ ("dba-segments", dameng_object_statistics_dba_segments_sql(schema), true), ("user-segments", dameng_object_statistics_user_segments_sql(schema), false), ("rows-only", dameng_object_statistics_rows_only_sql(schema), true), ] } fn gbase8a_object_statistics_sql(database: &str) -> String { format!( "SELECT TABLE_NAME, TABLE_SCHEMA, TABLE_ROWS, \ COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0) AS TOTAL_BYTES \ FROM information_schema.TABLES \ WHERE TABLE_SCHEMA = {} AND TABLE_TYPE <> 'VIEW' \ ORDER BY TABLE_NAME", sql_string(database), ) } fn query_result_cell_i64(row: &[serde_json::Value], index: usize) -> Option { let value = row.get(index)?; if value.is_null() { return None; } value .as_i64() .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) .or_else(|| value.as_f64().map(|value| value as i64)) .or_else(|| value.as_str()?.trim().parse::().ok()) } fn oracle_object_statistics_from_query_result(result: db::QueryResult) -> Vec { result .rows .into_iter() .filter_map(|row| { let name = query_result_cell_string(&row, 0)?; if name.trim().is_empty() { return None; } Some(db::ObjectStatistics { name, schema: query_result_cell_string(&row, 1), estimated_rows: query_result_cell_i64(&row, 2), total_bytes: query_result_cell_i64(&row, 3), }) }) .collect() } fn comment_is_blank(comment: &Option) -> bool { comment.as_deref().map(str::trim).unwrap_or("").is_empty() } fn oracle_table_info_can_have_comment(table: &db::TableInfo) -> bool { oracle_type_is_table_or_view(&table.table_type) } fn oracle_object_info_can_have_table_comment(object: &db::ObjectInfo) -> bool { oracle_type_is_table_or_view(&object.object_type) } fn oracle_type_is_table_or_view(value: &str) -> bool { let normalized = value.to_ascii_uppercase().replace([' ', '-'], "_"); matches!(normalized.as_str(), "TABLE" | "BASE_TABLE" | "VIEW") } fn oracle_missing_table_comment_names(tables: &[db::TableInfo]) -> Vec { unique_oracle_comment_names( tables .iter() .filter(|table| oracle_table_info_can_have_comment(table) && comment_is_blank(&table.comment)) .map(|table| table.name.as_str()), ) } fn oracle_missing_object_table_comment_names(objects: &[db::ObjectInfo]) -> Vec { unique_oracle_comment_names( objects .iter() .filter(|object| oracle_object_info_can_have_table_comment(object) && comment_is_blank(&object.comment)) .map(|object| object.name.as_str()), ) } fn unique_oracle_comment_names<'a>(names: impl Iterator) -> Vec { let mut seen = std::collections::HashSet::new(); let mut unique = Vec::new(); for name in names { let name = name.trim(); if name.is_empty() || !seen.insert(name.to_string()) { continue; } unique.push(name.to_string()); } unique } fn apply_table_comments(tables: &mut [db::TableInfo], comments: &HashMap) { for table in tables { if !comment_is_blank(&table.comment) { continue; } if let Some(comment) = oracle_comment_for_name(comments, &table.name) { table.comment = Some(comment.clone()); } } } fn apply_oracle_object_table_comments(objects: &mut [db::ObjectInfo], comments: &HashMap) { for object in objects { if !comment_is_blank(&object.comment) { continue; } if let Some(comment) = oracle_comment_for_name(comments, &object.name) { object.comment = Some(comment.clone()); } } } fn oracle_comment_for_name<'a>(comments: &'a HashMap, name: &str) -> Option<&'a String> { comments .get(name) .or_else(|| comments.iter().find(|(key, _)| key.eq_ignore_ascii_case(name)).map(|(_, value)| value)) } async fn oracle_table_comments_for_names( client: &mut db::agent_driver::AgentDriverClient, database: &str, schema: &str, table_names: &[String], timeout_duration: Option, ) -> Result, String> { let mut comments = HashMap::new(); for chunk in table_names.chunks(ORACLE_TABLE_COMMENT_BATCH_SIZE) { let Some(sql) = oracle_table_comments_sql(schema, chunk) else { continue; }; let result = client .execute_query_with_timeout::( agent_execute_query_params( &sql, if database.is_empty() { None } else { Some(database) }, if schema.is_empty() { None } else { Some(schema) }, QueryExecutionOptions { max_rows: Some(chunk.len()), ..Default::default() }, ), timeout_duration, ) .await?; comments.extend(table_comments_from_query_result(result)); } Ok(comments) } async fn load_oracle_table_comments_for_tables( client: &mut db::agent_driver::AgentDriverClient, database: &str, schema: &str, tables: &mut [db::TableInfo], timeout_duration: Option, ) -> Result<(), String> { let table_names = oracle_missing_table_comment_names(tables); if table_names.is_empty() { return Ok(()); } let comments = oracle_table_comments_for_names(client, database, schema, &table_names, timeout_duration).await?; apply_table_comments(tables, &comments); Ok(()) } async fn load_tdengine_table_comments_for_filter( client: &mut db::agent_driver::AgentDriverClient, database: &str, schema: &str, filter: &str, tables: &mut [db::TableInfo], ) -> Result<(), String> { let metadata_database = if schema.trim().is_empty() { database } else { schema }; let sql = tdengine_table_comments_sql(metadata_database, filter); let cache_key = tdengine_table_comment_cache_key(database, schema, filter); let result = client .execute_query_cached_with_timeout::( cache_key, TDENGINE_COMMENT_SEARCH_CACHE_TTL, agent_execute_query_params( &sql, if database.is_empty() { None } else { Some(database) }, if schema.is_empty() { None } else { Some(schema) }, QueryExecutionOptions::default(), ), Some(TDENGINE_COMMENT_SEARCH_TIMEOUT), ) .await?; let comments = table_comments_from_query_result(result); apply_table_comments(tables, &comments); Ok(()) } async fn load_oracle_table_comments_for_objects( client: &mut db::agent_driver::AgentDriverClient, database: &str, schema: &str, objects: &mut [db::ObjectInfo], timeout_duration: Option, ) -> Result<(), String> { let table_names = oracle_missing_object_table_comment_names(objects); if table_names.is_empty() { return Ok(()); } let comments = oracle_table_comments_for_names(client, database, schema, &table_names, timeout_duration).await?; apply_oracle_object_table_comments(objects, &comments); Ok(()) } /// Runs an object-statistics fallback chain against an arbitrary query /// transport, so the agent drivers and generic JDBC connections /// (`PoolKind::ExternalDriver`) can share the same vendor SQL and the same /// "try the next catalog view" behaviour. async fn object_statistics_from_query_plan( vendor: &str, schema: &str, plan: Vec, mut run: F, ) -> Result, String> where F: FnMut(String) -> Fut, Fut: std::future::Future>, { let mut last_error = None; for (source, sql, accept_empty) in plan { match run(sql).await { Ok(result) if accept_empty || !result.rows.is_empty() => { return Ok(oracle_object_statistics_from_query_result(result)); } Ok(_) => { log::debug!("[schema][{vendor}:list_object_statistics:empty-fallback] schema={schema} source={source}"); } Err(error) => { log::debug!( "[schema][{vendor}:list_object_statistics:fallback-failed] schema={schema} source={source} error={error}" ); last_error = Some(error); } } } Err(last_error.unwrap_or_else(|| format!("{vendor} object statistics are unavailable"))) } async fn oracle_agent_list_object_statistics( client: Arc, database: &str, schema: &str, timeout_duration: Option, ) -> Result, String> { object_statistics_from_query_plan("Oracle", schema, oracle_object_statistics_query_plan(schema), |sql| { let client = client.clone(); async move { let mut client = client.lock().await; agent_object_statistics_query(&mut client, database, schema, &sql, timeout_duration).await } }) .await } async fn dameng_agent_list_object_statistics( client: Arc, database: &str, schema: &str, timeout_duration: Option, ) -> Result, String> { object_statistics_from_query_plan("Dameng", schema, dameng_object_statistics_query_plan(schema), |sql| { let client = client.clone(); async move { let mut client = client.lock().await; agent_object_statistics_query(&mut client, database, schema, &sql, timeout_duration).await } }) .await } async fn agent_list_object_statistics( client: Arc, database: &str, schema: &str, sql: String, timeout_duration: Option, ) -> Result, String> { let mut client = client.lock().await; let result = agent_object_statistics_query(&mut client, database, schema, &sql, timeout_duration).await?; Ok(oracle_object_statistics_from_query_result(result)) } /// Vendors whose object statistics can be collected over a generic JDBC /// connection (`PoolKind::ExternalDriver`). /// /// The bundled JDBC plugin exposes no `listObjectStatistics` RPC, but the /// vendor statistics SQL is plain SQL that runs fine through `executeQuery`, so /// the only missing piece is recognising which database sits behind the JDBC /// URL. The prefixes mirror `DbxJdbcPlugin.driverQuirks`, which picks its own /// metadata dialect the same way. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum ExternalDriverStatisticsDialect { Oracle, Dameng, Kingbase, } fn external_driver_statistics_dialect(config: &ConnectionConfig) -> Option { // `is_oracle_external_driver_config` (added alongside the JDBC Oracle DDL // fix) checks both the `jdbc:oracle:` URL prefix and the driver class, so // it recognises more real-world configs than a URL-only sniff would. if config.db_type == DatabaseType::Oracle || is_oracle_external_driver_config(config) { return Some(ExternalDriverStatisticsDialect::Oracle); } match config.db_type { DatabaseType::Dameng => return Some(ExternalDriverStatisticsDialect::Dameng), DatabaseType::Kingbase => return Some(ExternalDriverStatisticsDialect::Kingbase), // Generic JDBC connections carry no vendor in `db_type`; sniff the URL. DatabaseType::Jdbc => {} _ => return None, } let url = config.connection_string.as_deref().map(str::trim).filter(|url| !url.is_empty())?.to_ascii_lowercase(); if url.starts_with("jdbc:dm:") { Some(ExternalDriverStatisticsDialect::Dameng) } else if url.starts_with("jdbc:kingbase") { Some(ExternalDriverStatisticsDialect::Kingbase) } else { None } } fn external_driver_statistics_query_plan( dialect: ExternalDriverStatisticsDialect, schema: &str, ) -> Vec { match dialect { ExternalDriverStatisticsDialect::Oracle => oracle_object_statistics_query_plan(schema), ExternalDriverStatisticsDialect::Dameng => dameng_object_statistics_query_plan(schema), ExternalDriverStatisticsDialect::Kingbase => { vec![("catalog", kingbase::object_statistics_sql(schema), true)] } } } fn external_driver_statistics_vendor(dialect: ExternalDriverStatisticsDialect) -> &'static str { match dialect { ExternalDriverStatisticsDialect::Oracle => "Oracle", ExternalDriverStatisticsDialect::Dameng => "Dameng", ExternalDriverStatisticsDialect::Kingbase => "Kingbase", } } async fn external_driver_list_object_statistics( session: Arc, config: &ConnectionConfig, dialect: ExternalDriverStatisticsDialect, database: &str, schema: &str, ) -> Result, String> { let timeout_duration = agent_metadata_timeout(Some(config)); object_statistics_from_query_plan( external_driver_statistics_vendor(dialect), schema, external_driver_statistics_query_plan(dialect, schema), |sql| { let session = session.clone(); async move { session .invoke_with_timeout( "executeQuery", serde_json::json!({ "connection": config, "database": database, "schema": schema, "sql": sql, "maxRows": 10_000, "fetchSize": 1000, }), timeout_duration, ) .await } }, ) .await } async fn agent_object_statistics_query( client: &mut db::agent_driver::AgentDriverClient, database: &str, schema: &str, sql: &str, timeout_duration: Option, ) -> Result { client .execute_query_with_timeout( agent_execute_query_params( sql, if database.is_empty() { None } else { Some(database) }, if schema.is_empty() { None } else { Some(schema) }, QueryExecutionOptions { max_rows: Some(10_000), ..Default::default() }, ), timeout_duration, ) .await } #[cfg(feature = "mq-admin")] fn message_queue_topic_tables(topics: Vec) -> Vec { topics .into_iter() .map(|topic| db::TableInfo { name: topic.name, table_type: "TOPIC".to_string(), valid: None, comment: None, parent_schema: topic.namespace, parent_name: None, }) .collect() } async fn list_tables_once( state: &AppState, connection_id: &str, database: &str, schema: &str, filter: Option<&str>, limit: Option, offset: Option, object_types: Option<&[String]>, table_name_filter: Option<&TableNameFilter>, client_session_id: Option<&str>, ) -> Result, String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await?; let db_config = connection_config(state, connection_id).await; #[cfg(feature = "mq-admin")] if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::MessageQueue) { let topics = crate::mq::service::mq_list_topics_core( state, connection_id, crate::mq::NamespaceRef { tenant: database.to_string(), namespace: schema.to_string() }, crate::mq::ListTopicsOpts::default(), ) .await?; return Ok(filter_table_infos( message_queue_topic_tables(topics), filter, limit, offset, object_types, table_name_filter, )); } { let pool_handle = state.pool_handle(&pool_key).await; if let Some(PoolKind::ExternalDriver { driver_id, config, session }) = pool_handle.as_ref() { let driver_id = driver_id.clone(); let config = config.clone(); let session = session.clone(); if uses_presto_like_information_schema_tables(&config.db_type) { let force_local_table_name_filter = table_name_filter.is_some_and(|filter| !filter.is_empty()); return external_driver_presto_like_tables( session, config.as_ref(), database, schema, filter, if force_local_table_name_filter { None } else { limit }, if force_local_table_name_filter { None } else { offset }, ) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)); } let mut params = serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": schema }); if let Some(filter) = filter.map(str::trim).filter(|value| !value.is_empty()) { params["filter"] = serde_json::json!(filter); } if let Some(object_types) = object_types { params["object_types"] = serde_json::json!(object_types); } if let Some(limit) = limit { params["limit"] = serde_json::json!(limit); } if let Some(offset) = offset { params["offset"] = serde_json::json!(offset); } return session .invoke_with_timeout::>( "listTables", params, agent_metadata_timeout(Some(config.as_ref())), ) .await .map(|tables| { let final_offset = if external_driver_paging_likely_applied(&driver_id, limit, tables.len()) { Some(0) } else { offset }; filter_table_infos(tables, filter, limit, final_offset, object_types, table_name_filter) }); } #[cfg(feature = "duckdb-sidecar")] if let Some(client) = extract_pool!(pool_handle.as_ref(), DuckDbWorker) { let database = database.to_string(); let schema = schema.to_string(); return client .list_tables(database, schema) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)); } if let Some(client) = extract_pool!(pool_handle.as_ref(), ClickHouse) { if requests_table_objects_only(object_types) && table_name_filter.is_none_or(TableNameFilter::is_empty) { return db::clickhouse_driver::list_table_objects_filtered( &client, clickhouse_metadata_database(database, schema), filter, limit, offset, ) .await .map(|tables| filter_table_infos(tables, None, None, None, object_types, None)); } return db::clickhouse_driver::list_tables(&client, clickhouse_metadata_database(database, schema)) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)); } if let Some(client) = extract_pool!(pool_handle.as_ref(), InfluxDb) { return db::influxdb_driver::list_tables(&client, database) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)); } if let Some(client) = extract_pool!(pool_handle.as_ref(), InfluxDb3) { return db::influxdb3_driver::list_tables(&client, database) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)); } if let Some(client) = extract_pool!(pool_handle.as_ref(), VictoriaMetrics) { return db::victoriametrics_driver::list_tables(&client) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)); } if let Some(linked) = crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema) { if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::list_linked_server_tables( &mut client, &linked.server, &linked.catalog, &linked.schema, filter, None, None, ) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)); } } if requests_table_objects_only(object_types) && table_name_filter.is_none_or(TableNameFilter::is_empty) { if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::list_table_objects(&mut client, schema, filter, limit, offset).await; } } if object_types.is_some() || table_name_filter.is_some_and(|filter| !filter.is_empty()) { if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::list_tables(&mut client, schema, filter, None, None) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)); } } try_sqlserver!(pool_handle, list_tables, schema, filter, limit, offset); if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let use_mongodb_collection_listing = uses_mongodb_agent_collection_listing(db_config.as_ref()); let is_oracle = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle); let is_tdengine = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Tdengine); let use_agent_table_paging = db_config.as_ref().is_some_and(supports_agent_table_paging); let filter_locally_after_oracle_comments = is_oracle && filter.is_some_and(|filter| !filter.trim().is_empty()); let filter_locally_after_tdengine_comments = is_tdengine && filter.is_some_and(|filter| !filter.trim().is_empty()); let filter_locally_after_comments = filter_locally_after_oracle_comments || filter_locally_after_tdengine_comments; let timeout_duration = agent_metadata_timeout(db_config.as_ref()); let fallback_config = db_config.clone(); let mut client = client.lock().await; if use_mongodb_collection_listing { let collection_names = client.mongo_list_collections::>(database).await?; return Ok(filter_mongodb_agent_collections( collection_names, filter, limit, offset, object_types, table_name_filter, )); } let agent_filter = if filter_locally_after_comments { None } else { filter }; let force_local_table_name_filter = table_name_filter.is_some_and(|filter| !filter.is_empty()); let agent_limit = if filter_locally_after_comments || force_local_table_name_filter { None } else if use_agent_table_paging { limit } else { None }; let agent_offset = if filter_locally_after_comments || force_local_table_name_filter { None } else if use_agent_table_paging { offset } else { None }; match client .list_tables_constrained::>( database, schema, agent_filter, agent_limit, agent_offset, object_types, timeout_duration, ) .await { Ok(mut tables) if !tables.is_empty() => { if is_oracle { load_oracle_table_comments_for_tables( &mut client, database, schema, &mut tables, timeout_duration, ) .await?; } if filter_locally_after_tdengine_comments { if let Err(error) = load_tdengine_table_comments_for_filter( &mut client, database, schema, filter.expect("TDengine comment filtering requires a non-empty filter"), &mut tables, ) .await { // TDengine 2.x can lack the information_schema views. SHOW // metadata remains usable, so preserve name filtering when // the optional comment lookup is unavailable or times out. log::warn!( "[schema][tdengine:list_tables:comment-search-failed] connection_id={} database={} schema={} error={}", connection_id, database, schema, error ); } } let final_offset = if filter_locally_after_comments || force_local_table_name_filter { offset } else if agent_paging_likely_applied(use_agent_table_paging, limit, tables.len()) { Some(0) } else { offset }; let tables = filter_table_infos(tables, filter, limit, final_offset, object_types, table_name_filter); return Ok(tables); } Ok(tables) => { if let Some(config) = fallback_config.as_ref() { match native_postgres_metadata_pool(state, connection_id, database, config).await { Ok(Some(pool)) => { return if object_types.is_some() { db::postgres::list_tables_filtered(&pool, schema, filter, None, None).await.map( |tables| { filter_table_infos( tables, filter, limit, offset, object_types, table_name_filter, ) }, ) } else { db::postgres::list_tables_filtered(&pool, schema, filter, limit, offset).await }; } Ok(None) => { return Ok(filter_table_infos( tables, filter, limit, offset, object_types, table_name_filter, )) } Err(error) => { log::warn!( "[schema][agent:list_tables:fallback-failed] connection_id={} database={} schema={} error={}", connection_id, database, schema, error ); } } } return Ok(filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)); } Err(agent_error) => { if let Some(config) = fallback_config.as_ref() { if let Some(pool) = native_postgres_metadata_pool(state, connection_id, database, config).await? { let result = if object_types.is_some() { db::postgres::list_tables_filtered(&pool, schema, filter, None, None).await.map( |tables| { filter_table_infos( tables, filter, limit, offset, object_types, table_name_filter, ) }, ) } else { db::postgres::list_tables_filtered(&pool, schema, filter, limit, offset).await }; return result.map_err(|fallback_error| { crate::db::agent_driver::append_legacy_error_context( &agent_error, &format!("Native PostgreSQL metadata fallback failed: {fallback_error}"), ) }); } } return Err(agent_error); } } } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(db::starrocks::is_config) => { db::starrocks::list_tables(p, database) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)) } PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(db::mysql_compatible::uses_show_metadata) => { db::mysql::list_tables_show(p, database) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)) } PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(db::dolt::system_tables_visible) && db::dolt::requests_system_tables( table_name_filter.map(|filter| filter.include_patterns.as_slice()), ) => { db::dolt::list_system_tables(p, mysql_table_metadata_catalog(database, schema), filter) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)) } PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(db::oceanbase_mysql::is_config) => { db::oceanbase_mysql::list_tables(p, mysql_table_metadata_catalog(database, schema)) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)) } PoolKind::Mysql(p, mode) => { if *mode == MysqlMode::OceanBaseOracle { let tables = db::ob_oracle::list_tables(p, schema).await?; Ok(filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)) } else if mysql_table_list_source_for_config(db_config.as_ref()) == MysqlTableListSource::ShowFullTables { db::mysql::list_logical_tables_show(p, mysql_table_metadata_catalog(database, schema)) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)) } else { db::mysql::list_tables_filtered( p, mysql_table_metadata_catalog(database, schema), filter, limit, offset, object_types, table_name_filter, ) .await .map(|tables| filter_table_infos(tables, None, None, None, object_types, None)) } } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { db::questdb::list_tables(p, schema) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)) } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => { if object_types.is_some() || table_name_filter.is_some_and(|filter| !filter.is_empty()) { db::cloudberry::list_tables_filtered(p, schema, filter, None, None) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)) } else { db::cloudberry::list_tables_filtered(p, schema, filter, limit, offset).await } } PoolKind::Postgres(p) => { if requests_table_objects_only(object_types) && table_name_filter.is_none_or(TableNameFilter::is_empty) { db::postgres::list_table_objects_filtered(p, schema, filter, limit, offset).await } else if object_types.is_some() || table_name_filter.is_some_and(|filter| !filter.is_empty()) { db::postgres::list_tables_filtered(p, schema, filter, None, None) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)) } else { db::postgres::list_tables_filtered(p, schema, filter, limit, offset).await } } PoolKind::Sqlite(p) => db::sqlite::list_tables(p, schema) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)), PoolKind::Rqlite(client) => db::rqlite_driver::list_tables(client, schema) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)), PoolKind::Turso(client) => db::turso_driver::list_tables(client, schema) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)), PoolKind::MongoDb(client) => db::mongo_driver::list_collections(client, database) .await .map(|names| collection_names_to_tables(names, "COLLECTION")) .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)), PoolKind::Elasticsearch(client) => db::elasticsearch_driver::list_indices(client) .await .map(|names| collection_names_to_tables(names, "INDEX")) .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)), PoolKind::Easysearch(client) => db::easysearch_driver::list_indices(client) .await .map(|names| collection_names_to_tables(names, "INDEX")) .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)), PoolKind::Meilisearch(client) => db::meilisearch_driver::list_indexes(client) .await .map(|names| collection_names_to_tables(names, "INDEX")) .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)), PoolKind::HBase(client) => db::hbase_driver::list_tables(client, database) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)), PoolKind::VectorDb(client) => db::vector_driver::list_collections(client) .await .map(|infos| collection_names_to_tables(infos.into_iter().map(|i| i.name).collect(), "COLLECTION")) .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)), PoolKind::CloudflareD1(client) => db::cloudflare_d1_driver::list_tables(client, schema) .await .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)), _ => Ok(vec![]), } } fn collection_names_to_tables(names: Vec, table_type: &str) -> Vec { names .into_iter() .map(|name| db::TableInfo { name, table_type: table_type.to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, }) .collect() } fn filter_mongodb_agent_collections( names: Vec, filter: Option<&str>, limit: Option, offset: Option, object_types: Option<&[String]>, table_name_filter: Option<&TableNameFilter>, ) -> Vec { filter_table_infos( collection_names_to_tables(names, "COLLECTION"), filter, limit, offset, object_types, table_name_filter, ) } fn filter_table_infos( tables: Vec, filter: Option<&str>, limit: Option, offset: Option, object_types: Option<&[String]>, table_name_filter: Option<&TableNameFilter>, ) -> Vec { let filter = filter.unwrap_or(""); let limit = limit.unwrap_or(usize::MAX); let offset = offset.unwrap_or(0); tables .into_iter() .filter(|table| metadata_name_or_comment_matches(&table.name, table.comment.as_deref(), filter)) .filter(|table| table_name_filter_matches(&table.name, table_name_filter)) .filter(|table| table_info_matches_object_types(table, object_types)) .skip(offset) .take(limit) .collect() } fn filter_object_infos( objects: Vec, filter: Option<&str>, limit: Option, offset: Option, object_types: Option<&[String]>, table_name_filter: Option<&TableNameFilter>, ) -> Vec { let filter = filter.unwrap_or(""); let limit = limit.unwrap_or(usize::MAX); let offset = offset.unwrap_or(0); objects .into_iter() .filter(|object| metadata_name_or_comment_matches(&object.name, object.comment.as_deref(), filter)) .filter(|object| table_name_filter_matches(&object.name, table_name_filter)) .filter(|object| object_info_matches_object_types(object, object_types)) .skip(offset) .take(limit) .collect() } fn metadata_name_or_comment_matches(name: &str, comment: Option<&str>, filter: &str) -> bool { if filter.trim().is_empty() { return true; } crate::sql::contains_or_fuzzy_match(name, filter) || comment.is_some_and(|comment| crate::sql::contains_or_fuzzy_match(comment, filter)) } fn object_info_matches_object_types(object: &db::ObjectInfo, object_types: Option<&[String]>) -> bool { let Some(object_types) = object_types else { return true; }; if object_types.is_empty() { return true; } let object_type = normalize_object_info_object_type(&object.object_type); object_types.iter().any(|expected| normalize_object_info_object_type(expected) == object_type) } fn normalize_object_info_object_type(value: &str) -> String { let upper = value.to_ascii_uppercase().replace(' ', "_"); if upper.contains("MATERIALIZED") && upper.contains("VIEW") { return "MATERIALIZED_VIEW".to_string(); } if upper == "BASE_TABLE" || upper.contains("TABLE") { return "TABLE".to_string(); } if upper.contains("VIEW") { return "VIEW".to_string(); } upper } fn table_info_matches_object_types(table: &db::TableInfo, object_types: Option<&[String]>) -> bool { let Some(object_types) = object_types else { return true; }; if object_types.is_empty() { return true; } let table_type = normalize_table_info_object_type(&table.table_type); object_types.iter().any(|object_type| normalize_table_info_object_type(object_type) == table_type) } fn normalize_table_info_object_type(value: &str) -> String { let upper = value.to_ascii_uppercase().replace(' ', "_"); if upper.contains("MATERIALIZED") && upper.contains("VIEW") { return "MATERIALIZED_VIEW".to_string(); } if upper.contains("VIEW") { return "VIEW".to_string(); } if upper.contains("COLLECTION") { return "COLLECTION".to_string(); } if upper.contains("INDEX") { return "INDEX".to_string(); } "TABLE".to_string() } fn requests_table_objects_only(object_types: Option<&[String]>) -> bool { object_types.is_some_and(|types| types.len() == 1 && normalize_table_info_object_type(&types[0]) == "TABLE") } fn uses_presto_like_information_schema_tables(db_type: &DatabaseType) -> bool { matches!(db_type, DatabaseType::PrestoSql | DatabaseType::Trino) } fn uses_mongodb_agent_collection_listing(config: Option<&ConnectionConfig>) -> bool { config.is_some_and(|config| config.db_type == DatabaseType::MongoDb) } async fn external_driver_presto_like_tables( session: Arc, config: &ConnectionConfig, database: &str, schema: &str, filter: Option<&str>, limit: Option, offset: Option, ) -> Result, String> { let query_limit = limit.map(|limit| limit.saturating_add(offset.unwrap_or(0)).max(1)).unwrap_or(100000); let result: db::QueryResult = session .invoke_with_timeout( "executeQuery", serde_json::json!({ "connection": config, "database": database, "schema": schema, "sql": presto_like_information_schema_tables_sql(database, schema, filter, Some(query_limit)), "maxRows": query_limit, "fetchSize": 1000, "timeoutSecs": 60 }), agent_metadata_timeout(Some(config)), ) .await?; Ok(presto_like_tables_from_query_result(&result)) } async fn external_driver_presto_like_objects( session: Arc, config: &ConnectionConfig, database: &str, schema: &str, filter: Option<&str>, object_types: Option<&[String]>, ) -> Result, String> { let tables = external_driver_presto_like_tables(session, config, database, schema, filter, None, None) .await .map(|tables| filter_table_infos(tables, filter, None, None, object_types, None))?; Ok(tables .into_iter() .map(|table| db::ObjectInfo { name: table.name, object_type: table.table_type, schema: Some(schema.to_string()), valid: None, signature: None, custom_type_kind: None, has_members: None, comment: table.comment, created_at: None, updated_at: None, parent_schema: table.parent_schema, parent_name: table.parent_name, trigger: None, xugu_type_members_expandable: None, }) .collect()) } async fn external_driver_presto_like_columns( session: Arc, config: &ConnectionConfig, database: &str, schema: &str, table: &str, ) -> Result, String> { let result: db::QueryResult = session .invoke( "executeQuery", serde_json::json!({ "connection": config, "database": database, "schema": schema, "sql": presto_like_information_schema_columns_sql(database, schema, table), "maxRows": 10000, "fetchSize": 1000, "timeoutSecs": 60 }), ) .await?; Ok(presto_like_columns_from_query_result(&result)) } fn presto_like_information_schema_tables_sql( database: &str, schema: &str, filter: Option<&str>, limit: Option, ) -> String { let source = if database.trim().is_empty() { "information_schema.tables".to_string() } else { format!("{}.information_schema.tables", quote_presto_like_identifier(database)) }; let mut sql = format!( "SELECT table_name, CASE table_type WHEN 'BASE TABLE' THEN 'TABLE' ELSE table_type END AS table_type \ FROM {source} \ WHERE table_schema = {} AND table_type IN ('BASE TABLE', 'VIEW')", sql_string_literal(schema) ); if let Some(filter) = filter.map(str::trim).filter(|value| !value.is_empty()) { sql.push_str(" AND lower(table_name) LIKE "); sql.push_str(&sql_string_literal(&format!("{}%", escape_presto_like_pattern(&filter.to_lowercase())))); sql.push_str(" ESCAPE '\\'"); } sql.push_str(" ORDER BY table_type, table_name"); if let Some(limit) = limit { sql.push_str(&format!(" LIMIT {}", limit.max(1))); } sql } fn presto_like_information_schema_columns_sql(database: &str, schema: &str, table: &str) -> String { let source = if database.trim().is_empty() { "information_schema.columns".to_string() } else { format!("{}.information_schema.columns", quote_presto_like_identifier(database)) }; format!( "SELECT column_name, data_type, is_nullable, column_default, comment \ FROM {source} \ WHERE table_schema = {} AND table_name = {} \ ORDER BY ordinal_position", sql_string_literal(schema), sql_string_literal(table) ) } fn presto_like_tables_from_query_result(result: &db::QueryResult) -> Vec { result .rows .iter() .filter_map(|row| { let name = query_result_cell_string(row, 0)?; if name.trim().is_empty() { return None; } Some(db::TableInfo { name, table_type: normalize_information_schema_table_type( query_result_cell_string(row, 1).as_deref().unwrap_or("TABLE"), ), valid: None, comment: None, parent_schema: None, parent_name: None, }) }) .collect() } fn presto_like_columns_from_query_result(result: &db::QueryResult) -> Vec { result .rows .iter() .filter_map(|row| { let name = query_result_cell_string(row, 0)?; if name.trim().is_empty() { return None; } let data_type = query_result_cell_string(row, 1).unwrap_or_default(); Some(db::ColumnInfo { name, // Presto/Trino do not expose precision/length columns in information_schema.columns. data_type: data_type.clone(), is_nullable: query_result_cell_string(row, 2) .map(|value| value.eq_ignore_ascii_case("YES")) .unwrap_or(true), column_default: query_result_cell_string(row, 3), is_primary_key: false, extra: None, comment: query_result_cell_string(row, 4), numeric_precision: presto_like_numeric_precision(&data_type), numeric_scale: presto_like_numeric_scale(&data_type), character_maximum_length: presto_like_character_maximum_length(&data_type), enum_values: None, ..Default::default() }) }) .collect() } fn query_result_cell_string(row: &[serde_json::Value], index: usize) -> Option { let value = row.get(index)?; if value.is_null() { return None; } value.as_str().map(ToString::to_string).or_else(|| Some(value.to_string())) } fn presto_like_numeric_precision(data_type: &str) -> Option { presto_like_type_argument(data_type, &["decimal", "numeric"], 0) } fn presto_like_numeric_scale(data_type: &str) -> Option { presto_like_type_argument(data_type, &["decimal", "numeric"], 1) } fn presto_like_character_maximum_length(data_type: &str) -> Option { presto_like_type_argument(data_type, &["char", "varchar"], 0) } fn presto_like_type_argument(data_type: &str, type_names: &[&str], index: usize) -> Option { let value = data_type.trim(); let open = value.find('(')?; let close = value[open + 1..].find(')')? + open + 1; let name = value[..open].trim().to_ascii_lowercase(); if !type_names.iter().any(|type_name| *type_name == name) { return None; } value[open + 1..close].split(',').nth(index)?.trim().parse::().ok() } fn normalize_information_schema_table_type(table_type: &str) -> String { match table_type.trim().to_ascii_uppercase().replace(' ', "_").as_str() { "BASE_TABLE" => "TABLE".to_string(), "VIEW" => "VIEW".to_string(), "MATERIALIZED_VIEW" => "MATERIALIZED_VIEW".to_string(), _ => table_type.to_string(), } } fn mysql_table_metadata_catalog<'a>(database: &'a str, schema: &'a str) -> &'a str { if schema.trim().is_empty() { database } else { schema } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum MysqlTableListSource { InformationSchema, ShowFullTables, } fn is_shardingsphere_proxy_version(version: &str) -> bool { const MARKER: &[u8] = b"shardingsphere-proxy"; version.as_bytes().windows(MARKER.len()).any(|window| window.eq_ignore_ascii_case(MARKER)) } fn mysql_table_list_source_for_config(config: Option<&ConnectionConfig>) -> MysqlTableListSource { if config.is_some_and(db::tdsql_mysql::is_config) || config .and_then(|config| config.database_info.as_ref()) .and_then(|info| info.product_version.as_deref()) .is_some_and(is_shardingsphere_proxy_version) { MysqlTableListSource::ShowFullTables } else { MysqlTableListSource::InformationSchema } } fn quote_presto_like_identifier(identifier: &str) -> String { format!("\"{}\"", identifier.replace('"', "\"\"")) } fn sql_string_literal(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } fn escape_presto_like_pattern(value: &str) -> String { value.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_") } #[cfg(test)] mod tests { use super::agent_postgres_extension_fallback_config; use super::db; use super::{ clickhouse_metadata_database, dameng_object_statistics_dba_segments_sql, dameng_object_statistics_rows_only_sql, dameng_object_statistics_user_segments_sql, deduplicate_column_infos, ephemeral_agent_metadata_session_id, external_driver_statistics_dialect, external_driver_statistics_query_plan, external_driver_uses_generic_ddl, external_driver_uses_mysql_ddl, filter_mongodb_agent_collections, filter_mysql_system_databases_for_config, filter_object_infos, filter_table_infos, filter_visible_schema_names, finalize_object_source, gaussdb_m_view_object_source_sql, gbase8a_object_statistics_sql, is_agent_postgres_metadata_fallback_config, is_mysql_external_driver_config, is_oracle_external_driver_config, is_retryable_metadata_error, metadata_error_action, metadata_name_or_comment_matches, mysql_database_list_timeout, mysql_external_driver_ddl_from_query_result, mysql_external_driver_ddl_sql, mysql_object_source_ddl_column_index, mysql_object_source_sql, mysql_table_list_source_for_config, mysql_table_metadata_catalog, normalize_information_schema_table_type, oracle_columns_from_query_result, oracle_columns_sql, oracle_columns_sql_for_resolved_owner, oracle_current_schema_from_query_result, oracle_object_statistics_dba_segments_sql, oracle_object_statistics_from_query_result, oracle_object_statistics_rows_only_sql, oracle_object_statistics_sql, oracle_object_statistics_user_segments_sql, oracle_synonym_target_from_query_result, oracle_synonym_target_sql, oracle_table_comment_from_query_result, oracle_table_comment_sql, oracle_table_comments_sql, presto_like_columns_from_query_result, presto_like_information_schema_columns_sql, presto_like_information_schema_tables_sql, presto_like_tables_from_query_result, reference_key_columns_from_indexes, reference_keys_from_indexes, replace_metadata_runtime, should_append_oracle_style_comment_ddl, should_query_oracle_columns_via_sql_first, table_comments_from_query_result, table_name_filter_matches, tdengine_table_comment_like_pattern, tdengine_table_comment_sql, tdengine_table_comments_sql, uses_mongodb_agent_collection_listing, visible_schema_filter, ExternalDriverStatisticsDialect, MetadataErrorAction, MysqlTableListSource, OracleObjectRef, OracleSynonymResolver, ReferenceKeyInfo, TableNameFilter, ORACLE_CURRENT_SCHEMA_SQL, ORACLE_SYNONYM_MAX_DEPTH, TDENGINE_COMMENT_SEARCH_TIMEOUT, TDENGINE_LIKE_PATTERN_MAX_BYTES, }; use super::{list_databases_core, list_tables_core}; use super::{ object_types_include_custom_types, object_types_include_relations, object_types_include_routines, object_types_only_custom_types, supports_custom_type_details, supports_pg_custom_type_objects, }; use crate::connection::{AppState, PoolKind}; use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType}; #[cfg(unix)] use crate::plugins::{ InstalledPlugin, PluginDriverManifest, PluginDriverSession, PluginManifest, PluginRuntimeEnv, }; use crate::storage::Storage; use std::collections::HashMap; use std::time::Duration; #[test] fn reference_keys_require_effective_unfiltered_plain_unique_columns() { let index = |name: &str, columns: &[&str], is_unique: bool, is_primary: bool| db::IndexInfo { name: name.to_string(), columns: columns.iter().map(|column| (*column).to_string()).collect(), is_unique, is_primary, filter: None, index_type: None, included_columns: None, comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }; let mut filtered = index("uq_active_code", &["active_code"], true, false); filtered.filter = Some("active = true".to_string()); let mut expression = index("uq_lower_email", &["lower(email)"], true, false); expression.key_is_expression = vec![true]; let mut composite_expression = index("uq_tenant_lower_code", &["tenant_id", "lower(code)"], true, false); composite_expression.key_is_expression = vec![false, true]; let indexes = vec![ index("clickhouse_primary", &["event_id"], false, true), index("uq_code", &["code"], true, false), index("uq_Code", &["Code"], true, false), index("uq_tenant_code", &["tenant_id", "code"], true, false), index("uq_tenant_code_duplicate", &["tenant_id", "code"], true, false), index("uq_empty", &[""], true, false), index("uq_repeated", &["tenant_id", "tenant_id"], true, false), filtered, expression, composite_expression, ]; assert_eq!( reference_keys_from_indexes(&indexes), vec![ ReferenceKeyInfo { columns: vec!["code".to_string()] }, ReferenceKeyInfo { columns: vec!["Code".to_string()] }, ReferenceKeyInfo { columns: vec!["tenant_id".to_string(), "code".to_string()] }, ] ); assert_eq!(reference_key_columns_from_indexes(&indexes), vec!["code", "Code"]); } fn oracle_current_schema_result(columns: &[&str], rows: Vec>) -> db::QueryResult { db::QueryResult { columns: columns.iter().map(|column| (*column).to_string()).collect(), column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: Vec::new(), spatial_values: Vec::new(), rows, affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), } } async fn spawn_turso_table_server() -> (String, tokio::task::JoinHandle<()>) { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { let mut saw_table_query = false; while !saw_table_query { let (mut socket, _) = listener.accept().await.unwrap(); let mut request = Vec::new(); let mut chunk = [0_u8; 4096]; let header_end = loop { if let Some(index) = request.windows(4).position(|window| window == b"\r\n\r\n") { break index + 4; } let read = socket.read(&mut chunk).await.unwrap(); assert!(read > 0, "request ended before headers were complete"); request.extend_from_slice(&chunk[..read]); }; let headers = String::from_utf8(request[..header_end].to_vec()).unwrap(); let content_length = headers .lines() .find_map(|line| { let (name, value) = line.split_once(':')?; name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::().unwrap()) }) .unwrap(); while request.len() < header_end + content_length { let read = socket.read(&mut chunk).await.unwrap(); assert!(read > 0, "request ended before body was complete"); request.extend_from_slice(&chunk[..read]); } assert!(headers.starts_with("POST /v2/pipeline HTTP/1.1")); assert!(headers.to_ascii_lowercase().contains("authorization: bearer test-token")); let request_body: serde_json::Value = serde_json::from_slice(&request[header_end..header_end + content_length]).unwrap(); let sql = request_body["requests"][0]["stmt"]["sql"].as_str().unwrap(); let is_table_query = sql.contains("sqlite_master"); saw_table_query |= is_table_query; let body = if is_table_query { r#"{"results":[{"type":"ok","response":{"type":"execute","result":{"cols":[{"name":"name","decltype":"TEXT"},{"name":"type","decltype":"TEXT"}],"rows":[[{"type":"text","value":"dbx_test_records"},{"type":"text","value":"table"}]],"rows_read":1,"rows_written":0}}}]}"# } else { r#"{"results":[{"type":"ok","response":{"type":"execute","result":{"cols":[{"name":"1","decltype":"INTEGER"}],"rows":[[{"type":"integer","value":"1"}]],"rows_read":1,"rows_written":0}}}]}"# }; let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); socket.write_all(response.as_bytes()).await.unwrap(); } }); (format!("http://{address}"), server) } async fn turso_test_state(base_url: &str) -> (AppState, std::path::PathBuf) { let dir = std::env::temp_dir().join(format!("dbx-turso-schema-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let storage = Storage::open(&dir.join("storage.db")).await.unwrap(); let state = AppState::new(storage); let mut config = test_connection_config(DatabaseType::Turso); config.database = Some("main".to_string()); config.host = base_url.to_string(); state.configs.write().await.insert(config.id.clone(), config); let client = db::turso_driver::TursoClient::new(base_url, "test-token", false, Duration::from_secs(2)).unwrap(); state .update_connection_pools(|connections| { connections.insert("test".to_string(), PoolKind::Turso(client)); }) .await; (state, dir) } fn test_column(name: &str, comment: Option<&str>, is_primary_key: bool) -> super::db::ColumnInfo { super::db::ColumnInfo { name: name.to_string(), data_type: "VARCHAR".to_string(), is_nullable: true, column_default: None, is_primary_key, extra: None, comment: comment.map(|value| value.to_string()), numeric_precision: None, numeric_scale: None, character_maximum_length: None, enum_values: None, ..Default::default() } } fn test_connection_config(db_type: DatabaseType) -> ConnectionConfig { ConnectionConfig { docs_notes_path: None, id: "test".to_string(), name: "test".to_string(), note: String::new(), db_type, driver_profile: None, driver_label: None, url_params: None, agent_java_options: Vec::new(), host: "127.0.0.1".to_string(), port: 5432, username: "user".to_string(), password: "secret".to_string(), database: Some("demo".to_string()), default_schema: None, visible_databases: None, visible_database_patterns: None, visible_schemas: None, show_system_schemas: false, attached_databases: Vec::new(), init_script: None, color: None, transport_layers: Vec::new(), connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), client_cert_path: String::new(), client_key_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, redis_connection_mode: None, redis_sentinel_master: String::new(), redis_sentinel_nodes: String::new(), redis_sentinel_username: String::new(), redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), redis_key_separator: crate::models::connection::default_redis_key_separator(), redis_scan_page_size: None, redis_database_aliases: Default::default(), redis_key_templates: Vec::new(), redis_key_grouping: None, etcd_endpoints: String::new(), gbase_server: String::new(), informix_server: String::new(), external_config: None, plugin_id: None, plugin_connection_provider: None, plugin_connection_type: None, connection_secrets: HashMap::new(), jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, save_password: true, read_only: false, is_production: false, production_databases: vec![], database_info: None, } } #[test] fn mysql_database_list_timeout_uses_configured_and_effective_bounds() { let mut config = test_connection_config(DatabaseType::Mysql); config.connect_timeout_secs = 10; assert_eq!(mysql_database_list_timeout(Some(&config)), Duration::from_secs(10)); config.connect_timeout_secs = 0; assert_eq!( mysql_database_list_timeout(Some(&config)), Duration::from_secs(crate::models::connection::default_connect_timeout_secs()) ); config.connect_timeout_secs = 500; assert_eq!(mysql_database_list_timeout(Some(&config)), Duration::from_secs(300)); assert_eq!(mysql_database_list_timeout(None), db::connection_timeout()); } #[tokio::test] async fn turso_schema_dispatch_lists_databases_and_tables() { let (base_url, server) = spawn_turso_table_server().await; let (state, dir) = turso_test_state(&base_url).await; let databases = list_databases_core(&state, "test").await.unwrap(); assert_eq!(databases.into_iter().map(|database| database.name).collect::>(), ["main"]); let tables = list_tables_core(&state, "test", "main", "main", None, None, None, None, None).await.unwrap(); assert_eq!(tables.len(), 1); assert_eq!(tables[0].name, "dbx_test_records"); assert_eq!(tables[0].table_type, "BASE TABLE"); server.await.unwrap(); drop(state); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn supports_pg_custom_type_objects_only_for_verified_family() { for db_type in [DatabaseType::Postgres, DatabaseType::OpenGauss, DatabaseType::Gaussdb] { assert!(supports_pg_custom_type_objects(&test_connection_config(db_type)), "{db_type:?}"); } for db_type in [ DatabaseType::Kingbase, DatabaseType::Vastbase, DatabaseType::Highgo, DatabaseType::Uxdb, DatabaseType::Kwdb, DatabaseType::Redshift, ] { assert!(!supports_pg_custom_type_objects(&test_connection_config(db_type)), "{db_type:?}"); } } #[test] fn supports_custom_type_details_covers_five_verified_families() { for db_type in [ DatabaseType::Postgres, DatabaseType::OpenGauss, DatabaseType::Gaussdb, DatabaseType::Kingbase, DatabaseType::Vastbase, ] { assert!(supports_custom_type_details(&test_connection_config(db_type)), "{db_type:?}"); } for db_type in [ DatabaseType::Highgo, DatabaseType::Uxdb, DatabaseType::Kwdb, DatabaseType::Redshift, DatabaseType::Mysql, DatabaseType::Xugu, ] { assert!(!supports_custom_type_details(&test_connection_config(db_type)), "{db_type:?}"); } } #[test] fn object_types_include_custom_types_only_when_unfiltered_or_type_requested() { assert!(object_types_include_custom_types(None)); assert!(object_types_include_custom_types(Some(&["TYPE".to_string()]))); assert!(object_types_include_custom_types(Some(&["type_body".to_string()]))); assert!(object_types_include_custom_types(Some(&["table".to_string(), "type".to_string()]))); assert!(!object_types_include_custom_types(Some(&["TABLE".to_string()]))); assert!(!object_types_include_custom_types(Some(&["FUNCTION".to_string()]))); } #[test] fn object_types_select_independent_catalog_branches() { assert!(object_types_include_relations(None)); assert!(object_types_include_routines(None)); assert!(object_types_include_custom_types(None)); assert!(object_types_include_relations(Some(&["TABLE".to_string()]))); assert!(object_types_include_relations(Some(&["VIEW".to_string()]))); assert!(object_types_include_relations(Some(&["SEQUENCE".to_string()]))); assert!(!object_types_include_routines(Some(&["TABLE".to_string()]))); assert!(!object_types_include_custom_types(Some(&["TABLE".to_string()]))); assert!(object_types_include_routines(Some(&["PROCEDURE".to_string()]))); assert!(object_types_include_routines(Some(&["FUNCTION".to_string()]))); assert!(!object_types_include_relations(Some(&["FUNCTION".to_string()]))); assert!(!object_types_include_custom_types(Some(&["FUNCTION".to_string()]))); assert!(object_types_include_custom_types(Some(&["TYPE".to_string()]))); assert!(!object_types_include_relations(Some(&["TYPE".to_string()]))); assert!(!object_types_include_routines(Some(&["TYPE".to_string()]))); // The sidebar type group sends the TYPE_BODY companion kind as well; // it must select the type branch alone, never relations or routines. let type_group = ["TYPE".to_string(), "TYPE_BODY".to_string()]; assert!(object_types_include_custom_types(Some(&type_group))); assert!(!object_types_include_relations(Some(&type_group))); assert!(!object_types_include_routines(Some(&type_group))); } #[test] fn object_types_only_custom_types_detects_dedicated_type_requests() { assert!(!object_types_only_custom_types(None)); assert!(object_types_only_custom_types(Some(&["TYPE".to_string()]))); assert!(object_types_only_custom_types(Some(&["TYPE".to_string(), "TYPE_BODY".to_string()]))); assert!(object_types_only_custom_types(Some(&["type_body".to_string()]))); assert!(!object_types_only_custom_types(Some(&["TYPE".to_string(), "TABLE".to_string()]))); assert!(!object_types_only_custom_types(Some(&["TABLE".to_string()]))); assert!(!object_types_only_custom_types(Some(&[]))); } #[test] fn agent_metadata_uses_unique_ephemeral_sessions_only_for_agents() { let oracle = test_connection_config(DatabaseType::Oracle); let first = ephemeral_agent_metadata_session_id(Some(&oracle), "completion-objects").unwrap(); let second = ephemeral_agent_metadata_session_id(Some(&oracle), "completion-objects").unwrap(); assert_ne!(first, second); assert!(first.starts_with("completion-objects:")); let postgres = test_connection_config(DatabaseType::Postgres); assert!(ephemeral_agent_metadata_session_id(Some(&postgres), "completion-objects").is_none()); assert!(ephemeral_agent_metadata_session_id(None, "completion-objects").is_none()); } #[test] fn mysql_table_child_metadata_prefers_schema_when_present() { assert_eq!(mysql_table_metadata_catalog("app_db", ""), "app_db"); assert_eq!(mysql_table_metadata_catalog("app_db", "tenant_db"), "tenant_db"); } #[test] fn mysql_external_driver_detection_only_accepts_standard_jdbc_signals() { let mut config = test_connection_config(DatabaseType::Jdbc); config.connection_string = Some(" jdbc:mysql://127.0.0.1:3306/demo ".to_string()); assert!(is_mysql_external_driver_config(&config)); config.jdbc_driver_class = Some("com.example.AoeMysqlDriver".to_string()); assert!(!is_mysql_external_driver_config(&config)); config.connection_string = Some("jdbc:mariadb://127.0.0.1:3306/demo".to_string()); config.jdbc_driver_class = Some("com.mysql.cj.jdbc.Driver".to_string()); assert!(!is_mysql_external_driver_config(&config)); config.connection_string = None; assert!(is_mysql_external_driver_config(&config)); config.jdbc_driver_class = Some("org.mariadb.jdbc.Driver".to_string()); assert!(!is_mysql_external_driver_config(&config)); } #[cfg(unix)] #[tokio::test] async fn mysql_external_driver_ddl_falls_back_to_generic_source() { use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!("dbx-mysql-wrapper-ddl-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let executable = dir.join("plugin.sh"); let calls = dir.join("calls.log"); std::fs::write( &executable, format!( r#"#!/bin/sh while IFS= read -r line; do id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\1/') case "$line" in *'"method":"executeQuery"'*) echo executeQuery >> '{}' printf '{{"id":%s,"error":{{"message":"SHOW CREATE TABLE is not supported"}}}}\n' "$id" ;; *'"method":"getObjectSource"'*) echo getObjectSource >> '{}' printf '{{"id":%s,"result":{{"source":"CREATE TABLE fallback_ddl"}}}}\n' "$id" ;; esac done "#, calls.display(), calls.display() ), ) .unwrap(); let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); permissions.set_mode(0o755); std::fs::set_permissions(&executable, permissions).unwrap(); let plugin = InstalledPlugin { manifest: PluginManifest { id: "jdbc".to_string(), name: "JDBC".to_string(), version: "test".to_string(), protocol_version: 1, description: String::new(), executable: Some("plugin.sh".to_string()), drivers: vec![PluginDriverManifest { id: "jdbc".to_string(), label: "JDBC".to_string(), kind: "external".to_string(), database_type: Some("jdbc".to_string()), }], ..PluginManifest::default() }, path: dir.clone(), compatibility: crate::plugins::PluginCompatibility { compatible: true, backend_executable: Some(dir.join("plugin.sh")), ..Default::default() }, }; let session = std::sync::Arc::new( PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default()).await.unwrap(), ); let storage = Storage::open(&dir.join("storage.db")).await.unwrap(); let state = AppState::new(storage); let mut config = test_connection_config(DatabaseType::Jdbc); config.id = "mysql-wrapper".to_string(); config.database = Some("demo".to_string()); config.connection_string = Some("jdbc:mysql://127.0.0.1:3306/demo".to_string()); config.jdbc_driver_class = Some("com.example.AoeMysqlDriver".to_string()); state.configs.write().await.insert(config.id.clone(), config.clone()); state .update_connection_pools(|connections| { connections.insert( "mysql-wrapper".to_string(), PoolKind::ExternalDriver { driver_id: "jdbc".to_string(), config: std::sync::Arc::new(config), session, }, ); }) .await; let ddl = super::get_table_ddl_core(&state, "mysql-wrapper", "demo", "", "orders", None) .await .expect("generic DDL should be returned after SHOW CREATE TABLE fails"); assert_eq!(ddl, "CREATE TABLE fallback_ddl"); assert_eq!(std::fs::read_to_string(&calls).unwrap(), "executeQuery\ngetObjectSource\n"); state.shutdown(Duration::from_secs(1)).await; let _ = std::fs::remove_dir_all(dir); } #[test] fn oracle_external_driver_detection_only_accepts_standard_jdbc_signals() { let mut config = test_connection_config(DatabaseType::Jdbc); config.connection_string = Some(" jdbc:oracle:thin:@//127.0.0.1:1521/ORCL ".to_string()); assert!(is_oracle_external_driver_config(&config)); config.connection_string = Some("jdbc:postgresql://127.0.0.1:5432/demo".to_string()); config.jdbc_driver_class = Some("oracle.jdbc.OracleDriver".to_string()); assert!(!is_oracle_external_driver_config(&config)); config.connection_string = None; config.jdbc_driver_class = Some(" oracle.jdbc.driver.OracleDriver ".to_string()); assert!(is_oracle_external_driver_config(&config)); config.connection_string = Some("jdbc:oracle:thin:@//127.0.0.1:1521/ORCL".to_string()); config.jdbc_driver_class = Some("com.mysql.cj.jdbc.Driver".to_string()); assert!(!is_oracle_external_driver_config(&config)); config.db_type = DatabaseType::Oracle; assert!(!is_oracle_external_driver_config(&config)); } #[cfg(unix)] mod oracle_ddl_regression_tests { use super::*; use crate::types::ObjectSourceKind; use serde_json::{json, Value}; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; async fn scripted_oracle_driver(source: Value, columns: Value, comment: Value) -> (AppState, PathBuf) { let dir = std::env::temp_dir().join(format!("dbx-oracle-ddl-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); for (method, response) in [("getObjectSource", source), ("getColumns", columns), ("executeQuery", comment)] { std::fs::write(dir.join(format!("{method}.json")), response.to_string()).unwrap(); } let executable = dir.join("plugin.sh"); std::fs::write( &executable, r#"#!/bin/sh base=$(dirname "$0") while IFS= read -r line; do printf '%s\n' "$line" >> "$base/calls.log" id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\1/') method=$(printf '%s' "$line" | sed -E 's/.*"method":"([^"]+)".*/\1/') case "$method" in getObjectSource|getColumns|executeQuery) response=$(cat "$base/$method.json") printf '%s\n' "$response" | sed 's/^{/{"id":'"$id"',/' ;; *) printf '{"id":%s,"error":{"message":"unsupported test method"}}\n' "$id" ;; esac done "#, ) .unwrap(); std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); let plugin = InstalledPlugin { manifest: PluginManifest { id: "jdbc".into(), name: "JDBC".into(), version: "test".into(), protocol_version: 1, executable: Some("plugin.sh".into()), drivers: vec![PluginDriverManifest { id: "jdbc".into(), label: "JDBC".into(), kind: "external".into(), database_type: Some("jdbc".into()), }], ..Default::default() }, path: dir.clone(), compatibility: crate::plugins::PluginCompatibility { compatible: true, backend_executable: Some(executable), ..Default::default() }, }; let session = std::sync::Arc::new( PluginDriverSession::start_for_test(plugin, "jdbc".into(), PluginRuntimeEnv::default()).await.unwrap(), ); let state = AppState::new(Storage::open(&dir.join("storage.db")).await.unwrap()); let mut config = test_connection_config(DatabaseType::Jdbc); config.id = "oracle-ddl".into(); config.database = Some("demo".into()); config.connection_string = Some("jdbc:oracle:thin:@127.0.0.1:1521/demo".into()); config.jdbc_driver_class = Some("oracle.jdbc.OracleDriver".into()); state.configs.write().await.insert(config.id.clone(), config.clone()); state .update_connection_pools(|connections| { connections.insert( config.id.clone(), PoolKind::ExternalDriver { driver_id: "jdbc".into(), config: std::sync::Arc::new(config), session, }, ); }) .await; (state, dir) } fn column_response() -> Value { json!({"result": [db::ColumnInfo { name: "ID".into(), comment: Some("Column's comment".into()), ..Default::default() }]}) } fn comment_response() -> Value { json!({"result": {"columns": ["COMMENTS"], "rows": [["View's comment"]], "affected_rows": 0, "execution_time_ms": 0}}) } fn driver_calls(dir: &std::path::Path) -> Vec { std::fs::read_to_string(dir.join("calls.log")) .unwrap() .lines() .map(|line| serde_json::from_str(line).unwrap()) .collect() } #[tokio::test] async fn canonical_schema_reaches_queries_and_identifiers() { for (requested, returned, expected) in [ ("hr", Some(json!("HR")), "HR"), ("", Some(json!("HR")), "HR"), ("mixed_owner", Some(json!("mixed_owner")), "mixed_owner"), ("Mixed\"Owner", Some(json!("Mixed\"Owner")), "Mixed\"Owner"), ("FallbackOwner", None, "FallbackOwner"), ("FallbackOwner", Some(Value::Null), "FallbackOwner"), ("FallbackOwner", Some(json!("")), "FallbackOwner"), ("FallbackOwner", Some(json!(" ")), "FallbackOwner"), ("", None, ""), ] { for shape in ["table", "view body", "view ddl"] { let object_type = (shape != "table").then_some(ObjectSourceKind::View); let qualified = if expected.is_empty() { "\"ORDERS\"".to_string() } else { format!("{}.\"ORDERS\"", crate::schema::oracle_ident(expected)) }; let source = match shape { "table" => format!("CREATE TABLE {qualified} (\"ID\" NUMBER)"), "view ddl" => format!("CREATE VIEW {qualified} AS SELECT 1 FROM DUAL -- tail;"), _ => "SELECT 1 FROM DUAL -- tail;".to_string(), }; let mut result = json!({"name": "ORDERS", "object_type": if object_type.is_some() { "VIEW" } else { "TABLE" }, "source": source}); if let Some(returned) = returned.clone() { result["schema"] = returned; } let (state, dir) = scripted_oracle_driver(json!({"result": result}), column_response(), comment_response()).await; let ddl = crate::schema::get_table_display_ddl_core( &state, "oracle-ddl", "demo", requested, "ORDERS", object_type, ) .await .unwrap(); assert!( ddl.starts_with(&format!( "CREATE {} {qualified}", if shape == "table" { "TABLE" } else { "VIEW" } )), "{ddl}" ); assert!(ddl.contains(&format!("COMMENT ON TABLE {qualified} IS 'View''s comment';")), "{ddl}"); assert!( ddl.contains(&format!("COMMENT ON COLUMN {qualified}.\"ID\" IS 'Column''s comment';")), "{ddl}" ); assert_eq!( crate::sql::split_sql_statements_for_database(&ddl, DatabaseType::Oracle).len(), 3, "{ddl}" ); let calls = driver_calls(&dir); let source_call = calls.iter().find(|call| call["method"] == "getObjectSource").unwrap(); assert_eq!(source_call["params"]["schema"], requested); let columns_call = calls.iter().find(|call| call["method"] == "getColumns").unwrap(); assert_eq!(columns_call["params"]["schema"], expected); let comment_call = calls .iter() .find(|call| call["params"]["sql"].as_str().is_some_and(|sql| sql.contains("ALL_TAB_COMMENTS"))) .unwrap(); assert_eq!(comment_call["params"]["schema"], expected); assert_eq!( comment_call["params"]["sql"], crate::schema::oracle_table_comment_sql(expected, "ORDERS") ); state.shutdown(Duration::from_secs(1)).await; std::fs::remove_dir_all(dir).unwrap(); } } } #[tokio::test] async fn optional_metadata_failures_preserve_source_and_available_comments() { let error = json!({"error": {"message": "dictionary denied"}}); for (columns, comment, comment_count) in [ ( json!({"result": []}), json!({"result": {"columns": ["COMMENTS"], "rows": [], "affected_rows": 0, "execution_time_ms": 0}}), 0, ), (error.clone(), comment_response(), 1), (column_response(), error.clone(), 1), (error.clone(), error, 0), ] { for object_type in [None, Some(ObjectSourceKind::View)] { let source = if object_type.is_none() { "CREATE TABLE \"HR\".\"ORDERS\" (\"ID\" NUMBER);" } else { "CREATE VIEW \"HR\".\"ORDERS\" AS SELECT 1 FROM DUAL -- tail;" }; let (state, dir) = scripted_oracle_driver( json!({"result": {"name": "ORDERS", "object_type": if object_type.is_some() { "VIEW" } else { "TABLE" }, "schema": "HR", "source": source}}), columns.clone(), comment.clone(), ).await; let ddl = crate::schema::get_table_display_ddl_core( &state, "oracle-ddl", "demo", "hr", "ORDERS", object_type, ) .await .unwrap(); assert!(ddl.starts_with(source), "{ddl}"); assert_eq!(ddl.matches("COMMENT ON").count(), comment_count, "{ddl}"); assert_eq!( crate::sql::split_sql_statements_for_database(&ddl, DatabaseType::Oracle).len(), 1 + comment_count, "{ddl}" ); state.shutdown(Duration::from_secs(1)).await; std::fs::remove_dir_all(dir).unwrap(); } } } #[tokio::test] async fn missing_or_failed_source_stops_before_comment_lookups() { for (object_type, response) in [ (None, json!({"result": {}})), (None, json!({"result": {"source": " "}})), (None, json!({"error": {"message": "source denied"}})), (Some(ObjectSourceKind::View), json!({"error": {"message": "source denied"}})), ] { let (state, dir) = scripted_oracle_driver(response, column_response(), comment_response()).await; let result = crate::schema::get_table_display_ddl_core( &state, "oracle-ddl", "demo", "hr", "ORDERS", object_type, ) .await; assert!(result.is_err()); assert!(driver_calls(&dir).iter().all(|call| call["method"] == "getObjectSource")); state.shutdown(Duration::from_secs(1)).await; std::fs::remove_dir_all(dir).unwrap(); } } } #[test] fn should_append_oracle_style_comment_ddl_for_oracle_family() { assert!(should_append_oracle_style_comment_ddl(Some(&test_connection_config(DatabaseType::Oracle)))); assert!(should_append_oracle_style_comment_ddl(Some(&test_connection_config(DatabaseType::OceanbaseOracle)))); assert!(should_append_oracle_style_comment_ddl(Some(&test_connection_config(DatabaseType::Dameng)))); assert!(!should_append_oracle_style_comment_ddl(Some(&test_connection_config(DatabaseType::Mysql)))); assert!(!should_append_oracle_style_comment_ddl(None)); let mut jdbc_oracle = test_connection_config(DatabaseType::Jdbc); jdbc_oracle.connection_string = Some("jdbc:oracle:thin:@localhost:1521/ORCL".to_string()); assert!(should_append_oracle_style_comment_ddl(Some(&jdbc_oracle))); } #[test] fn external_driver_generic_ddl_applies_to_unmatched_jdbc_connections() { // JDBCX-style wrappers match no vendor DDL dialect and fall back to the // plugin's generic DatabaseMetaData renderer. let mut config = test_connection_config(DatabaseType::Jdbc); config.connection_string = Some("jdbcx:wrap-jdbc:jdbc:mysql://127.0.0.1:3306/demo".to_string()); config.jdbc_driver_class = Some("io.github.jdbcx.WrappedDriver".to_string()); assert!(external_driver_uses_generic_ddl(&config)); assert!(!external_driver_uses_mysql_ddl(&config)); assert!(!is_oracle_external_driver_config(&config)); // Vendor dialects keep their dedicated SQL paths. config.connection_string = Some("jdbc:mysql://127.0.0.1:3306/demo".to_string()); config.jdbc_driver_class = None; assert!(!external_driver_uses_generic_ddl(&config)); config.connection_string = Some("jdbc:oracle:thin:@//127.0.0.1:1521/ORCL".to_string()); assert!(!external_driver_uses_generic_ddl(&config)); // Vendor-typed database never routes through the generic renderer. let oracle = test_connection_config(DatabaseType::Oracle); assert!(!external_driver_uses_generic_ddl(&oracle)); } #[test] fn external_driver_statistics_dialect_matches_jdbc_url_vendor() { let mut config = test_connection_config(DatabaseType::Jdbc); config.connection_string = Some(" JDBC:Oracle:thin:@127.0.0.1:1521:XE ".to_string()); assert_eq!(external_driver_statistics_dialect(&config), Some(ExternalDriverStatisticsDialect::Oracle)); config.connection_string = Some("jdbc:dm://127.0.0.1:5236".to_string()); assert_eq!(external_driver_statistics_dialect(&config), Some(ExternalDriverStatisticsDialect::Dameng)); config.connection_string = Some("jdbc:kingbase8://127.0.0.1:54321/app".to_string()); assert_eq!(external_driver_statistics_dialect(&config), Some(ExternalDriverStatisticsDialect::Kingbase)); // Vendors without statistics SQL keep the previous "no statistics" behaviour. config.connection_string = Some("jdbc:hive2://127.0.0.1:10000/default".to_string()); assert_eq!(external_driver_statistics_dialect(&config), None); config.connection_string = None; assert_eq!(external_driver_statistics_dialect(&config), None); // A vendor-typed connection routed through an external driver keeps its dialect. let oracle = test_connection_config(DatabaseType::Oracle); assert_eq!(external_driver_statistics_dialect(&oracle), Some(ExternalDriverStatisticsDialect::Oracle)); } #[test] fn external_driver_statistics_query_plan_reuses_vendor_sql() { let oracle = external_driver_statistics_query_plan(ExternalDriverStatisticsDialect::Oracle, "dbx_test"); assert_eq!( oracle.iter().map(|(source, ..)| *source).collect::>(), vec!["all-segments", "dba-segments", "user-segments", "rows-only"] ); assert_eq!(oracle[0].1, oracle_object_statistics_sql("dbx_test")); assert!(oracle[0].1.contains("t.OWNER = 'DBX_TEST'")); let dameng = external_driver_statistics_query_plan(ExternalDriverStatisticsDialect::Dameng, "dbx_test"); assert_eq!(dameng[0].1, dameng_object_statistics_dba_segments_sql("dbx_test")); let kingbase = external_driver_statistics_query_plan(ExternalDriverStatisticsDialect::Kingbase, "public"); assert_eq!(kingbase.len(), 1); assert!(kingbase[0].1.contains("sys_catalog.sys_class")); } #[test] fn gaussdb_m_external_driver_uses_mysql_style_ddl() { let mut config = test_connection_config(DatabaseType::Gaussdb); config.driver_profile = Some("gaussdb-m".to_string()); config.jdbc_driver_class = Some("com.huawei.gaussdb.jdbc.Driver".to_string()); assert!(external_driver_uses_mysql_ddl(&config)); assert_eq!( mysql_external_driver_ddl_sql("app", "app_schema", "order"), "SHOW CREATE TABLE `app_schema`.`order`" ); config.driver_profile = Some("gaussdb".to_string()); assert!(!external_driver_uses_mysql_ddl(&config)); } #[test] fn gaussdb_m_view_object_source_sql_is_qualified_and_gated() { let mut config = test_connection_config(DatabaseType::Gaussdb); config.driver_profile = Some("gaussdb-m".to_string()); assert_eq!( gaussdb_m_view_object_source_sql( &config, "connection_db", "tenant`schema", "active`users", &db::ObjectSourceKind::View, ) .as_deref(), Some("SHOW CREATE VIEW `tenant``schema`.`active``users`") ); assert_eq!( gaussdb_m_view_object_source_sql(&config, "connection_db", "", "active`users", &db::ObjectSourceKind::View) .as_deref(), Some("SHOW CREATE VIEW `active``users`") ); assert_eq!( gaussdb_m_view_object_source_sql( &config, "connection_db", "tenant_schema", "refresh_users", &db::ObjectSourceKind::Function, ), None ); config.driver_profile = Some("gaussdb".to_string()); assert_eq!( gaussdb_m_view_object_source_sql( &config, "connection_db", "tenant_schema", "active_users", &db::ObjectSourceKind::View, ), None ); } #[test] fn mysql_external_driver_ddl_sql_uses_catalog_and_escaped_identifiers() { assert_eq!( mysql_external_driver_ddl_sql("app_db", "tenant`db", "user`events"), "SHOW CREATE TABLE `tenant``db`.`user``events`" ); assert_eq!( mysql_external_driver_ddl_sql("app`db", "", "user`events"), "SHOW CREATE TABLE `app``db`.`user``events`" ); } #[test] fn mysql_external_driver_ddl_reads_named_column_case_insensitively() { let result = db::QueryResult { columns: vec!["Table".to_string(), "Extra".to_string(), "CREATE TABLE".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: vec![], spatial_values: vec![], rows: vec![vec![ serde_json::json!("users"), serde_json::json!("ignored"), serde_json::json!("CREATE TABLE `users` (`id` bigint)"), ]], affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; assert_eq!( mysql_external_driver_ddl_from_query_result(result, "Create Table").unwrap(), "CREATE TABLE `users` (`id` bigint);" ); } #[test] fn mysql_external_driver_ddl_repairs_double_encoded_comments() { let result = db::QueryResult { columns: vec!["Table".to_string(), "Create Table".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: vec![], spatial_values: vec![], rows: vec![vec![ serde_json::json!("orders"), serde_json::json!("CREATE TABLE `orders` (`id` bigint COMMENT '订单ID')"), ]], affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; assert_eq!( mysql_external_driver_ddl_from_query_result(result, "Create Table").unwrap(), "CREATE TABLE `orders` (`id` bigint COMMENT '订单ID');" ); } #[test] fn mysql_external_driver_ddl_falls_back_to_second_column() { let result = db::QueryResult { columns: vec!["name".to_string(), "definition".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: vec![], spatial_values: vec![], rows: vec![vec![serde_json::json!("users"), serde_json::json!("CREATE TABLE `users` (`id` bigint);\n")]], affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; assert_eq!( mysql_external_driver_ddl_from_query_result(result, "Create Table").unwrap(), "CREATE TABLE `users` (`id` bigint);\n" ); } #[test] fn mysql_external_driver_view_ddl_reads_named_column_case_insensitively() { let result = db::QueryResult { columns: vec!["View".to_string(), "Extra".to_string(), "CREATE VIEW".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: vec![], spatial_values: vec![], rows: vec![vec![ serde_json::json!("active_users"), serde_json::json!("ignored"), serde_json::json!("CREATE VIEW `active_users` AS SELECT 1"), ]], affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; assert_eq!( mysql_external_driver_ddl_from_query_result(result, "Create View").unwrap(), "CREATE VIEW `active_users` AS SELECT 1;" ); } #[test] fn mysql_external_driver_view_ddl_falls_back_to_second_column() { let result = db::QueryResult { columns: vec!["name".to_string(), "definition".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: vec![], spatial_values: vec![], rows: vec![vec![ serde_json::json!("active_users"), serde_json::json!("CREATE VIEW `active_users` AS SELECT 1;\n"), ]], affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; assert_eq!( mysql_external_driver_ddl_from_query_result(result, "Create View").unwrap(), "CREATE VIEW `active_users` AS SELECT 1;\n" ); } #[test] fn mysql_object_source_sql_qualifies_cross_database_objects() { assert_eq!( mysql_object_source_sql("tenant_db", "users_view", &db::ObjectSourceKind::View), "SHOW CREATE VIEW `tenant_db`.`users_view`" ); assert_eq!( mysql_object_source_sql("tenant_db", "sync_users", &db::ObjectSourceKind::Procedure), "SHOW CREATE PROCEDURE `tenant_db`.`sync_users`" ); assert_eq!( mysql_object_source_sql("tenant_db", "calc_score", &db::ObjectSourceKind::Function), "SHOW CREATE FUNCTION `tenant_db`.`calc_score`" ); assert_eq!( mysql_object_source_sql("", "users_view", &db::ObjectSourceKind::View), "SHOW CREATE VIEW `users_view`" ); } #[test] fn mysql_object_source_sql_emits_show_create_trigger() { assert_eq!( mysql_object_source_sql("tenant_db", "before_insert", &db::ObjectSourceKind::Trigger), "SHOW CREATE TRIGGER `tenant_db`.`before_insert`" ); } #[test] fn mysql_object_source_sql_emits_show_create_event() { assert_eq!( mysql_object_source_sql("tenant_db", "event_daily_sync", &db::ObjectSourceKind::Event), "SHOW CREATE EVENT `tenant_db`.`event_daily_sync`" ); } #[test] fn mysql_event_object_source_is_read_only() { let source = finalize_object_source(db::ObjectSource { name: "event_daily_sync".to_string(), object_type: db::ObjectSourceKind::Event, schema: None, source: "CREATE EVENT event_daily_sync ON SCHEDULE EVERY 1 DAY DO SELECT 1".to_string(), editable: None, }); assert_eq!(source.editable, Some(false)); } #[test] fn mysql_object_source_sql_emits_show_create_materialized_view() { // Regression for the review comment: Doris / StarRocks ride on the MySQL // protocol, so the MV branch of mysql_object_source_sql must produce a // real statement (used at crates/dbx-core/src/schema/mod.rs:5395-5404 by // get_table_ddl_core). Returning an empty string silently broke the UI. assert_eq!( mysql_object_source_sql("shop", "daily_sales_mv", &db::ObjectSourceKind::MaterializedView), "SHOW CREATE MATERIALIZED VIEW `shop`.`daily_sales_mv`" ); assert_eq!( mysql_object_source_sql("", "daily_sales_mv", &db::ObjectSourceKind::MaterializedView), "SHOW CREATE MATERIALIZED VIEW `daily_sales_mv`" ); } #[test] fn mysql_object_source_ddl_column_index_matches_dialect_layout() { // VIEW and Doris/StarRocks MaterializedView return (Name, DDL). // PROCEDURE / FUNCTION return (Name, sql_mode, DDL, …). // Reading the wrong index returns the empty/no-op and surfaces as // "Failed to read object source" — regression-guarded here so we // don't have to spin up a real StarRocks to catch it. assert_eq!(mysql_object_source_ddl_column_index(&db::ObjectSourceKind::View), 1); assert_eq!(mysql_object_source_ddl_column_index(&db::ObjectSourceKind::MaterializedView), 1); assert_eq!(mysql_object_source_ddl_column_index(&db::ObjectSourceKind::Procedure), 2); assert_eq!(mysql_object_source_ddl_column_index(&db::ObjectSourceKind::Function), 2); assert_eq!(mysql_object_source_ddl_column_index(&db::ObjectSourceKind::Trigger), 2); // SHOW CREATE EVENT returns (Event, sql_mode, time_zone, Create Event, …) — // one extra time_zone column before the DDL, verified against a real MySQL // 8.0 instance (`SHOW CREATE EVENT` for a live event). assert_eq!(mysql_object_source_ddl_column_index(&db::ObjectSourceKind::Event), 3); } #[test] fn metadata_retry_excludes_pool_saturation_from_reconnects() { assert!(!is_retryable_metadata_error("Pool not found")); assert!(!is_retryable_metadata_error(crate::query::METADATA_POOL_BUSY_ERROR)); assert!(is_retryable_metadata_error("connection reset by peer")); assert!(is_retryable_metadata_error("Agent RPC error (-1): dm.jdbc.driver.DMException: 网络通信异常")); assert!(is_retryable_metadata_error( "Agent RPC error (-1): connection lost\nDBX_AGENT_ERROR_DATA:{\"category\":\"connection\",\"sessionDisposition\":\"quarantine\"}" )); assert!(!is_retryable_metadata_error( "Agent RPC error (-1): connection text in SQL error\nDBX_AGENT_ERROR_DATA:{\"category\":\"sql\",\"sessionDisposition\":\"keep\"}" )); assert!(!is_retryable_metadata_error( "Agent RPC error (-1): connection kept\nDBX_AGENT_ERROR_DATA:{\"category\":\"connection\",\"sessionDisposition\":\"keep\"}" )); assert!(!is_retryable_metadata_error( "Agent RPC error (-1): runtime saturated\nDBX_AGENT_ERROR_DATA:{\"category\":\"resource\",\"sessionDisposition\":\"replace_runtime\"}" )); assert!(!is_retryable_metadata_error("Unknown column 'email' in 'field list'")); assert!(!is_retryable_metadata_error("Access denied for user")); } #[tokio::test] async fn metadata_pool_races_retry_once_and_saturation_returns_busy() { let dir = std::env::temp_dir().join(format!("dbx-schema-metadata-pool-race-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let storage = crate::storage::Storage::open(&dir.join("storage.db")).await.unwrap(); let state = crate::connection::AppState::new(storage); state.configs.write().await.insert("conn".to_string(), test_connection_config(DatabaseType::Mysql)); let mut recovered_attempts = 0; let recovered = super::retry_metadata_connection_for_session(&state, "conn", Some("app"), None, || { recovered_attempts += 1; let attempt = recovered_attempts; async move { if attempt == 1 { Err("Pool not found".to_string()) } else { Ok("loaded") } } }) .await; assert_eq!(recovered, Ok("loaded")); assert_eq!(recovered_attempts, 2); let mut missing_attempts = 0; let missing = super::retry_metadata_connection_for_session(&state, "conn", Some("app"), None, || { missing_attempts += 1; async { Err::<(), _>("Pool not found".to_string()) } }) .await; assert_eq!(missing.err().as_deref(), Some(crate::query::METADATA_POOL_BUSY_ERROR)); assert_eq!(missing_attempts, 2); let mut saturation_attempts = 0; let saturated = super::retry_metadata_connection_for_session(&state, "conn", Some("app"), None, || { saturation_attempts += 1; async { Err::<(), _>("MySQL connection pool checkout timed out [stage=wait, timeout_ms=500]".to_string()) } }) .await; assert_eq!(saturated.err().as_deref(), Some(crate::query::METADATA_POOL_BUSY_ERROR)); assert_eq!(saturation_attempts, 1); let _ = std::fs::remove_dir_all(dir); } #[tokio::test] async fn sqlserver_metadata_mutex_contention_returns_busy_after_timeout() { let client = std::sync::Arc::new(tokio::sync::Mutex::new(())); let _held = client.lock().await; let result = super::lock_metadata_mutex_with_timeout(&client, Duration::from_millis(1)).await; assert_eq!(result.err().as_deref(), Some(crate::query::METADATA_POOL_BUSY_ERROR)); } #[tokio::test] async fn metadata_pool_snapshot_releases_global_connections_lock() { let dir = std::env::temp_dir().join(format!("dbx-schema-metadata-snapshot-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let storage = crate::storage::Storage::open(&dir.join("storage.db")).await.unwrap(); let state = crate::connection::AppState::new(storage); let pool = crate::db::sqlite::connect_path(":memory:").await.unwrap(); state .update_connection_pools(|connections| { connections.insert("conn".to_string(), super::PoolKind::Sqlite(pool)); }) .await; let snapshot = super::clone_metadata_pool(&state, "conn").await.expect("metadata pool snapshot"); tokio::time::timeout(std::time::Duration::from_millis(100), state.update_connection_pools(|_| ())) .await .expect("snapshot must not retain the global pool-map lock"); assert!(matches!(snapshot, super::PoolKind::Sqlite(_))); drop(snapshot); drop(state); let _ = std::fs::remove_dir_all(dir); } #[test] fn metadata_error_action_applies_fail_stop_to_every_attempt() { let quarantine = "Agent RPC error (-1): connection lost\nDBX_AGENT_ERROR_DATA:{\"category\":\"connection\",\"sessionDisposition\":\"quarantine\"}"; let replace_runtime = "Agent RPC error (-1): runtime saturated\nDBX_AGENT_ERROR_DATA:{\"category\":\"resource\",\"sessionDisposition\":\"replace_runtime\"}"; let sql = "Agent RPC error (-1): syntax error\nDBX_AGENT_ERROR_DATA:{\"category\":\"sql\",\"sessionDisposition\":\"keep\"}"; let db_type = Some(DatabaseType::Dameng); assert_eq!(metadata_error_action(db_type, quarantine, false), MetadataErrorAction::Retry); assert_eq!(metadata_error_action(db_type, quarantine, true), MetadataErrorAction::Discard); assert_eq!( metadata_error_action(db_type, "Agent RPC call timed out (30s)", false), MetadataErrorAction::Discard ); assert_eq!(metadata_error_action(db_type, replace_runtime, false), MetadataErrorAction::ReplaceRuntime); assert_eq!(metadata_error_action(db_type, replace_runtime, true), MetadataErrorAction::ReplaceRuntime); assert_eq!(metadata_error_action(db_type, sql, false), MetadataErrorAction::Return); } #[tokio::test] async fn metadata_fail_stop_detaches_base_pool_without_client_session() { let dir = std::env::temp_dir().join(format!("dbx-schema-metadata-fail-stop-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let storage = crate::storage::Storage::open(&dir.join("storage.db")).await.unwrap(); let state = crate::connection::AppState::new(storage); let mut config = test_connection_config(DatabaseType::Dameng); config.id = "conn".to_string(); state.configs.write().await.insert(config.id.clone(), config); state .update_connection_pools(|connections| { connections.insert( "conn:analytics:role:metadata".to_string(), super::PoolKind::agent(crate::db::agent_driver::AgentDriverClient::test_stub()), ); }) .await; replace_metadata_runtime(&state, "conn", Some("analytics"), None).await; assert!(state.pool_handle("conn:analytics:role:metadata").await.is_none()); let _ = std::fs::remove_dir_all(dir); } #[tokio::test] async fn metadata_timeout_detaches_pool_without_replaying_operation() { let dir = std::env::temp_dir().join(format!("dbx-schema-metadata-timeout-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let storage = crate::storage::Storage::open(&dir.join("storage.db")).await.unwrap(); let state = crate::connection::AppState::new(storage); let mut config = test_connection_config(DatabaseType::Dameng); config.id = "conn".to_string(); state.configs.write().await.insert(config.id.clone(), config); let pool = crate::db::sqlite::connect_path(":memory:").await.unwrap(); state .update_connection_pools(|connections| { connections.insert("conn:role:metadata".to_string(), super::PoolKind::Sqlite(pool)); }) .await; let mut attempts = 0; let result = super::retry_metadata_connection_for_session(&state, "conn", None, None, || { attempts += 1; async { Err::<(), _>("Agent RPC call timed out (30s)".to_string()) } }) .await; assert_eq!(result.unwrap_err(), "Agent RPC call timed out (30s)"); assert_eq!(attempts, 1); assert!(state.pool_handle("conn:role:metadata").await.is_none()); let _ = std::fs::remove_dir_all(dir); } #[tokio::test] async fn metadata_second_quarantine_detaches_replacement_pool() { let dir = std::env::temp_dir().join(format!("dbx-schema-metadata-quarantine-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let storage = crate::storage::Storage::open(&dir.join("storage.db")).await.unwrap(); let state = crate::connection::AppState::new(storage); let mut config = test_connection_config(DatabaseType::Sqlite); config.id = "conn".to_string(); config.host = ":memory:".to_string(); config.password.clear(); config.database = None; state.configs.write().await.insert(config.id.clone(), config); let pool = crate::db::sqlite::connect_path(":memory:").await.unwrap(); state .update_connection_pools(|connections| { connections.insert("conn".to_string(), super::PoolKind::Sqlite(pool)); }) .await; let mut attempts = 0; let quarantine = "Agent RPC error (-1): connection lost\nDBX_AGENT_ERROR_DATA:{\"category\":\"connection\",\"sessionDisposition\":\"quarantine\"}"; let result = super::retry_metadata_connection_for_session(&state, "conn", None, None, || { attempts += 1; async { Err::<(), _>(quarantine.to_string()) } }) .await; assert_eq!(result.unwrap_err(), quarantine); assert_eq!(attempts, 2); assert!(state.pool_handle("conn").await.is_none()); let _ = std::fs::remove_dir_all(dir); } #[tokio::test] async fn table_ddl_timeout_detaches_metadata_pool_without_replay() { let dir = std::env::temp_dir().join(format!("dbx-schema-table-ddl-timeout-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let script_path = dir.join("table-ddl-timeout-agent.py"); let call_count_path = dir.join("table-ddl-call-count"); let call_count = serde_json::to_string(&call_count_path.to_string_lossy()).unwrap(); std::fs::write( &script_path, format!( r#"import json, pathlib, sys call_count = pathlib.Path({call_count}) print(json.dumps({{'ready': True}}), flush=True) for line in sys.stdin: req = json.loads(line) if req['method'] == 'handshake': result = {{'protocolVersion': 2, 'agentProtocolVersion': 2, 'capabilities': ['multi_session']}} response = {{'jsonrpc': '2.0', 'id': req['id'], 'result': result}} elif req['method'] in ('validate_session', 'validate_connection'): response = {{'jsonrpc': '2.0', 'id': req['id'], 'result': {{}}}} else: previous = int(call_count.read_text()) if call_count.exists() else 0 call_count.write_text(str(previous + 1)) response = {{ 'jsonrpc': '2.0', 'id': req['id'], 'error': {{ 'code': -1, 'message': 'metadata timed out', 'data': {{ 'category': 'timeout', 'retryable': False, 'sessionDisposition': 'quarantine', 'stage': 'execute' }} }} }} print(json.dumps(response), flush=True) "# ), ) .unwrap(); let python = if cfg!(windows) { "python" } else { "python3" }; let runtime = crate::db::agent_driver::AgentRuntimeClient::spawn( crate::db::agent_driver::AgentLaunchSpec::new(python) .with_args([script_path.to_string_lossy().to_string()]), "test", ) .await .unwrap(); runtime.increment_session_count(); let client = crate::db::agent_driver::AgentDriverClient::shared_session(runtime.clone(), "metadata-session".to_string()); let storage = crate::storage::Storage::open(&dir.join("storage.db")).await.unwrap(); let state = crate::connection::AppState::new(storage); let mut config = test_connection_config(DatabaseType::Dameng); config.id = "conn".to_string(); state.configs.write().await.insert(config.id.clone(), config); let pool_key = "conn:analytics:role:metadata"; state .update_connection_pools(|connections| { connections.insert(pool_key.to_string(), super::PoolKind::agent(client)); }) .await; let error = super::get_table_ddl_core(&state, "conn", "analytics", "APP", "EVENTS", None).await.unwrap_err(); assert_eq!( crate::db::agent_driver::try_agent_error_from_legacy(&error).and_then(|error| error.category()), Some(crate::db::agent_driver::AgentErrorCategory::Timeout) ); assert_eq!(std::fs::read_to_string(call_count_path).unwrap(), "1"); assert!(state.pool_handle(pool_key).await.is_none()); runtime.kill(); let _ = std::fs::remove_dir_all(dir); } #[test] fn visible_schema_filter_only_applies_when_requested() { let mut config = test_connection_config(DatabaseType::Oracle); config.visible_schemas = Some(HashMap::from([("ORCLPDB1".to_string(), vec!["APP".to_string(), "REPORTING".to_string()])])); assert_eq!(visible_schema_filter(Some(&config), "ORCLPDB1", false), None); assert_eq!( visible_schema_filter(Some(&config), "ORCLPDB1", true), Some(vec!["APP".to_string(), "REPORTING".to_string()]) ); assert_eq!(visible_schema_filter(Some(&config), "OTHER", true), None); } #[test] fn default_oracle_agent_config_excludes_legacy_profiles() { let mut config = test_connection_config(DatabaseType::Oracle); assert!(super::is_default_oracle_agent_config(&config)); config.driver_profile = Some("oracle".to_string()); assert!(super::is_default_oracle_agent_config(&config)); config.driver_profile = Some("oracle-legacy".to_string()); assert!(!super::is_default_oracle_agent_config(&config)); config.driver_profile = Some("oracle-10g".to_string()); assert!(!super::is_default_oracle_agent_config(&config)); } #[test] fn oracle_metadata_object_source_is_limited_to_supported_kinds() { let oracle = test_connection_config(DatabaseType::Oracle); let postgres = test_connection_config(DatabaseType::Postgres); for object_type in [db::ObjectSourceKind::Sequence, db::ObjectSourceKind::Package, db::ObjectSourceKind::PackageBody] { assert!(super::uses_oracle_metadata_object_source(Some(&oracle), &object_type)); } assert!(!super::uses_oracle_metadata_object_source(Some(&postgres), &db::ObjectSourceKind::Sequence,)); assert!(!super::uses_oracle_metadata_object_source(Some(&oracle), &db::ObjectSourceKind::View,)); } #[test] fn agent_table_paging_supports_tdengine_and_default_oracle_only() { assert!(super::supports_agent_table_paging(&test_connection_config(DatabaseType::Tdengine))); assert!(super::supports_agent_table_paging(&test_connection_config(DatabaseType::Oracle))); assert!(!super::supports_agent_table_paging(&test_connection_config(DatabaseType::Dameng))); let mut legacy_oracle = test_connection_config(DatabaseType::Oracle); legacy_oracle.driver_profile = Some("oracle-legacy".to_string()); assert!(!super::supports_agent_table_paging(&legacy_oracle)); } #[test] fn detects_opengauss_constraint_profiles_without_gaussdb() { assert!(super::is_opengauss_constraint_config(&test_connection_config(DatabaseType::OpenGauss))); assert!(!super::is_opengauss_constraint_config(&test_connection_config(DatabaseType::Gaussdb))); assert!(!super::is_opengauss_constraint_config(&test_connection_config(DatabaseType::Postgres))); let mut profiled_postgres = test_connection_config(DatabaseType::Postgres); profiled_postgres.driver_profile = Some("opengauss".to_string()); assert!(super::is_opengauss_constraint_config(&profiled_postgres)); profiled_postgres.driver_profile = Some("gaussdb".to_string()); assert!(!super::is_opengauss_constraint_config(&profiled_postgres)); } #[test] fn detects_opengauss_sequence_compatibility_profiles() { assert!(super::is_opengauss_family_config(&test_connection_config(DatabaseType::OpenGauss))); assert!(super::is_opengauss_family_config(&test_connection_config(DatabaseType::Gaussdb))); assert!(!super::is_opengauss_family_config(&test_connection_config(DatabaseType::Postgres))); let mut profiled_postgres = test_connection_config(DatabaseType::Postgres); profiled_postgres.driver_profile = Some("opengauss".to_string()); assert!(super::is_opengauss_family_config(&profiled_postgres)); profiled_postgres.driver_profile = Some("gaussdb".to_string()); assert!(super::is_opengauss_family_config(&profiled_postgres)); } #[test] fn agent_paging_detection_avoids_double_offset_only_when_page_sized() { assert!(super::agent_paging_likely_applied(true, Some(500), 500)); assert!(super::agent_paging_likely_applied(true, Some(500), 120)); assert!(!super::agent_paging_likely_applied(true, Some(500), 501)); assert!(!super::agent_paging_likely_applied(false, Some(500), 120)); assert!(!super::agent_paging_likely_applied(true, None, 120)); } #[test] fn external_driver_paging_detection_avoids_double_offset_for_jdbc_only() { assert!(super::external_driver_paging_likely_applied("jdbc", Some(500), 500)); assert!(super::external_driver_paging_likely_applied("jdbc", Some(500), 120)); assert!(!super::external_driver_paging_likely_applied("jdbc", Some(500), 501)); assert!(!super::external_driver_paging_likely_applied("jdbc", None, 120)); assert!(!super::external_driver_paging_likely_applied("other", Some(500), 120)); } #[test] fn filter_visible_schema_names_preserves_database_order() { let schemas = vec!["APP".to_string(), "SYS".to_string(), "REPORTING".to_string()]; let visible = vec!["REPORTING".to_string(), "APP".to_string()]; assert_eq!(filter_visible_schema_names(schemas, Some(&visible)), vec!["APP", "REPORTING"]); } fn test_table_info(name: &str) -> super::db::TableInfo { super::db::TableInfo { name: name.to_string(), table_type: "BASE TABLE".to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, } } #[cfg(feature = "mq-admin")] #[test] fn message_queue_topics_are_exposed_as_table_metadata() { let tables = super::message_queue_topic_tables(vec![crate::mq::TopicInfo { name: "orders".to_string(), short_name: "orders".to_string(), partitioned: true, partitions: Some(3), persistent: true, internal: false, message_type: None, namespace: Some("default".to_string()), message_count: None, messages_ready: None, messages_unacked: None, ..Default::default() }]); assert_eq!(tables.len(), 1); assert_eq!(tables[0].name, "orders"); assert_eq!(tables[0].table_type, "TOPIC"); assert_eq!(tables[0].parent_schema.as_deref(), Some("default")); } fn test_object_info(name: &str, object_type: &str) -> super::db::ObjectInfo { super::db::ObjectInfo { name: name.to_string(), object_type: object_type.to_string(), schema: Some("app".to_string()), valid: None, signature: None, custom_type_kind: None, has_members: None, comment: None, created_at: None, updated_at: None, parent_schema: None, parent_name: None, trigger: None, xugu_type_members_expandable: None, } } fn test_database_info(name: &str) -> super::db::DatabaseInfo { super::db::DatabaseInfo { name: name.to_string(), ..Default::default() } } #[test] fn manticoresearch_database_list_filters_mysql_system_databases() { let databases = vec![ test_database_info("Manticore"), test_database_info("information_schema"), test_database_info("mysql"), test_database_info("performance_schema"), test_database_info("sys"), ]; let config = test_connection_config(DatabaseType::ManticoreSearch); let filtered = filter_mysql_system_databases_for_config(databases, Some(&config)); assert_eq!(filtered.into_iter().map(|database| database.name).collect::>(), vec!["Manticore"]); } #[test] fn manticoresearch_show_metadata_uses_unqualified_table_names() { let config = test_connection_config(DatabaseType::ManticoreSearch); assert_eq!(super::mysql_show_metadata_database_for_config(Some(&config), "Manticore"), ""); } #[test] fn doris_show_metadata_keeps_database_qualifier() { let config = test_connection_config(DatabaseType::Doris); assert_eq!(super::mysql_show_metadata_database_for_config(Some(&config), "analytics"), "analytics"); } #[test] fn doris_show_metadata_resolves_effective_database_like_plain_mysql() { // A qualified `db.table` reference in a MySQL-family dialect puts the // database in the `schema` parameter (two-part names), so the // Doris/StarRocks show-metadata column branch must resolve its // effective database with `mysql_table_metadata_catalog` — schema // first, database fallback — exactly like the plain MySQL branch. // Otherwise an empty (or different) tab/execution database sends the // column lookup to the wrong namespace and column comments are lost // (fixes #6590). assert_eq!(super::mysql_table_metadata_catalog("", "analytics"), "analytics"); assert_eq!(super::mysql_table_metadata_catalog("default_db", "analytics"), "analytics"); assert_eq!(super::mysql_table_metadata_catalog("analytics", ""), "analytics"); assert_eq!(super::mysql_table_metadata_catalog("", ""), ""); } #[test] fn doris_database_list_keeps_system_databases() { let databases = vec![test_database_info("information_schema"), test_database_info("analytics")]; let config = test_connection_config(DatabaseType::Doris); let filtered = filter_mysql_system_databases_for_config(databases, Some(&config)); assert_eq!( filtered.into_iter().map(|database| database.name).collect::>(), vec!["information_schema", "analytics"] ); } #[test] fn filter_table_infos_applies_filter_offset_and_limit() { let tables = vec![ test_table_info("alpha"), test_table_info("audit_log"), test_table_info("audit_record"), test_table_info("users"), ]; let filtered = filter_table_infos(tables, Some("audit"), Some(1), Some(1), None, None); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "audit_record"); } #[test] fn shardingsphere_proxy_marker_is_ascii_case_insensitive_and_exact() { assert!(super::is_shardingsphere_proxy_version("5.7.22-ShardingSphere-Proxy 5.5.2")); assert!(super::is_shardingsphere_proxy_version("8.0.27-ShardingSphere-Proxy 5.5.2")); assert!(super::is_shardingsphere_proxy_version("8.0.36-SHARDINGSPHERE-PROXY 5.5.2")); assert!(!super::is_shardingsphere_proxy_version("8.0.36-ShardingSphere Proxy 5.5.2")); assert!(!super::is_shardingsphere_proxy_version("8.0.36-MySQL Community Server")); } #[test] fn mysql_table_list_source_uses_only_saved_shardingsphere_version() { let mut config = test_connection_config(DatabaseType::Mysql); assert_eq!(mysql_table_list_source_for_config(Some(&config)), MysqlTableListSource::InformationSchema); config.database_info = Some(DatabaseConnectionInfo { product_version: Some("5.7.22-ShardingSphere-Proxy 5.5.2".to_string()), ..DatabaseConnectionInfo::default() }); assert_eq!(mysql_table_list_source_for_config(Some(&config)), MysqlTableListSource::ShowFullTables); config.database_info.as_mut().unwrap().product_version = Some("8.0.36-MySQL Community Server".to_string()); assert_eq!(mysql_table_list_source_for_config(Some(&config)), MysqlTableListSource::InformationSchema); assert_eq!(mysql_table_list_source_for_config(None), MysqlTableListSource::InformationSchema); } #[test] fn tdsql_profile_uses_logical_show_full_tables_source() { let mut config = test_connection_config(DatabaseType::Mysql); config.driver_profile = Some("TDSQL".to_string()); assert_eq!(mysql_table_list_source_for_config(Some(&config)), MysqlTableListSource::ShowFullTables); } #[test] fn shardingsphere_logical_tables_keep_local_constraints() { let tables = vec![ test_table_info("normal_table"), test_table_info("t_order"), test_table_info("t_order_archive"), super::db::TableInfo { name: "t_order_view".to_string(), table_type: "VIEW".to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, }, test_table_info("t_user"), ]; let table_types = vec!["TABLE".to_string()]; let name_filter = TableNameFilter { include_patterns: vec!["t_%".to_string()], exclude_patterns: vec!["%user%".to_string()] }; let filtered = filter_table_infos(tables, Some("order"), Some(1), Some(1), Some(&table_types), Some(&name_filter)); assert_eq!(filtered.into_iter().map(|table| table.name).collect::>(), vec!["t_order_archive"]); } #[test] fn mongodb_agent_collection_listing_only_applies_to_mongodb() { let mongodb = test_connection_config(DatabaseType::MongoDb); let postgres = test_connection_config(DatabaseType::Postgres); assert!(uses_mongodb_agent_collection_listing(Some(&mongodb))); assert!(!uses_mongodb_agent_collection_listing(Some(&postgres))); assert!(!uses_mongodb_agent_collection_listing(None)); } #[test] fn mongodb_agent_collections_preserve_table_list_constraints() { let collection_types = vec!["COLLECTION".to_string()]; let names = vec!["audit_log".to_string(), "users".to_string(), "audit_record".to_string()]; let filtered = filter_mongodb_agent_collections( names.clone(), Some("audit"), Some(1), Some(1), Some(&collection_types), None, ); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "audit_record"); assert_eq!(filtered[0].table_type, "COLLECTION"); let table_types = vec!["TABLE".to_string()]; let filtered = filter_mongodb_agent_collections(names, None, None, None, Some(&table_types), None); assert!(filtered.is_empty()); } #[test] fn filter_table_infos_matches_fuzzy_subsequences() { let tables = vec![test_table_info("system_user"), test_table_info("user_order"), test_table_info("alpha")]; let system_user = filter_table_infos(tables.clone(), Some("sysu"), None, None, None, None); assert_eq!(system_user.into_iter().map(|table| table.name).collect::>(), vec!["system_user"]); let user_order = filter_table_infos(tables, Some("uo"), None, None, None, None); assert_eq!(user_order.into_iter().map(|table| table.name).collect::>(), vec!["user_order"]); } #[test] fn filter_table_infos_matches_comments() { let mut orders = test_table_info("orders"); orders.comment = Some("sales archive".to_string()); let mut profile = test_table_info("profile"); profile.comment = Some("customer account data".to_string()); let tables = vec![orders, profile, test_table_info("logs")]; let filtered = filter_table_infos(tables, Some("account"), None, None, None, None); assert_eq!(filtered.into_iter().map(|table| table.name).collect::>(), vec!["profile"]); } #[test] fn filter_table_infos_skips_fuzzy_for_single_character_filters() { let tables = vec![test_table_info("orders"), test_table_info("user_order")]; let filtered = filter_table_infos(tables, Some("u"), None, None, None, None); assert_eq!(filtered.into_iter().map(|table| table.name).collect::>(), vec!["user_order"]); } #[test] fn filter_table_infos_keeps_special_filter_characters_literal() { let tables = vec![test_table_info("user_%"), test_table_info("user_account"), test_table_info("userXpercent")]; let filtered = filter_table_infos(tables, Some("user_%"), None, None, None, None); assert_eq!(filtered.into_iter().map(|table| table.name).collect::>(), vec!["user_%"]); } #[test] fn table_name_filter_uses_sql_like_without_fuzzy_subsequence() { let filter = TableNameFilter { include_patterns: vec!["ads_cp%".to_string()], exclude_patterns: vec!["%_bak".to_string()], }; assert!(table_name_filter_matches("ads_cp_report", Some(&filter))); assert!(!table_name_filter_matches("ads_180d_creator_detail_report_di", Some(&filter))); assert!(!table_name_filter_matches("ads_cp_report_bak", Some(&filter))); } #[test] fn table_name_filter_supports_escaped_like_wildcards() { let filter = TableNameFilter { include_patterns: vec![r"order\_%".to_string()], exclude_patterns: vec![] }; assert!(table_name_filter_matches("order_items", Some(&filter))); assert!(!table_name_filter_matches("orderXitems", Some(&filter))); } #[test] fn table_name_filter_handles_adversarial_failing_like_pattern() { let filter = TableNameFilter { include_patterns: vec!["%a".repeat(128)], exclude_patterns: vec![] }; assert!(!table_name_filter_matches(&"a".repeat(127), Some(&filter))); } #[test] fn filter_table_infos_filters_object_type_before_offset_and_limit() { let tables = vec![ test_table_info("orders"), super::db::TableInfo { name: "active_orders".to_string(), table_type: "VIEW".to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, }, test_table_info("users"), super::db::TableInfo { name: "active_users".to_string(), table_type: "VIEW".to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, }, ]; let object_types = vec!["VIEW".to_string()]; let filtered = filter_table_infos(tables, None, Some(1), Some(1), Some(&object_types), None); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "active_users"); } #[test] fn filter_table_infos_pages_starrocks_materialized_views_independently() { let tables = vec![ test_table_info("orders"), super::db::TableInfo { name: "orders_view".to_string(), table_type: "VIEW".to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, }, super::db::TableInfo { name: "daily_orders_mv".to_string(), table_type: "MATERIALIZED_VIEW".to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, }, super::db::TableInfo { name: "monthly_orders_mv".to_string(), table_type: "MATERIALIZED_VIEW".to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, }, ]; let object_types = vec!["MATERIALIZED_VIEW".to_string()]; let filtered = filter_table_infos(tables, Some("orders"), Some(1), Some(1), Some(&object_types), None); assert_eq!(filtered.into_iter().map(|table| table.name).collect::>(), vec!["monthly_orders_mv"]); } #[test] fn filter_object_infos_applies_sql_like_name_filter() { let objects = vec![ test_object_info("fn_get_user", "FUNCTION"), test_object_info("fn_get_role", "FUNCTION"), test_object_info("fn_get_role_bak", "FUNCTION"), test_object_info("internal_hash", "FUNCTION"), ]; let object_types = vec!["FUNCTION".to_string()]; let name_filter = TableNameFilter { include_patterns: vec!["FN_%".to_string()], exclude_patterns: vec!["%_BAK".to_string()] }; let filtered = filter_object_infos(objects, None, None, None, Some(&object_types), Some(&name_filter)); assert_eq!( filtered.into_iter().map(|object| object.name).collect::>(), vec!["fn_get_user", "fn_get_role"] ); } #[test] fn filter_object_infos_filters_object_type_before_offset_and_limit() { let objects = vec![ test_object_info("sync_user", "PROCEDURE"), test_object_info("find_user", "FUNCTION"), test_object_info("fetch_name", "FUNCTION"), test_object_info("orders", "TABLE"), ]; let object_types = vec!["FUNCTION".to_string()]; let filtered = filter_object_infos(objects, Some("fn"), Some(1), Some(1), Some(&object_types), None); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "fetch_name"); } #[test] fn filter_object_infos_pages_starrocks_materialized_views_independently() { let objects = vec![ test_object_info("orders", "TABLE"), test_object_info("orders_view", "VIEW"), test_object_info("daily_orders_mv", "MATERIALIZED_VIEW"), test_object_info("monthly_orders_mv", "MATERIALIZED_VIEW"), ]; let object_types = vec!["MATERIALIZED_VIEW".to_string()]; let filtered = filter_object_infos(objects, Some("orders"), Some(1), Some(1), Some(&object_types), None); assert_eq!(filtered.into_iter().map(|object| object.name).collect::>(), vec!["monthly_orders_mv"]); } #[test] fn filter_object_infos_matches_comments() { let mut order_view = test_object_info("order_view", "VIEW"); order_view.comment = Some("monthly revenue summary".to_string()); let mut sync_user = test_object_info("sync_user", "PROCEDURE"); sync_user.comment = Some("sync account records".to_string()); let objects = vec![order_view, sync_user, test_object_info("audit_log", "TABLE")]; let object_types = vec!["VIEW".to_string()]; let filtered = filter_object_infos(objects, Some("revenue"), None, None, Some(&object_types), None); assert_eq!(filtered.into_iter().map(|object| object.name).collect::>(), vec!["order_view"]); } #[test] fn presto_like_information_schema_sql_uses_catalog_and_schema_without_system_jdbc() { let sql = presto_like_information_schema_tables_sql("hive", "sales_analytics", None, None); assert_eq!( sql, "SELECT table_name, CASE table_type WHEN 'BASE TABLE' THEN 'TABLE' ELSE table_type END AS table_type FROM \"hive\".information_schema.tables WHERE table_schema = 'sales_analytics' AND table_type IN ('BASE TABLE', 'VIEW') ORDER BY table_type, table_name" ); assert!(!sql.contains("system.jdbc.tables")); } #[test] fn presto_like_information_schema_sql_escapes_identifiers_and_literals() { let sql = presto_like_information_schema_tables_sql("hi\"ve", "sales'analytics", None, None); assert!(sql.contains("\"hi\"\"ve\".information_schema.tables")); assert!(sql.contains("table_schema = 'sales''analytics'")); } #[test] fn presto_like_information_schema_sql_pushes_table_filter_and_limit() { let sql = presto_like_information_schema_tables_sql("hive", "sales_analytics", Some("Daily_%\\"), Some(20)); assert!(sql.contains("AND lower(table_name) LIKE 'daily\\_\\%\\\\%' ESCAPE '\\'")); assert!(sql.ends_with("ORDER BY table_type, table_name LIMIT 20")); } #[test] fn presto_like_information_schema_columns_sql_uses_catalog_information_schema() { let sql = presto_like_information_schema_columns_sql("hive", "sales_analytics", "daily_revenue"); assert_eq!( sql, "SELECT column_name, data_type, is_nullable, column_default, comment FROM \"hive\".information_schema.columns WHERE table_schema = 'sales_analytics' AND table_name = 'daily_revenue' ORDER BY ordinal_position" ); assert!(!sql.contains("system.jdbc.columns")); } #[test] fn presto_like_information_schema_columns_sql_escapes_identifiers_and_literals() { let sql = presto_like_information_schema_columns_sql("hi\"ve", "sales'analytics", "daily'revenue"); assert!(sql.contains("\"hi\"\"ve\".information_schema.columns")); assert!(sql.contains("table_schema = 'sales''analytics'")); assert!(sql.contains("table_name = 'daily''revenue'")); } #[test] fn presto_like_tables_from_query_result_normalizes_base_table_type() { let result = super::db::QueryResult { columns: vec!["table_name".to_string(), "table_type".to_string()], column_types: vec![], column_sortables: vec![], spatial_columns: vec![], spatial_values: vec![], rows: vec![ vec![serde_json::json!("daily_revenue"), serde_json::json!("BASE TABLE")], vec![serde_json::json!("revenue_view"), serde_json::json!("VIEW")], ], affected_rows: 0, execution_time_ms: 1, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; let tables = presto_like_tables_from_query_result(&result); assert_eq!(tables[0].name, "daily_revenue"); assert_eq!(tables[0].table_type, "TABLE"); assert_eq!(tables[1].name, "revenue_view"); assert_eq!(tables[1].table_type, "VIEW"); assert_eq!(normalize_information_schema_table_type("MATERIALIZED VIEW"), "MATERIALIZED_VIEW"); } #[test] fn presto_like_columns_from_query_result_maps_column_metadata() { let result = super::db::QueryResult { columns: vec![ "column_name".to_string(), "data_type".to_string(), "is_nullable".to_string(), "column_default".to_string(), "comment".to_string(), ], column_types: vec![], column_sortables: vec![], spatial_columns: vec![], spatial_values: vec![], rows: vec![ vec![ serde_json::json!("amount"), serde_json::json!("decimal(12,2)"), serde_json::json!("NO"), serde_json::Value::Null, serde_json::json!("daily amount"), ], vec![ serde_json::json!("code"), serde_json::json!("varchar(64)"), serde_json::json!("YES"), serde_json::Value::Null, serde_json::Value::Null, ], ], affected_rows: 0, execution_time_ms: 1, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; let columns = presto_like_columns_from_query_result(&result); assert_eq!(columns[0].name, "amount"); assert_eq!(columns[0].data_type, "decimal(12,2)"); assert!(!columns[0].is_nullable); assert_eq!(columns[0].comment.as_deref(), Some("daily amount")); assert_eq!(columns[0].numeric_precision, Some(12)); assert_eq!(columns[0].numeric_scale, Some(2)); assert_eq!(columns[0].character_maximum_length, None); assert!(!columns[0].is_primary_key); assert_eq!(columns[1].name, "code"); assert!(columns[1].is_nullable); assert_eq!(columns[1].numeric_precision, None); assert_eq!(columns[1].numeric_scale, None); assert_eq!(columns[1].character_maximum_length, Some(64)); } #[test] fn detects_unsupported_agent_completion_assistant_errors() { assert!(super::is_agent_completion_assistant_unsupported( "Agent RPC error (-1): Unknown method: completion_assistant_search_v1" )); assert!(super::is_agent_completion_assistant_unsupported( "Agent RPC error (-1): unknown method: completion_assistant_search_v1" )); assert!(super::is_agent_completion_assistant_unsupported( "Agent RPC error (-1): Completion assistant search is not supported by this agent" )); assert!(!super::is_agent_completion_assistant_unsupported("Agent RPC error (-1): Connection failed")); } #[test] fn clickhouse_metadata_prefers_schema_qualifier() { assert_eq!(clickhouse_metadata_database("", "testdb"), "testdb"); assert_eq!(clickhouse_metadata_database("testdb", ""), "testdb"); // 查询元数据流程:database 是 tab 当前库,schema 是 SQL 限定的真实库 assert_eq!(clickhouse_metadata_database("default", "testdb"), "testdb"); } #[test] fn deduplicates_columns_and_preserves_later_comment() { let columns = deduplicate_column_infos(vec![ test_column("ID", None, false), test_column("ID", Some("源主键"), true), test_column("TFBH", Some(""), false), test_column("TFBH", Some("台账编号"), false), ]); assert_eq!(columns.len(), 2); assert_eq!(columns[0].name, "ID"); assert_eq!(columns[0].comment.as_deref(), Some("源主键")); assert!(columns[0].is_primary_key); assert_eq!(columns[1].name, "TFBH"); assert_eq!(columns[1].comment.as_deref(), Some("台账编号")); } #[test] fn postgres_like_agent_metadata_fallback_targets_pg_compatible_agents() { assert!(!is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Kingbase))); assert!(is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Highgo))); assert!(is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Vastbase))); assert!(!is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Uxdb))); assert!(!is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Postgres))); assert!(!is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Mysql))); } #[test] fn postgres_extension_metadata_fallback_targets_pg_compatible_agents() { for db_type in [DatabaseType::Highgo, DatabaseType::Vastbase] { assert!(agent_postgres_extension_fallback_config(Some(&test_connection_config(db_type))).is_some()); } for db_type in [DatabaseType::Kingbase, DatabaseType::Uxdb, DatabaseType::Postgres, DatabaseType::Mysql] { assert!(agent_postgres_extension_fallback_config(Some(&test_connection_config(db_type))).is_none()); } assert!(agent_postgres_extension_fallback_config(None).is_none()); } #[test] fn agent_metadata_timeout_defaults_to_sixty_seconds_and_honors_longer_config() { assert_eq!(super::agent_metadata_timeout(None), Some(std::time::Duration::from_secs(60))); let mut config = test_connection_config(DatabaseType::Oracle); assert_eq!(super::agent_metadata_timeout(Some(&config)), Some(std::time::Duration::from_secs(60))); config.query_timeout_secs = 120; assert_eq!(super::agent_metadata_timeout(Some(&config)), Some(std::time::Duration::from_secs(120))); config.query_timeout_secs = 0; assert_eq!(super::agent_metadata_timeout(Some(&config)), None); } #[test] fn oracle_table_comment_sql_targets_single_table_and_escapes_literals() { let sql = oracle_table_comment_sql("APP'S", "USER'S"); assert!(sql.contains("ALL_TAB_COMMENTS")); assert!(sql.contains("OWNER = 'APP''S'")); assert!(sql.contains("TABLE_NAME = 'USER''S'")); assert!(sql.contains("TABLE_TYPE IN ('TABLE', 'VIEW')")); assert!(!sql.contains("ALL_OBJECTS")); } #[test] fn tdengine_table_comment_sql_targets_one_name_and_escapes_literals() { let sql = tdengine_table_comment_sql("dbx's", "meter's"); assert!(sql.contains("information_schema.ins_stables")); assert!(sql.contains("information_schema.ins_tables")); assert!(sql.contains("db_name = 'dbx''s'")); assert!(sql.contains("stable_name = 'meter''s'")); assert!(sql.contains("table_name = 'meter''s'")); } #[test] fn tdengine_table_comments_sql_only_queries_comments_matching_the_filter() { let sql = tdengine_table_comments_sql("dbx's", "s_%\\q"); assert!(sql.contains("information_schema.ins_stables")); assert!(sql.contains("information_schema.ins_tables")); assert!(sql.contains("db_name = 'dbx''s'")); assert!(sql.contains("table_comment IS NOT NULL")); assert!(sql.contains("LOWER(table_comment) LIKE '%s%\\_%\\%%\\\\%q%'")); assert!(!sql.contains("LIMIT")); } #[test] fn tdengine_table_comment_pattern_respects_ascii_boundary() { let filter_49 = "a".repeat(49); let filter_50 = format!("{filter_49}b"); let pattern_49 = tdengine_table_comment_like_pattern(&filter_49); let pattern_50 = tdengine_table_comment_like_pattern(&filter_50); assert_eq!(pattern_49.len(), 99); assert_eq!(pattern_50, pattern_49); assert!(pattern_50.len() <= TDENGINE_LIKE_PATTERN_MAX_BYTES); assert!(!metadata_name_or_comment_matches("table", Some(&filter_49), &filter_50)); } #[test] fn tdengine_table_comment_pattern_keeps_escaped_fragments_within_limit() { assert_eq!(tdengine_table_comment_like_pattern("%_\\"), r"%\%%\_%\\%"); let pattern_33 = tdengine_table_comment_like_pattern(&"%".repeat(33)); let pattern_34 = tdengine_table_comment_like_pattern(&"%".repeat(34)); assert_eq!(pattern_33.len(), TDENGINE_LIKE_PATTERN_MAX_BYTES); assert_eq!(pattern_34, pattern_33); assert!(pattern_34.ends_with('%')); } #[test] fn tdengine_table_comment_pattern_truncates_only_at_utf8_boundaries() { let pattern_24 = tdengine_table_comment_like_pattern(&"你".repeat(24)); let pattern_25 = tdengine_table_comment_like_pattern(&"你".repeat(25)); assert_eq!(pattern_24.len(), 97); assert_eq!(pattern_25, pattern_24); assert!(pattern_25.is_char_boundary(pattern_25.len())); assert!(pattern_25.len() <= TDENGINE_LIKE_PATTERN_MAX_BYTES); } #[test] fn tdengine_comment_search_uses_a_short_outer_deadline() { assert_eq!(TDENGINE_COMMENT_SEARCH_TIMEOUT, std::time::Duration::from_secs(5)); } #[test] fn oracle_table_comment_from_query_result_returns_optional_non_blank_comment() { let result = db::QueryResult { columns: vec!["COMMENTS".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: vec![], spatial_values: vec![], rows: vec![vec![serde_json::json!("Customer table")]], affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; assert_eq!(oracle_table_comment_from_query_result(result).unwrap().as_deref(), Some("Customer table")); let empty = db::QueryResult { columns: vec!["COMMENTS".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: vec![], spatial_values: vec![], rows: vec![vec![serde_json::json!(" ")]], affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; assert_eq!(oracle_table_comment_from_query_result(empty).unwrap(), None); } #[test] fn oracle_table_comments_sql_targets_current_page_tables() { let sql = oracle_table_comments_sql("dbx_test", &["ORDERS".to_string(), "USER'S".to_string()]).unwrap(); assert!(sql.contains("ALL_TAB_COMMENTS")); assert!(sql.contains("OWNER = 'DBX_TEST'")); assert!(sql.contains("TABLE_NAME IN ('ORDERS', 'USER''S')")); assert!(sql.contains("TABLE_TYPE IN ('TABLE', 'VIEW')")); assert!(sql.contains("COMMENTS IS NOT NULL")); assert_eq!(oracle_table_comments_sql("DBX_TEST", &[]), None); } #[test] fn table_comments_from_query_result_maps_non_blank_comments() { let result = db::QueryResult { columns: vec!["TABLE_NAME".to_string(), "COMMENTS".to_string()], column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: vec![], spatial_values: vec![], rows: vec![ vec![serde_json::json!("ORDERS"), serde_json::json!("Orders table")], vec![serde_json::json!("PRODUCTS"), serde_json::json!(" ")], ], affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; let comments = table_comments_from_query_result(result); assert_eq!(comments.get("ORDERS").map(String::as_str), Some("Orders table")); assert!(!comments.contains_key("PRODUCTS")); } #[test] fn oracle_columns_sql_uses_exact_table_name_for_quoted_lowercase_tables() { let sql = oracle_columns_sql("DBX_TEST", "test").unwrap(); assert!(sql.contains("ALL_TAB_COLUMNS")); assert!(sql.contains("ALL_COL_COMMENTS")); assert!(!sql.contains("SYS_CONTEXT")); assert!(!sql.contains("ALL_SYNONYMS")); assert!(!sql.contains("CONNECT BY")); assert!(sql.contains("c.OWNER = 'DBX_TEST'")); assert!(sql.contains("c.TABLE_NAME = 'test'")); assert!(sql.contains("cols.OWNER = c.OWNER")); assert!(sql.contains("cols.TABLE_NAME = c.TABLE_NAME")); assert!(sql.contains("cm.OWNER = c.OWNER")); } #[test] fn oracle_synonym_sql_resolves_private_before_public_without_hierarchical_queries() { let sql = oracle_synonym_target_sql("Dbx'Owner", "ORDERS_ALIAS", true).unwrap(); assert!(!sql.contains("SYS_CONTEXT")); assert!(sql.contains("FROM ALL_SYNONYMS s")); assert!(sql.contains("s.OWNER IN ('Dbx''Owner', 'PUBLIC')")); assert!(sql.contains("CASE WHEN s.OWNER = 'Dbx''Owner' THEN 0 ELSE 1 END")); assert!(sql.contains("s.DB_LINK IS NULL")); assert!(!sql.contains("CONNECT BY")); let nested_sql = oracle_synonym_target_sql("OTHER_OWNER", "ORDERS_ALIAS_2", false).unwrap(); assert!(nested_sql.contains("s.OWNER = 'OTHER_OWNER'")); assert!(!nested_sql.contains("s.OWNER IN")); } #[test] fn oracle_blank_schema_requires_a_resolved_current_schema_literal() { assert_eq!( ORACLE_CURRENT_SCHEMA_SQL, "SELECT SYS_CONTEXT('USERENV','CURRENT_SCHEMA') AS CURRENT_SCHEMA FROM DUAL" ); assert!(oracle_columns_sql("", "ORDERS_ALIAS").is_err()); let current_schema = oracle_current_schema_from_query_result(oracle_current_schema_result( &["current_schema"], vec![vec![serde_json::json!(" Mixed'Case ")]], )) .unwrap(); assert_eq!(current_schema, "Mixed'Case"); let sql = oracle_columns_sql_for_resolved_owner(¤t_schema, "ORDERS_ALIAS").unwrap(); let synonym_sql = oracle_synonym_target_sql(¤t_schema, "ORDERS_ALIAS", true).unwrap(); assert!(sql.contains("c.OWNER = 'Mixed''Case'")); assert!(synonym_sql.contains("s.OWNER IN ('Mixed''Case', 'PUBLIC')")); assert!(!sql.contains("SYS_CONTEXT")); } #[test] fn oracle_current_schema_result_rejects_missing_empty_and_non_string_values() { assert!(oracle_current_schema_from_query_result(oracle_current_schema_result( &["OTHER"], vec![vec![serde_json::json!("DBX_TEST")]], )) .is_err()); let missing_rows = oracle_current_schema_result(&["CURRENT_SCHEMA"], Vec::new()); assert!(oracle_current_schema_from_query_result(missing_rows).is_err()); assert!(oracle_current_schema_from_query_result(oracle_current_schema_result( &["CURRENT_SCHEMA"], vec![vec![serde_json::json!(" ")]], )) .is_err()); assert!(oracle_current_schema_from_query_result(oracle_current_schema_result( &["CURRENT_SCHEMA"], vec![vec![serde_json::json!(11)]], )) .is_err()); } #[test] fn oracle_synonym_resolver_follows_two_levels_and_rejects_cycles() { let initial = OracleObjectRef { owner: "DBX_TEST".to_string(), name: "ORDERS_ALIAS".to_string() }; let mut resolver = OracleSynonymResolver::new(initial.owner.clone(), initial.name.clone()); assert_eq!(resolver.current(), &initial); assert!(resolver.include_public_synonym()); assert!(resolver.follow(OracleObjectRef { owner: "APP".to_string(), name: "ORDERS_ALIAS_2".to_string() })); assert!(!resolver.include_public_synonym()); assert!(resolver.follow(OracleObjectRef { owner: "DATA".to_string(), name: "ORDERS".to_string() })); assert_eq!(resolver.current().owner, "DATA"); assert_eq!(resolver.current().name, "ORDERS"); assert!(!resolver.follow(initial)); } #[test] fn oracle_columns_sql_preserves_quoted_case_synonym_names_and_excludes_database_links() { let sql = oracle_synonym_target_sql("DBX_TEST", "Order Alias", true).unwrap(); assert!(sql.contains("s.SYNONYM_NAME = 'Order Alias'")); assert!(!sql.contains("ORDER ALIAS")); assert_eq!(sql.matches("s.DB_LINK IS NULL").count(), 1); } #[test] fn oracle_synonym_target_preserves_dictionary_identifier_values() { let target = oracle_synonym_target_from_query_result(oracle_current_schema_result( &["table_name", "table_owner"], vec![vec![serde_json::json!("Order Detail"), serde_json::json!("Mixed Owner")]], )) .unwrap(); assert_eq!(target.owner, "Mixed Owner"); assert_eq!(target.name, "Order Detail"); } #[test] fn oracle_synonym_resolver_enforces_maximum_depth() { let mut resolver = OracleSynonymResolver::new("DBX_TEST".to_string(), "ALIAS_0".to_string()); for depth in 1..=ORACLE_SYNONYM_MAX_DEPTH { assert!(resolver.follow(OracleObjectRef { owner: "DBX_TEST".to_string(), name: format!("ALIAS_{depth}") })); } assert!(!resolver.can_follow()); assert!(!resolver.follow(OracleObjectRef { owner: "DBX_TEST".to_string(), name: "ALIAS_TOO_DEEP".to_string() })); } #[test] fn oracle_columns_sql_first_handles_current_schema_editor_sessions() { assert!(should_query_oracle_columns_via_sql_first(&DatabaseType::Oracle, Some("tab-1"))); } #[test] fn oracle_columns_sql_first_handles_explicit_schema_editor_sessions() { assert!(should_query_oracle_columns_via_sql_first(&DatabaseType::Oracle, Some("tab-1"))); assert!(!should_query_oracle_columns_via_sql_first(&DatabaseType::Oracle, None)); assert!(!should_query_oracle_columns_via_sql_first(&DatabaseType::Oracle, Some(" "))); assert!(!should_query_oracle_columns_via_sql_first(&DatabaseType::Postgres, Some("tab-1"))); } #[test] fn oracle_columns_from_query_result_maps_types_comments_and_primary_key() { let result = db::QueryResult { columns: vec![ "COLUMN_NAME".to_string(), "DATA_TYPE".to_string(), "NULLABLE".to_string(), "DATA_DEFAULT".to_string(), "DATA_LENGTH".to_string(), "DATA_PRECISION".to_string(), "DATA_SCALE".to_string(), "COLUMN_ID".to_string(), "IS_PK".to_string(), "COMMENTS".to_string(), ], column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: vec![], spatial_values: vec![], rows: vec![ vec![ serde_json::json!("id"), serde_json::json!("VARCHAR2"), serde_json::json!("N"), serde_json::Value::Null, serde_json::json!("255"), serde_json::Value::Null, serde_json::Value::Null, serde_json::json!("1"), serde_json::json!("1"), serde_json::json!("identifier"), ], vec![ serde_json::json!("data"), serde_json::json!("TIMESTAMP"), serde_json::json!("Y"), serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null, serde_json::json!("2"), serde_json::json!("0"), serde_json::Value::Null, ], ], affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; let columns = oracle_columns_from_query_result(result); assert_eq!(columns.len(), 2); assert_eq!(columns[0].name, "id"); assert_eq!(columns[0].data_type, "VARCHAR2(255)"); assert!(!columns[0].is_nullable); assert!(columns[0].is_primary_key); assert_eq!(columns[0].comment.as_deref(), Some("identifier")); assert_eq!(columns[1].name, "data"); assert_eq!(columns[1].data_type, "TIMESTAMP"); assert!(columns[1].is_nullable); } #[test] fn oracle_object_statistics_sql_reads_rows_and_segment_bytes() { let sql = oracle_object_statistics_sql("app's"); assert!(sql.contains("ALL_TABLES")); assert!(sql.contains("ALL_SEGMENTS")); assert!(sql.contains("ALL_INDEXES")); assert!(sql.contains("ALL_LOBS")); assert!(sql.contains("t.NUM_ROWS")); assert!(sql.contains("OWNER = 'APP''S'")); assert!(sql.contains("t.NESTED = 'NO'")); let dba_sql = oracle_object_statistics_dba_segments_sql("app's"); assert!(dba_sql.contains("DBA_SEGMENTS")); assert!(!dba_sql.contains("ALL_SEGMENTS")); let user_sql = oracle_object_statistics_user_segments_sql("app's"); assert!(user_sql.contains("USER_SEGMENTS")); assert!(user_sql.contains("OWNER = 'APP''S'")); assert!(user_sql.contains("t.OWNER = USER")); assert!(!user_sql.contains("CURRENT_SCHEMA")); let rows_only_sql = oracle_object_statistics_rows_only_sql("app's"); assert!(rows_only_sql.contains("ALL_TABLES")); assert!(rows_only_sql.contains("CAST(NULL AS NUMBER) AS TOTAL_BYTES")); assert!(!rows_only_sql.contains("ALL_SEGMENTS")); } #[test] fn dameng_object_statistics_sql_uses_available_segment_views() { let dba_sql = dameng_object_statistics_dba_segments_sql("app's"); assert!(dba_sql.contains("DBA_SEGMENTS")); assert!(dba_sql.contains("ALL_INDEXES")); assert!(!dba_sql.contains("ALL_SEGMENTS")); assert!(!dba_sql.contains("ALL_LOBS")); assert!(dba_sql.contains("OWNER = 'APP''S'")); assert!(dba_sql.contains("t.NESTED IS NULL OR t.NESTED = 'NO'")); let user_sql = dameng_object_statistics_user_segments_sql("app's"); assert!(user_sql.contains("USER_SEGMENTS")); assert!(user_sql.contains("t.OWNER = USER")); let rows_only_sql = dameng_object_statistics_rows_only_sql("app's"); assert!(rows_only_sql.contains("CAST(NULL AS NUMBER) AS TOTAL_BYTES")); assert!(!rows_only_sql.contains("SEGMENTS")); } #[test] fn gbase8a_object_statistics_sql_uses_information_schema() { let gbase_sql = gbase8a_object_statistics_sql("shop's"); assert!(gbase_sql.contains("information_schema.TABLES")); assert!(gbase_sql.contains("DATA_LENGTH")); assert!(gbase_sql.contains("INDEX_LENGTH")); assert!(gbase_sql.contains("TABLE_SCHEMA = 'shop''s'")); } #[test] fn oracle_object_statistics_from_query_result_maps_numbers() { let result = db::QueryResult { columns: vec![ "TABLE_NAME".to_string(), "OWNER".to_string(), "NUM_ROWS".to_string(), "TOTAL_BYTES".to_string(), ], column_types: Vec::new(), column_sortables: Vec::new(), spatial_columns: vec![], spatial_values: vec![], rows: vec![ vec![ serde_json::json!("ORDERS"), serde_json::json!("APP"), serde_json::json!("1200"), serde_json::json!(65536), ], vec![ serde_json::json!("AUDIT_LOG"), serde_json::json!("APP"), serde_json::Value::Null, serde_json::json!("8192"), ], ], affected_rows: 0, execution_time_ms: 0, truncated: false, session_id: None, has_more: false, elasticsearch_raw_body: None, messages: Vec::new(), }; let stats = oracle_object_statistics_from_query_result(result); assert_eq!(stats.len(), 2); assert_eq!(stats[0].name, "ORDERS"); assert_eq!(stats[0].schema.as_deref(), Some("APP")); assert_eq!(stats[0].estimated_rows, Some(1200)); assert_eq!(stats[0].total_bytes, Some(65536)); assert_eq!(stats[1].estimated_rows, None); assert_eq!(stats[1].total_bytes, Some(8192)); } #[test] fn apply_table_comments_only_fills_missing_table_comments() { let mut tables = vec![ super::db::TableInfo { name: "ORDERS".to_string(), table_type: "TABLE".to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, }, super::db::TableInfo { name: "PRODUCTS".to_string(), table_type: "TABLE".to_string(), valid: None, comment: Some("Existing".to_string()), parent_schema: None, parent_name: None, }, ]; let comments = HashMap::from([ ("ORDERS".to_string(), "Orders table".to_string()), ("PRODUCTS".to_string(), "Products table".to_string()), ]); super::apply_table_comments(&mut tables, &comments); assert_eq!(tables[0].comment.as_deref(), Some("Orders table")); assert_eq!(tables[1].comment.as_deref(), Some("Existing")); } #[test] fn oracle_missing_object_table_comment_names_only_includes_tables_and_views() { let objects = vec![ super::db::ObjectInfo { name: "ORDERS".to_string(), object_type: "TABLE".to_string(), schema: Some("DBX_TEST".to_string()), valid: None, signature: None, custom_type_kind: None, has_members: None, comment: None, created_at: None, updated_at: None, parent_schema: None, parent_name: None, trigger: None, xugu_type_members_expandable: None, }, super::db::ObjectInfo { name: "ORDERS_VIEW".to_string(), object_type: "VIEW".to_string(), schema: Some("DBX_TEST".to_string()), valid: None, signature: None, custom_type_kind: None, has_members: None, comment: None, created_at: None, updated_at: None, parent_schema: None, parent_name: None, trigger: None, xugu_type_members_expandable: None, }, super::db::ObjectInfo { name: "REFRESH_ORDERS".to_string(), object_type: "PROCEDURE".to_string(), schema: Some("DBX_TEST".to_string()), valid: None, signature: None, custom_type_kind: None, has_members: None, comment: None, created_at: None, updated_at: None, parent_schema: None, parent_name: None, trigger: None, xugu_type_members_expandable: None, }, ]; assert_eq!( super::oracle_missing_object_table_comment_names(&objects), vec!["ORDERS".to_string(), "ORDERS_VIEW".to_string()] ); } #[test] fn doris_family_catalog_capable_matches_doris_and_starrocks_only() { // Doris and StarRocks expose multi-catalog federation. assert!(db::mysql_compatible::supports_external_catalogs(&test_connection_config(DatabaseType::Doris))); assert!(db::mysql_compatible::supports_external_catalogs(&test_connection_config(DatabaseType::StarRocks))); // Driver profiles for Doris/SelectDB/StarRocks also qualify. let mut doris = test_connection_config(DatabaseType::Mysql); doris.driver_profile = Some("doris".to_string()); assert!(db::mysql_compatible::supports_external_catalogs(&doris)); let mut selectdb = test_connection_config(DatabaseType::Mysql); selectdb.driver_profile = Some("selectdb".to_string()); assert!(db::mysql_compatible::supports_external_catalogs(&selectdb)); let mut starrocks = test_connection_config(DatabaseType::Mysql); starrocks.driver_profile = Some("starrocks".to_string()); assert!(db::mysql_compatible::supports_external_catalogs(&starrocks)); // ManticoreSearch shares the MySQL code path but has no catalog concept. assert!(!db::mysql_compatible::supports_external_catalogs(&test_connection_config( DatabaseType::ManticoreSearch ))); let mut manticore = test_connection_config(DatabaseType::Mysql); manticore.driver_profile = Some("manticoresearch".to_string()); assert!(!db::mysql_compatible::supports_external_catalogs(&manticore)); // Plain MySQL / Postgres are not catalog-capable. assert!(!db::mysql_compatible::supports_external_catalogs(&test_connection_config(DatabaseType::Mysql))); assert!(!db::mysql_compatible::supports_external_catalogs(&test_connection_config(DatabaseType::Postgres))); } } pub async fn list_objects_core( state: &AppState, connection_id: &str, database: &str, schema: &str, filter: Option<&str>, limit: Option, offset: Option, object_types: Option<&[String]>, table_name_filter: Option<&TableNameFilter>, ) -> Result, String> { let db_config = connection_config(state, connection_id).await; let filter_locally_after_oracle_comments = db_config.as_ref().is_some_and(|config| { config.db_type == DatabaseType::Oracle && filter.is_some_and(|filter| !filter.trim().is_empty()) }); let force_local_table_name_filter = table_name_filter.is_some_and(|filter| !filter.is_empty()); let use_oracle_agent_paging = db_config.as_ref().is_some_and(is_default_oracle_agent_config) && !filter_locally_after_oracle_comments && !force_local_table_name_filter; let metadata_session = EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "objects").await; let result = retry_metadata_connection_for_session( state, connection_id, Some(database), metadata_session.client_session_id(), || async { let objects = list_objects_once( state, connection_id, database, schema, filter, limit, offset, object_types, table_name_filter, metadata_session.client_session_id(), ) .await .map(|outcome| { let final_offset = if outcome.paging_applied || agent_paging_likely_applied(use_oracle_agent_paging, limit, outcome.objects.len()) { Some(0) } else { offset }; filter_object_infos(outcome.objects, filter, limit, final_offset, object_types, table_name_filter) })?; Ok(objects) }, ) .await; metadata_session.finish(state, connection_id, Some(database)).await; result } pub async fn list_object_statistics_core( state: &AppState, connection_id: &str, database: &str, schema: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || { list_object_statistics_once(state, connection_id, database, schema) }) .await } pub async fn list_completion_objects_core( state: &AppState, connection_id: &str, database: &str, schema: &str, ) -> Result, String> { let metadata_session = EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "completion-objects").await; let result = retry_metadata_connection_for_session( state, connection_id, Some(database), metadata_session.client_session_id(), || list_completion_objects_once(state, connection_id, database, schema, metadata_session.client_session_id()), ) .await; metadata_session.finish(state, connection_id, Some(database)).await; result } fn ephemeral_agent_metadata_session_id(config: Option<&ConnectionConfig>, task_kind: &str) -> Option { config .filter(|config| crate::database_capabilities::is_agent_type(&config.db_type)) .map(|_| task_client_session_id(task_kind, &uuid::Uuid::new_v4().to_string())) } async fn close_ephemeral_agent_metadata_session( state: &AppState, connection_id: &str, database: Option<&str>, client_session_id: Option<&str>, ) -> bool { let Some(client_session_id) = client_session_id else { return true; }; match state.close_metadata_session_pool(connection_id, database, client_session_id).await { Ok(_) => true, Err(error) => { log::warn!( "Failed to close ephemeral Agent metadata session '{client_session_id}' for '{connection_id}': {error}" ); false } } } pub async fn completion_assistant_search_core( state: &AppState, request: db::CompletionAssistantRequest, ) -> Result { let started_at = Instant::now(); let request_summary = format!( "connection_id={} database={} schema={:?} kinds={:?} mask={} limit={:?}", request.connection_id, request.database, request.schema, request.object_kinds, request.mask, request.max_results ); retry_metadata_connection(state, &request.connection_id, Some(&request.database), || async { let pool_key = state .get_or_create_metadata_pool_for_session(&request.connection_id, Some(&request.database), None) .await?; log::debug!("[schema][completion_assistant:start] {request_summary}"); { let pool_handle = state.pool_handle(&pool_key).await; try_sqlserver!(pool_handle, completion_assistant_search, &request); } { let pool_handle = state.pool_handle(&pool_key).await; if let Some(pool) = pool_handle.as_ref().and_then(|pool| match pool { PoolKind::Sqlite(pool) => Some(pool.clone()), _ => None, }) { return db::sqlite::completion_assistant_search(&pool, &request).await; } } #[cfg(feature = "duckdb-sidecar")] { let pool_handle = state.pool_handle(&pool_key).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), DuckDbWorker) { return client.completion_assistant(request.clone()).await; } } { let pool_handle = state.pool_handle(&pool_key).await; if let Some(pool) = pool_handle.as_ref().and_then(|pool| match pool { PoolKind::Postgres(pool) => Some(pool.clone()), _ => None, }) { let db_config = connection_config(state, &request.connection_id).await; if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OpenGauss) && request.parent_name.as_deref().is_some_and(|name| !name.trim().is_empty()) && request.object_kinds.iter().any(db::CompletionAssistantObjectKind::is_routine_like) { let response = db::postgres::opengauss_package_members(&pool, &request).await?; // fallback_used marks "parent is not a package": continue with // the ordinary routine completion instead of returning nothing. if !response.fallback_used { return Ok(response); } } return if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OpenGauss) { // openGauss variant excludes package/private members from the // ordinary routine lookup, so the fallback stays consistent // with the package-aware path above. db::postgres::opengauss_completion_assistant_search(&pool, &request).await } else { db::postgres::completion_assistant_search(&pool, &request).await }; } } { let pool_handle = state.pool_handle(&pool_key).await; if let Some(pool) = pool_handle.as_ref().and_then(|pool| match pool { PoolKind::Mysql(pool, mode) if *mode != MysqlMode::OceanBaseOracle => Some(pool.clone()), _ => None, }) { return db::mysql::completion_assistant_search(&pool, &request).await; } } { let pool_handle = state.pool_handle(&pool_key).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let db_config = connection_config(state, &request.connection_id).await; let mut client = client.lock().await; match client .completion_assistant_search::( &request, agent_metadata_timeout(db_config.as_ref()), ) .await { Ok(mut response) => { response.fallback_used = false; return Ok(response); } Err(error) if is_agent_completion_assistant_unsupported(&error) => { log::debug!( "[schema][completion_assistant:agent-fallback] {} reason={}", request_summary, error ); } Err(error) => return Err(error), } } } let response = completion_assistant_fallback_core(state, &request).await; if let Ok(response) = &response { log::debug!( "[schema][completion_assistant:done] {} elapsed_ms={} candidates={} fallback_used={}", request_summary, started_at.elapsed().as_millis(), response.candidates.len(), response.fallback_used ); } response }) .await } fn is_agent_completion_assistant_unsupported(error: &str) -> bool { let error = error.to_ascii_lowercase(); error.contains("unknown method: completion_assistant_search_v1") || error.contains("method not found: completion_assistant_search_v1") || error.contains("completion assistant search is not supported") } async fn completion_assistant_fallback_core( state: &AppState, request: &db::CompletionAssistantRequest, ) -> Result { let limit = request.max_results.unwrap_or(100).clamp(1, 1000); let kinds = if request.object_kinds.is_empty() { vec![db::CompletionAssistantObjectKind::Table, db::CompletionAssistantObjectKind::View] } else { request.object_kinds.clone() }; let mut candidates = Vec::new(); let schema = request.parent_schema.as_deref().or(request.schema.as_deref()).unwrap_or(""); let filter = request.mask.trim().trim_matches('%'); if kinds.iter().any(|kind| matches!(kind, db::CompletionAssistantObjectKind::Schema)) { let schemas = list_schemas_core(state, &request.connection_id, &request.database).await?; for schema_name in schemas { if completion_name_matches(&schema_name, filter, request.match_mode.as_ref()) { candidates.push(db::CompletionAssistantCandidate { name: schema_name.clone(), kind: db::CompletionAssistantCandidateKind::Schema, database: Some(request.database.clone()), schema: Some(schema_name), parent_schema: None, parent_name: None, comment: None, data_type: None, signature: None, }); } if candidates.len() >= limit { return Ok(db::CompletionAssistantResponse { candidates, incomplete: true, fallback_used: true }); } } } if kinds.iter().any(db::CompletionAssistantObjectKind::is_table_like) { let object_types = completion_table_object_types(&kinds); let tables = list_tables_core( state, &request.connection_id, &request.database, schema, if filter.is_empty() { None } else { Some(filter) }, Some(limit), None, object_types.as_deref(), None, ) .await?; for table in tables { let kind = if table.table_type.to_uppercase().contains("VIEW") { db::CompletionAssistantCandidateKind::View } else { db::CompletionAssistantCandidateKind::Table }; candidates.push(db::CompletionAssistantCandidate { name: table.name, kind, database: Some(request.database.clone()), schema: if schema.is_empty() { None } else { Some(schema.to_string()) }, parent_schema: table.parent_schema, parent_name: table.parent_name, comment: table.comment, data_type: None, signature: None, }); if candidates.len() >= limit { return Ok(db::CompletionAssistantResponse { candidates, incomplete: true, fallback_used: true }); } } } if kinds.iter().any(|kind| matches!(kind, db::CompletionAssistantObjectKind::Column)) { if let Some(table) = request.parent_name.as_deref().filter(|table| !table.trim().is_empty()) { let columns = get_columns_core(state, &request.connection_id, &request.database, schema, table).await?; for column in columns { if completion_name_matches(&column.name, filter, request.match_mode.as_ref()) { candidates.push(db::CompletionAssistantCandidate { name: column.name, kind: db::CompletionAssistantCandidateKind::Column, database: Some(request.database.clone()), schema: if schema.is_empty() { None } else { Some(schema.to_string()) }, parent_schema: if schema.is_empty() { None } else { Some(schema.to_string()) }, parent_name: Some(table.to_string()), comment: column.comment, data_type: Some(column.data_type), signature: None, }); } if candidates.len() >= limit { return Ok(db::CompletionAssistantResponse { candidates, incomplete: true, fallback_used: true }); } } } } Ok(db::CompletionAssistantResponse { candidates, incomplete: false, fallback_used: true }) } fn completion_table_object_types(kinds: &[db::CompletionAssistantObjectKind]) -> Option> { let mut object_types = Vec::new(); if kinds.iter().any(|kind| matches!(kind, db::CompletionAssistantObjectKind::Table)) { object_types.push("table".to_string()); } if kinds.iter().any(|kind| matches!(kind, db::CompletionAssistantObjectKind::View)) { object_types.push("view".to_string()); } if object_types.is_empty() { None } else { Some(object_types) } } fn completion_name_matches(name: &str, filter: &str, mode: Option<&db::CompletionAssistantMatchMode>) -> bool { if filter.is_empty() { return true; } let name = name.to_lowercase(); let filter = filter.to_lowercase(); match mode.unwrap_or(&db::CompletionAssistantMatchMode::Prefix) { db::CompletionAssistantMatchMode::Prefix => name.starts_with(&filter), db::CompletionAssistantMatchMode::Contains => name.contains(&filter), } } async fn list_object_statistics_once( state: &AppState, connection_id: &str, database: &str, schema: &str, ) -> Result, String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; let pool_handle = state.pool_handle(&pool_key).await; try_sqlserver!(pool_handle, list_object_statistics, schema); if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { // Generic JDBC connections have no `listObjectStatistics` RPC, so the // vendor statistics SQL is issued over the plugin's query channel. if let Some(dialect) = external_driver_statistics_dialect(config.as_ref()) { let config = config.clone(); let session = session.clone(); return external_driver_list_object_statistics(session, config.as_ref(), dialect, database, schema).await; } } if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle) { return oracle_agent_list_object_statistics( client, database, schema, agent_metadata_timeout(db_config.as_ref()), ) .await; } if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Dameng) { return dameng_agent_list_object_statistics( client, database, schema, agent_metadata_timeout(db_config.as_ref()), ) .await; } if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) { let sql = kingbase::object_statistics_sql(schema); return agent_list_object_statistics( client, database, schema, sql, agent_metadata_timeout(db_config.as_ref()), ) .await; } if db_config.as_ref().is_some_and(|config| { config.db_type == DatabaseType::Gbase && config.driver_profile.as_deref() != Some("gbase8s") }) { let sql = gbase8a_object_statistics_sql(database); return agent_list_object_statistics( client, database, schema, sql, agent_metadata_timeout(db_config.as_ref()), ) .await; } } if let Some(client) = extract_pool!(pool_handle.as_ref(), VictoriaMetrics) { return db::victoriametrics_driver::list_object_statistics(&client).await; } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, mode) => { if *mode == MysqlMode::OceanBaseOracle || db_config.as_ref().is_some_and(db::manticoresearch::is_config) { Ok(vec![]) } else { db::mysql::list_object_statistics(p, database).await } } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => Ok(vec![]), PoolKind::Postgres(p) => db::postgres::list_object_statistics(p, schema).await, PoolKind::ClickHouse(client) => { db::clickhouse_driver::list_object_statistics(client, clickhouse_metadata_database(database, schema)).await } _ => Ok(vec![]), } } struct ObjectListOutcome { objects: Vec, paging_applied: bool, } async fn list_native_postgres_objects( pool: &deadpool_postgres::Pool, config: &ConnectionConfig, schema: &str, ) -> Result, String> { if config.db_type == DatabaseType::Redshift { db::postgres::list_redshift_objects(pool, schema, true, true).await } else { db::postgres::list_objects(pool, schema, true, true, false).await } } fn unpaged_object_list(objects: Vec) -> ObjectListOutcome { ObjectListOutcome { objects, paging_applied: false } } async fn list_objects_once( state: &AppState, connection_id: &str, database: &str, schema: &str, filter: Option<&str>, limit: Option, offset: Option, object_types: Option<&[String]>, table_name_filter: Option<&TableNameFilter>, client_session_id: Option<&str>, ) -> Result { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await?; let db_config = connection_config(state, connection_id).await; let force_local_table_name_filter = table_name_filter.is_some_and(|filter| !filter.is_empty()); let (mysql_limit, mysql_offset) = if filter.is_none_or(|value| value.trim().is_empty()) && table_name_filter.is_none_or(|filter| filter.is_empty()) { (limit, offset) } else { (None, None) }; { let pool_handle = state.pool_handle(&pool_key).await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { let config = config.clone(); let session = session.clone(); if uses_presto_like_information_schema_tables(&config.db_type) { return external_driver_presto_like_objects( session, config.as_ref(), database, schema, filter, object_types, ) .await .map(unpaged_object_list); } let mut params = serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": schema }); if let Some(filter) = filter.map(str::trim).filter(|value| !value.is_empty()) { params["filter"] = serde_json::json!(filter); } if let Some(object_types) = object_types { params["object_types"] = serde_json::json!(object_types); } return session .invoke_with_timeout::>( "listObjects", params, agent_metadata_timeout(Some(config.as_ref())), ) .await .map(unpaged_object_list); } if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::list_objects(&mut client, schema).await.map(unpaged_object_list); } if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let is_oracle = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle); let use_oracle_agent_paging = db_config.as_ref().is_some_and(is_default_oracle_agent_config); let filter_locally_after_oracle_comments = is_oracle && filter.is_some_and(|filter| !filter.trim().is_empty()); let timeout_duration = agent_metadata_timeout(db_config.as_ref()); let fallback_config = db_config.clone(); if is_oracle && !use_oracle_agent_paging { return oracle_agent_list_objects(client, database, schema, timeout_duration) .await .map(unpaged_object_list); } let mut client = client.lock().await; let agent_filter = if filter_locally_after_oracle_comments { None } else { filter }; let agent_limit = if filter_locally_after_oracle_comments || force_local_table_name_filter { None } else if use_oracle_agent_paging { limit } else { None }; let agent_offset = if filter_locally_after_oracle_comments || force_local_table_name_filter { None } else if use_oracle_agent_paging { offset } else { None }; match client .list_objects_constrained::>( database, schema, agent_filter, agent_limit, agent_offset, object_types, timeout_duration, ) .await { Ok(mut objects) if !objects.is_empty() => { if is_oracle { load_oracle_table_comments_for_objects( &mut client, database, schema, &mut objects, timeout_duration, ) .await?; } return Ok(unpaged_object_list(objects)); } Ok(objects) => { if object_types_only_custom_types(object_types) { // A dedicated type request: the agent is authoritative. // The native fallback never lists types, so running it // would turn a real empty schema or a catalog error into // a misleading empty type group. return Ok(unpaged_object_list(objects)); } if let Some(config) = fallback_config.as_ref() { match native_postgres_metadata_pool(state, connection_id, database, config).await { Ok(Some(pool)) => { return list_native_postgres_objects(&pool, config, schema) .await .map(unpaged_object_list) } Ok(None) => return Ok(unpaged_object_list(objects)), Err(error) => { log::warn!( "[schema][agent:list_objects:fallback-failed] connection_id={} database={} schema={} error={}", connection_id, database, schema, error ); } } } return Ok(unpaged_object_list(objects)); } Err(agent_error) => { if object_types_only_custom_types(object_types) { // Preserve the type catalog error instead of masking it // with a relation/function fallback that cannot serve // user-defined types. return Err(agent_error); } if let Some(config) = fallback_config.as_ref() { if let Some(pool) = native_postgres_metadata_pool(state, connection_id, database, config).await? { return list_native_postgres_objects(&pool, config, schema) .await .map(unpaged_object_list) .map_err(|fallback_error| { crate::db::agent_driver::append_legacy_error_context( &agent_error, &format!("Native PostgreSQL metadata fallback failed: {fallback_error}"), ) }); } } return Err(agent_error); } } } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, mode) => { // Note: mysql and ob_oracle take different second args (database vs schema) if *mode == MysqlMode::OceanBaseOracle { db::ob_oracle::list_objects(p, schema).await.map(unpaged_object_list) } else if db_config.as_ref().is_some_and(db::manticoresearch::is_config) { db::manticoresearch::list_objects(p, database).await.map(unpaged_object_list) } else if db_config.as_ref().is_some_and(db::starrocks::is_config) { db::starrocks::list_table_objects(p, database).await.map(unpaged_object_list) } else if db_config.as_ref().is_some_and(db::oceanbase_mysql::is_config) { db::oceanbase_mysql::list_objects(p, database).await.map(unpaged_object_list) } else if db_config.as_ref().is_some_and(db::mysql_compatible::uses_show_metadata) { db::mysql::list_table_objects_show(p, database).await.map(unpaged_object_list) } else if mysql_table_list_source_for_config(db_config.as_ref()) == MysqlTableListSource::ShowFullTables { db::mysql::list_objects_with_logical_tables(p, database, object_types, mysql_limit, mysql_offset) .await .map(|result| ObjectListOutcome { objects: result.objects, paging_applied: result.paging_applied }) } else { db::mysql::list_objects(p, database, object_types, mysql_limit, mysql_offset) .await .map(|result| ObjectListOutcome { objects: result.objects, paging_applied: result.paging_applied }) } } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { db::questdb::list_objects(p, schema).await.map(unpaged_object_list) } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => { db::cloudberry::list_objects(p, schema).await.map(unpaged_object_list) } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Redshift) => { let include_relations = object_types_include_relations(object_types); let include_routines = object_types_include_routines(object_types); db::postgres::list_redshift_objects(p, schema, include_relations, include_routines) .await .map(unpaged_object_list) } PoolKind::Postgres(p) => { let include_relations = object_types_include_relations(object_types); let include_routines = object_types_include_routines(object_types); let include_custom_types = db_config.as_ref().is_some_and(supports_pg_custom_type_objects) && object_types_include_custom_types(object_types); let mut objects = if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OpenGauss) { // openGauss variant excludes package members (propackage=true) // from the top-level routine list; they are surfaced under // their PACKAGE nodes below. db::postgres::list_opengauss_objects( p, schema, include_relations, include_routines, include_custom_types, ) .await? } else { db::postgres::list_objects(p, schema, include_relations, include_routines, include_custom_types).await? }; if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OpenGauss) { let (include_package_spec, include_package_body) = object_types_include_packages(object_types); objects.extend( db::postgres::list_opengauss_packages(p, schema, include_package_spec, include_package_body) .await?, ); } Ok(unpaged_object_list(objects)) } _ => Ok(unpaged_object_list( list_tables_core(state, connection_id, database, schema, None, None, None, None, None) .await? .into_iter() .map(|table| db::ObjectInfo { name: table.name, object_type: table.table_type, schema: if schema.is_empty() { None } else { Some(schema.to_string()) }, valid: None, signature: None, custom_type_kind: None, has_members: None, comment: table.comment, created_at: None, updated_at: None, parent_schema: table.parent_schema, parent_name: table.parent_name, trigger: None, xugu_type_members_expandable: None, }) .collect(), )), } } async fn list_completion_objects_once( state: &AppState, connection_id: &str, database: &str, schema: &str, client_session_id: Option<&str>, ) -> Result, String> { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await?; let db_config = connection_config(state, connection_id).await; let pool_handle = state.pool_handle(&pool_key).await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { let config = config.clone(); let session = session.clone(); return session .invoke_with_timeout::>( "listObjects", serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": schema }), agent_metadata_timeout(Some(config.as_ref())), ) .await .map(filter_completion_objects); } if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let is_oracle = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle); let fallback_config = db_config.clone(); let objects = if is_oracle { oracle_agent_list_objects(client, database, schema, agent_metadata_timeout(db_config.as_ref())).await? } else { let mut client = client.lock().await; match client .list_objects::>(database, schema, agent_metadata_timeout(db_config.as_ref())) .await { Ok(objects) if !objects.is_empty() => objects, Ok(objects) => { if let Some(config) = fallback_config.as_ref() { match native_postgres_metadata_pool(state, connection_id, database, config).await { Ok(Some(pool)) => { return list_native_postgres_objects(&pool, config, schema) .await .map(filter_completion_objects) } Ok(None) => objects, Err(error) => { log::warn!( "[schema][agent:list_completion_objects:fallback-failed] connection_id={} database={} schema={} error={}", connection_id, database, schema, error ); objects } } } else { objects } } Err(agent_error) => { if let Some(config) = fallback_config.as_ref() { if let Some(pool) = native_postgres_metadata_pool(state, connection_id, database, config).await? { return list_native_postgres_objects(&pool, config, schema) .await .map(filter_completion_objects) .map_err(|fallback_error| { crate::db::agent_driver::append_legacy_error_context( &agent_error, &format!("Native PostgreSQL metadata fallback failed: {fallback_error}"), ) }); } } return Err(agent_error); } } }; return Ok(filter_completion_objects(objects)); } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, mode) if *mode != MysqlMode::OceanBaseOracle => { db::mysql::list_completion_objects(p, database).await } PoolKind::Mysql(p, mode) if *mode == MysqlMode::OceanBaseOracle => { db::ob_oracle::list_objects(p, schema).await.map(filter_completion_objects) } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { db::questdb::list_objects(p, schema).await.map(filter_completion_objects) } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => { db::cloudberry::list_objects(p, schema).await.map(filter_completion_objects) } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Redshift) => { db::postgres::list_redshift_objects(p, schema, false, true).await.map(filter_completion_objects) } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OpenGauss) => { let mut objects = db::postgres::list_opengauss_objects(p, schema, false, true, false).await?; objects.extend(db::postgres::list_opengauss_packages(p, schema, true, true).await?); Ok(filter_completion_objects_with_packages(objects)) } PoolKind::Postgres(p) => { db::postgres::list_objects(p, schema, true, true, false).await.map(filter_completion_objects) } PoolKind::SqlServer(_) => { let outcome = list_objects_once(state, connection_id, database, schema, None, None, None, None, None, None).await?; Ok(filter_completion_objects(outcome.objects)) } _ => Ok(Vec::new()), } } fn filter_completion_objects(objects: Vec) -> Vec { objects .into_iter() .filter(|object| { let object_type = object.object_type.to_ascii_uppercase(); object_type.contains("PROCEDURE") || object_type.contains("FUNCTION") || object_type.contains("TRIGGER") }) .collect() } fn filter_completion_objects_with_packages(objects: Vec) -> Vec { objects .into_iter() .filter(|object| { let object_type = object.object_type.to_ascii_uppercase(); object_type.contains("PROCEDURE") || object_type.contains("FUNCTION") || object_type.contains("TRIGGER") || object_type == "PACKAGE" }) .collect() } fn is_agent_postgres_metadata_fallback_config(config: &ConnectionConfig) -> bool { // HighGo and Vastbase can use the native PostgreSQL metadata path when their // agent returns no rows. UXDB is JDBC-only; opening a PostgreSQL fallback // connection there turns valid empty schemas into misleading DB errors. matches!(config.db_type, DatabaseType::Highgo | DatabaseType::Vastbase) } fn agent_postgres_extension_fallback_config(config: Option<&ConnectionConfig>) -> Option<&ConnectionConfig> { config.filter(|config| is_agent_postgres_metadata_fallback_config(config)) } async fn native_postgres_metadata_pool( state: &AppState, connection_id: &str, database: &str, config: &ConnectionConfig, ) -> Result, String> { if !is_agent_postgres_metadata_fallback_config(config) { return Ok(None); } let mut postgres_config = database_connection_config(config, Some(database)); postgres_config.db_type = DatabaseType::Postgres; postgres_config.validate_native_url_params()?; let (host, port) = state.connection_host_port(connection_id, &postgres_config).await?; let url = connection_url_for_endpoint(&postgres_config, &host, port); let connect_timeout = Duration::from_secs(postgres_config.effective_connect_timeout_secs()); db::postgres::connect(&url, connect_timeout).await.map(Some) } async fn retry_metadata_connection( state: &AppState, connection_id: &str, database: Option<&str>, operation: F, ) -> Result where F: FnMut() -> Fut, Fut: Future>, { retry_metadata_connection_for_session(state, connection_id, database, None, operation).await } async fn retry_metadata_connection_for_session( state: &AppState, connection_id: &str, database: Option<&str>, client_session_id: Option<&str>, mut operation: F, ) -> Result where F: FnMut() -> Fut, Fut: Future>, { let db_type = { let configs = state.configs.read().await; configs.get(connection_id).map(|config| config.db_type) }; let _metadata_permit = match db_type.filter(|db_type| uses_metadata_gate(*db_type)) { Some(db_type) => { Some(state.acquire_metadata_permit(connection_id, database, db_type, client_session_id).await?) } None => None, }; let mut retried = false; let mut missing_pool_retry = false; loop { let result = operation().await; if result.as_ref().err().is_some_and(|error| error == "Pool not found") { if !missing_pool_retry { missing_pool_retry = true; log::debug!( "[metadata:pool:missing-retry] connection_id={} database={}", connection_id, database.unwrap_or_default() ); continue; } log::warn!( "[metadata:pool:missing] connection_id={} database={}", connection_id, database.unwrap_or_default() ); return Err(crate::query::METADATA_POOL_BUSY_ERROR.to_string()); } if result.as_ref().err().is_some_and(|error| crate::query::is_pool_saturation_error(error)) { log::warn!( "[metadata:pool:saturation] connection_id={} database={} error={}", connection_id, database.unwrap_or_default(), result.as_ref().err().map(String::as_str).unwrap_or_default() ); return Err(crate::query::METADATA_POOL_BUSY_ERROR.to_string()); } let recovery = result.as_ref().err().map(|error| metadata_recovery(db_type, error, retried)).unwrap_or_default(); match recovery.action { MetadataErrorAction::ReplaceRuntime => { state .detach_metadata_pool_after_recovery( connection_id, database, client_session_id, recovery.agent_session_id.as_deref(), true, ) .await; return result; } MetadataErrorAction::Discard => { state .detach_metadata_pool_after_recovery( connection_id, database, client_session_id, recovery.agent_session_id.as_deref(), false, ) .await; return result; } MetadataErrorAction::Retry => { retried = true; if let Err(error) = state.reconnect_metadata_pool_for_session(connection_id, database, client_session_id).await { let reconnect_recovery = metadata_recovery(db_type, &error, true); match reconnect_recovery.action { MetadataErrorAction::ReplaceRuntime => { state .detach_metadata_pool_after_recovery( connection_id, database, client_session_id, reconnect_recovery.agent_session_id.as_deref(), true, ) .await; } MetadataErrorAction::Retry | MetadataErrorAction::Discard => { state .detach_metadata_pool_after_recovery( connection_id, database, client_session_id, reconnect_recovery.agent_session_id.as_deref(), false, ) .await; } MetadataErrorAction::Return => {} } return Err(error); } } MetadataErrorAction::Return => return result, } } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum MetadataErrorAction { Retry, Discard, ReplaceRuntime, #[default] Return, } #[derive(Debug, Default)] struct MetadataRecovery { action: MetadataErrorAction, agent_session_id: Option, } #[cfg(test)] fn metadata_error_action(db_type: Option, error: &str, retried: bool) -> MetadataErrorAction { metadata_recovery(db_type, error, retried).action } fn metadata_recovery(db_type: Option, error: &str, retried: bool) -> MetadataRecovery { if db_type.is_some_and(|db_type| crate::database_capabilities::is_agent_type(&db_type)) { if let Some(error) = crate::db::agent_driver::try_agent_error_from_legacy(error) { let agent_session_id = error.session_id().map(str::to_string); let action = match RecoveryPolicy::decide(&error, RecoveryScope::ReadOnlyMetadata { retried }) { RecoveryDecision::RetryReadOnlyMetadata => MetadataErrorAction::Retry, RecoveryDecision::QuarantineSession => MetadataErrorAction::Discard, RecoveryDecision::ReplaceRuntime => MetadataErrorAction::ReplaceRuntime, RecoveryDecision::KeepSession => MetadataErrorAction::Return, }; return MetadataRecovery { action, agent_session_id }; } } let action = if !retried && is_retryable_metadata_error(error) { MetadataErrorAction::Retry } else if should_discard_pool_after_error(db_type, error) { MetadataErrorAction::Discard } else { MetadataErrorAction::Return }; MetadataRecovery { action, agent_session_id: None } } #[cfg(test)] async fn replace_metadata_runtime( state: &AppState, connection_id: &str, database: Option<&str>, client_session_id: Option<&str>, ) { state.replace_runtime_for_metadata_pool(connection_id, database, client_session_id).await; } fn is_retryable_metadata_error(error: &str) -> bool { if let Some(error) = crate::db::agent_driver::try_agent_error_from_legacy(error) { return RecoveryPolicy::decide(&error, RecoveryScope::ReadOnlyMetadata { retried: false }) == RecoveryDecision::RetryReadOnlyMetadata; } crate::query::is_connection_error(error) } pub async fn get_columns_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { get_columns_core_for_session(state, connection_id, database, schema, table, None).await } pub async fn get_columns_core_for_session( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, client_session_id: Option<&str>, ) -> Result, String> { if client_session_id.is_none() { let metadata_session = EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "columns").await; if metadata_session.client_session_id().is_some() { let result = get_columns_core_for_session_inner( state, connection_id, database, schema, table, metadata_session.client_session_id(), false, ) .await; metadata_session.finish(state, connection_id, Some(database)).await; return result; } } get_columns_core_for_session_inner(state, connection_id, database, schema, table, client_session_id, true).await } async fn get_columns_core_for_session_inner( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, client_session_id: Option<&str>, use_client_session_context: bool, ) -> Result, String> { let context_session_id = if use_client_session_context { client_session_id } else { None }; retry_metadata_connection_for_session(state, connection_id, Some(database), client_session_id, || async { let pool_key = state .get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id) .await?; let db_config = connection_config(state, connection_id).await; { let pool_handle = state.pool_handle(&pool_key).await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { let config = config.clone(); let session = session.clone(); if uses_presto_like_information_schema_tables(&config.db_type) { return external_driver_presto_like_columns(session, config.as_ref(), database, schema, table).await; } let query_oracle_columns_first = should_query_oracle_columns_via_sql_first(&config.db_type, context_session_id); if query_oracle_columns_first { match external_driver_oracle_columns_via_sql( session.clone(), config.as_ref(), database, schema, table, ) .await { Ok(columns) if !columns.is_empty() => return Ok(columns), Ok(_) => {} Err(error) => { log::warn!( "[schema][external-driver:get_columns:oracle-primary-sql-failed] connection_id={} database={} schema={} table={} error={}", connection_id, database, schema, table, error ); } } } let columns = session .invoke_with_timeout::>( "getColumns", serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": schema, "table": table, }), agent_metadata_timeout(Some(config.as_ref())), ) .await?; if columns.is_empty() && config.db_type == DatabaseType::Oracle && !query_oracle_columns_first { match external_driver_oracle_columns_via_sql( session.clone(), config.as_ref(), database, schema, table, ) .await { Ok(fallback_columns) if !fallback_columns.is_empty() => return Ok(fallback_columns), Ok(_) => {} Err(error) => { log::warn!( "[schema][external-driver:get_columns:oracle-fallback-failed] connection_id={} database={} schema={} table={} error={}", connection_id, database, schema, table, error ); } } } return Ok(deduplicate_column_infos(columns)); } #[cfg(feature = "duckdb-sidecar")] if let Some(client) = extract_pool!(pool_handle.as_ref(), DuckDbWorker) { let database = database.to_string(); let schema = schema.to_string(); let table = table.to_string(); return client.list_columns(database, schema, table).await; } if let Some(client) = extract_pool!(pool_handle.as_ref(), ClickHouse) { return db::clickhouse_driver::get_columns(&client, clickhouse_metadata_database(database, schema), table) .await .map(deduplicate_column_infos); } if let Some(client) = extract_pool!(pool_handle.as_ref(), InfluxDb) { return db::influxdb_driver::get_columns(&client, database, table).await.map(deduplicate_column_infos); } if let Some(client) = extract_pool!(pool_handle.as_ref(), InfluxDb3) { return db::influxdb3_driver::get_columns(&client, database, table).await.map(deduplicate_column_infos); } if let Some(client) = extract_pool!(pool_handle.as_ref(), VictoriaMetrics) { return db::victoriametrics_driver::get_columns(&client, table).await.map(deduplicate_column_infos); } if let Some(linked) = crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema) { if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return db::sqlserver::get_linked_server_columns( &mut client, &linked.server, &linked.catalog, &linked.schema, table, ) .await .map(deduplicate_column_infos); } } try_sqlserver!(pool_handle, get_columns, schema, table); if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let fallback_config = db_config.clone(); let mut client = client.lock().await; let oracle_sql_config = fallback_config.as_ref().filter(|config| { should_query_oracle_columns_via_sql_first(&config.db_type, context_session_id) }); let query_oracle_columns_first = oracle_sql_config.is_some(); if let Some(config) = oracle_sql_config { match oracle_columns_via_sql( database, schema, table, &mut client, agent_metadata_timeout(Some(config)), ) .await { Ok(columns) if !columns.is_empty() => return Ok(columns), Ok(_) => {} Err(error) => { log::warn!( "[schema][agent:get_columns:oracle-primary-sql-failed] connection_id={} database={} schema={} table={} error={}", connection_id, database, schema, table, error ); } } } match client .get_columns::>( database, schema, table, agent_metadata_timeout(db_config.as_ref()), ) .await { Ok(columns) if !columns.is_empty() => return Ok(deduplicate_column_infos(columns)), Ok(columns) => { if let Some(config) = fallback_config.as_ref() { if config.db_type == DatabaseType::Oracle && !query_oracle_columns_first { match oracle_columns_via_sql( database, schema, table, &mut client, agent_metadata_timeout(Some(config)), ) .await { Ok(fallback_columns) if !fallback_columns.is_empty() => return Ok(fallback_columns), Ok(_) => {} Err(error) => { log::warn!( "[schema][agent:get_columns:oracle-fallback-failed] connection_id={} database={} schema={} table={} error={}", connection_id, database, schema, table, error ); } } } match native_postgres_metadata_pool(state, connection_id, database, config).await { Ok(Some(pool)) => { return db::postgres::get_columns(&pool, schema, table) .await .map(deduplicate_column_infos); } Ok(None) => return Ok(deduplicate_column_infos(columns)), Err(error) => { log::warn!( "[schema][agent:get_columns:fallback-failed] connection_id={} database={} schema={} table={} error={}", connection_id, database, schema, table, error ); } } } return Ok(deduplicate_column_infos(columns)); } Err(agent_error) => { if let Some(config) = fallback_config.as_ref() { if let Some(pool) = native_postgres_metadata_pool(state, connection_id, database, config).await? { return db::postgres::get_columns(&pool, schema, table) .await .map(deduplicate_column_infos) .map_err(|fallback_error| { crate::db::agent_driver::append_legacy_error_context( &agent_error, &format!("Native PostgreSQL metadata fallback failed: {fallback_error}"), ) }); } } return Err(agent_error); } } } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(db::manticoresearch::is_config) => { let metadata_database = mysql_show_metadata_database_for_config(db_config.as_ref(), database); db::manticoresearch::get_columns(p, metadata_database, table).await.map(deduplicate_column_infos) } PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(db::mysql_compatible::uses_show_metadata) => { // Resolve the metadata database exactly like the plain MySQL // branch below: a qualified `db.table` reference puts the // database in `schema` (MySQL-family two-part names), so an // empty or different tab/execution database must not send the // lookup to the wrong namespace (fixes #6590). let effective_db = mysql_table_metadata_catalog(database, schema); // Doris/StarRocks previously went straight to `SHOW COLUMNS` for // speed (see perf(doris) commit), but `SHOW COLUMNS` reports the // `Key` column as `YES`/`NO` rather than MySQL's `PRI`, so primary // keys were never detected. `get_columns` queries // information_schema.COLUMNS first — where `COLUMN_KEY = 'PRI'` // correctly identifies primary keys (and only real primary keys, // not duplicate-key sort columns) — and falls back to `SHOW COLUMNS` // automatically when information_schema is unavailable. db::mysql::get_columns(p, effective_db, table).await.map(deduplicate_column_infos) } PoolKind::Mysql(p, mode) => { let effective_db = mysql_table_metadata_catalog(database, schema); dispatch_mysql!(p, mode, db::mysql::get_columns, db::ob_oracle::get_columns, effective_db, table) .map(deduplicate_column_infos) } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { db::questdb::get_columns(p, schema, table).await.map(deduplicate_column_infos) } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Redshift) => { db::postgres::get_redshift_columns(p, schema, table).await.map(deduplicate_column_infos) } PoolKind::Postgres(p) => db::postgres::get_columns(p, schema, table).await.map(deduplicate_column_infos), PoolKind::Sqlite(p) => db::sqlite::get_columns(p, schema, table).await.map(deduplicate_column_infos), PoolKind::Rqlite(client) => { db::rqlite_driver::get_columns(client, schema, table).await.map(deduplicate_column_infos) } PoolKind::Turso(client) => { db::turso_driver::get_columns(client, schema, table).await.map(deduplicate_column_infos) } PoolKind::CloudflareD1(client) => db::cloudflare_d1_driver::get_columns(client, schema, table) .await .map(deduplicate_column_infos), PoolKind::Elasticsearch(client) => { db::elasticsearch_driver::get_columns(client, table).await.map(deduplicate_column_infos) } PoolKind::Easysearch(client) => { db::easysearch_driver::get_columns(client, table).await.map(deduplicate_column_infos) } PoolKind::Meilisearch(client) => { db::meilisearch_driver::get_columns(client, table).await.map(deduplicate_column_infos) } PoolKind::HBase(client) => { db::hbase_driver::get_columns(client, database, table).await.map(deduplicate_column_infos) } _ => Ok(vec![]), } }) .await } pub async fn get_sqlserver_column_metadata_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let pool_handle = state.pool_handle(&pool_key).await; try_sqlserver!(pool_handle, get_column_metadata, schema, table); Err("SQL Server column metadata requires a native SQL Server connection".to_string()) }) .await } fn deduplicate_column_infos(columns: Vec) -> Vec { let mut result: Vec = Vec::with_capacity(columns.len()); for column in columns { if let Some(existing) = result.iter_mut().find(|existing| existing.name == column.name) { existing.is_primary_key |= column.is_primary_key; existing.is_unique |= column.is_unique; existing.is_nullable &= column.is_nullable; merge_optional_string(&mut existing.column_default, column.column_default); merge_optional_string(&mut existing.extra, column.extra); merge_optional_string(&mut existing.comment, column.comment); if existing.numeric_precision.is_none() { existing.numeric_precision = column.numeric_precision; } if existing.numeric_scale.is_none() { existing.numeric_scale = column.numeric_scale; } if existing.character_maximum_length.is_none() { existing.character_maximum_length = column.character_maximum_length; } if existing.data_type.trim().is_empty() && !column.data_type.trim().is_empty() { existing.data_type = column.data_type; } } else { result.push(column); } } result } pub async fn get_all_columns_core( state: &AppState, connection_id: &str, database: &str, schema: &str, ) -> Result, String> { let tables = list_tables_core(state, connection_id, database, schema, None, None, None, None, None).await?; let mut result: Vec = Vec::with_capacity(tables.len()); for table in tables { match get_columns_core(state, connection_id, database, schema, &table.name).await { Ok(columns) => { result.push(db::TableColumnsResult { table_name: table.name, columns, error: None }); } Err(e) => { log::warn!( "[schema][get_all_columns] connection_id={} database={} schema={} table={} error={}", connection_id, database, schema, table.name, e ); result.push(db::TableColumnsResult { table_name: table.name, columns: Vec::new(), error: Some(e) }); } } } Ok(result) } fn merge_optional_string(target: &mut Option, candidate: Option) { let Some(candidate) = candidate else { return; }; if candidate.trim().is_empty() { if target.is_none() { *target = Some(candidate); } return; } if target.as_ref().is_none_or(|value| value.trim().is_empty()) { *target = Some(candidate); } } pub async fn list_indexes_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() { return Ok(vec![]); } let metadata_session = EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "indexes").await; let result = list_indexes_core_for_session( state, connection_id, database, schema, table, metadata_session.client_session_id(), ) .await; metadata_session.finish(state, connection_id, Some(database)).await; result } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReferenceKeyInfo { pub columns: Vec, } pub fn reference_keys_from_indexes(indexes: &[db::IndexInfo]) -> Vec { let mut keys = Vec::new(); for index in indexes { if !index.is_unique || index.filter.as_deref().is_some_and(|filter| !filter.trim().is_empty()) || index.columns.is_empty() || index.key_is_expression.iter().any(|is_expression| *is_expression) { continue; } let columns = index.columns.iter().map(|column| column.trim().to_string()).collect::>(); let mut seen = HashSet::new(); if columns.iter().any(|column| column.is_empty() || !seen.insert(column.as_str())) { continue; } let key = ReferenceKeyInfo { columns }; if !keys.contains(&key) { keys.push(key); } } keys } pub fn reference_key_columns_from_indexes(indexes: &[db::IndexInfo]) -> Vec { reference_keys_from_indexes(indexes) .into_iter() .filter_map(|key| (key.columns.len() == 1).then(|| key.columns[0].clone())) .collect() } pub async fn list_reference_keys_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { let indexes = list_indexes_core(state, connection_id, database, schema, table).await?; Ok(reference_keys_from_indexes(&indexes)) } pub async fn list_reference_key_columns_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { let indexes = list_indexes_core(state, connection_id, database, schema, table).await?; Ok(reference_key_columns_from_indexes(&indexes)) } async fn list_indexes_core_for_session( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, client_session_id: Option<&str>, ) -> Result, String> { retry_metadata_connection_for_session(state, connection_id, Some(database), client_session_id, || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await?; let db_config = connection_config(state, connection_id).await; { let pool_handle = state.pool_handle(&pool_key).await; try_sqlserver!(pool_handle, list_indexes, schema, table); if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { if external_driver_uses_mysql_ddl(config.as_ref()) { let config = config.clone(); let session = session.clone(); return external_driver_gaussdb_m_indexes(session, config.as_ref(), database, schema, table).await; } let config = config.clone(); let session = session.clone(); return session .invoke_with_timeout::>( "listIndexes", serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": schema, "table": table, }), agent_metadata_timeout(Some(config.as_ref())), ) .await; } if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let mut client = client.lock().await; return client.list_indexes(database, schema, table, agent_metadata_timeout(db_config.as_ref())).await; } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, mode) => { if db_config.as_ref().is_some_and(db::manticoresearch::is_config) { return db::manticoresearch::list_indexes(p, table).await; } if *mode == MysqlMode::OceanBaseOracle { db::ob_oracle::list_indexes(p, schema, table).await } else if db_config.as_ref().is_some_and(db::starrocks::is_config) { db::starrocks::list_indexes(p, mysql_table_metadata_catalog(database, schema), table).await } else if db_config.as_ref().is_some_and(db::doris::is_config) { db::doris::list_indexes(p, mysql_table_metadata_catalog(database, schema), table).await } else { db::mysql::list_indexes(p, mysql_table_metadata_catalog(database, schema), table).await } } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { db::questdb::list_indexes(p, schema, table).await } PoolKind::Postgres(_) if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Redshift) => { Ok(vec![]) } PoolKind::Postgres(p) => db::postgres::list_indexes(p, schema, table).await, PoolKind::Sqlite(p) => db::sqlite::list_indexes(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::list_indexes(client, schema, table).await, PoolKind::Turso(client) => db::turso_driver::list_indexes(client, schema, table).await, PoolKind::MongoDb(client) => db::mongo_driver::list_indexes(client, database, table).await, PoolKind::CloudflareD1(client) => db::cloudflare_d1_driver::list_indexes(client, schema, table).await, _ => Ok(vec![]), } }) .await } pub async fn list_foreign_keys_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() { return Ok(vec![]); } let metadata_session = EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "foreign-keys").await; let result = list_foreign_keys_core_for_session( state, connection_id, database, schema, table, metadata_session.client_session_id(), ) .await; metadata_session.finish(state, connection_id, Some(database)).await; result } async fn list_foreign_keys_core_for_session( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, client_session_id: Option<&str>, ) -> Result, String> { retry_metadata_connection_for_session(state, connection_id, Some(database), client_session_id, || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await?; let db_config = connection_config(state, connection_id).await; { let pool_handle = state.pool_handle(&pool_key).await; try_sqlserver!(pool_handle, list_foreign_keys, schema, table); if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let mut client = client.lock().await; return client .list_foreign_keys(database, schema, table, agent_metadata_timeout(db_config.as_ref())) .await; } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, mode) => { if *mode == MysqlMode::OceanBaseOracle { db::ob_oracle::list_foreign_keys(p, schema, table).await } else { db::mysql::list_foreign_keys(p, mysql_table_metadata_catalog(database, schema), table).await } } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_opengauss_constraint_config) => { db::postgres::list_opengauss_foreign_keys(p, schema, table).await } PoolKind::Postgres(p) => db::postgres::list_foreign_keys(p, schema, table).await, PoolKind::Sqlite(p) => db::sqlite::list_foreign_keys(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::list_foreign_keys(client, schema, table).await, PoolKind::Turso(client) => db::turso_driver::list_foreign_keys(client, schema, table).await, PoolKind::CloudflareD1(client) => db::cloudflare_d1_driver::list_foreign_keys(client, schema, table).await, _ => Ok(vec![]), } }) .await } pub async fn list_triggers_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() { return Ok(vec![]); } retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; { let pool_handle = state.pool_handle(&pool_key).await; try_sqlserver!(pool_handle, list_triggers, schema, table); if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let mut client = client.lock().await; return client.list_triggers(database, schema, table, agent_metadata_timeout(db_config.as_ref())).await; } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, mode) => { if *mode == MysqlMode::OceanBaseOracle { db::ob_oracle::list_triggers(p, schema, table).await } else { db::mysql::list_triggers(p, mysql_table_metadata_catalog(database, schema), table).await } } PoolKind::Postgres(p) => db::postgres::list_triggers(p, schema, table).await, PoolKind::Sqlite(p) => db::sqlite::list_triggers(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::list_triggers(client, schema, table).await, PoolKind::Turso(client) => db::turso_driver::list_triggers(client, schema, table).await, PoolKind::CloudflareD1(client) => db::cloudflare_d1_driver::list_triggers(client, schema, table).await, _ => Ok(vec![]), } }) .await } /// Lists structured constraints for a relation. Exposed generically so the /// agent protocol and native drivers share one route; the built-in drivers /// that implement it today are PostgreSQL, OpenGauss, and the Xugu agent. pub async fn list_constraints_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; { let pool_handle = state.pool_handle(&pool_key).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let mut client = client.lock().await; return client .list_constraints(database, schema, table, agent_metadata_timeout(db_config.as_ref())) .await; } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { // QuestDB speaks the PostgreSQL wire protocol but has no // constraint metadata, and Redshift does not enforce or expose // constraint definitions, so neither reports any. PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => Ok(vec![]), PoolKind::Postgres(_) if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Redshift) => { Ok(vec![]) } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_opengauss_constraint_config) => { db::postgres::list_opengauss_constraints(p, schema, table).await } PoolKind::Postgres(p) => db::postgres::list_constraints(p, schema, table).await, _ => Ok(vec![]), } }) .await } pub async fn list_partitions_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; let pool_handle = state.pool_handle(&pool_key).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let mut client = client.lock().await; return client.list_partitions(database, schema, table, agent_metadata_timeout(db_config.as_ref())).await; } Ok(vec![]) }) .await } /// PostgreSQL partition classification of a single table, used by the table /// structure editor to decide whether `CREATE INDEX CONCURRENTLY` applies. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TablePartitionStatus { /// The table is a partitioned parent (`pg_class.relkind = 'p'`); PostgreSQL /// rejects `CREATE INDEX CONCURRENTLY` directly on it — the supported /// approach is building child indexes concurrently and attaching them. pub is_partitioned_parent: bool, /// The table is itself a partition of a parent (`pg_class.relispartition`). pub is_partition: bool, } pub async fn table_partition_status_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let pool_handle = state.pool_handle(&pool_key).await; match pool_handle.as_ref() { Some(PoolKind::Postgres(pool)) => { let info = db::postgres::get_table_partition_info(pool, schema, table).await?; Ok(TablePartitionStatus { is_partitioned_parent: info.key.is_some() && !info.is_partition, is_partition: info.is_partition, }) } _ => Ok(TablePartitionStatus::default()), } }) .await } /// Same-table index names whose `pg_index.indisvalid` is `false` (left behind /// by a cancelled `CREATE INDEX CONCURRENTLY`). Empty for non-PostgreSQL pools. pub async fn list_invalid_indexes_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let pool_handle = state.pool_handle(&pool_key).await; match pool_handle.as_ref() { Some(PoolKind::Postgres(pool)) => db::postgres::list_invalid_indexes(pool, schema, table).await, _ => Ok(vec![]), } }) .await } pub async fn list_subpartitions_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; let pool_handle = state.pool_handle(&pool_key).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let mut client = client.lock().await; return client .list_subpartitions(database, schema, table, agent_metadata_timeout(db_config.as_ref())) .await; } Ok(vec![]) }) .await } pub async fn list_functions_core( state: &AppState, connection_id: &str, database: &str, schema: &str, ) -> Result, String> { let postgres_functions = retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Postgres(p) => Ok(Some(db::postgres::list_functions(p, schema).await?)), _ => Ok(None), } }) .await?; if let Some(functions) = postgres_functions { return Ok(functions); } // Non-Postgres: reuse sidebar list_objects + get_object_source paths. list_functions_via_objects(state, connection_id, database, schema).await } /// Build FunctionInfo for non-Postgres pools by reusing list_objects + get_object_source /// (same paths the sidebar uses for PROCEDURE/FUNCTION). async fn list_functions_via_objects( state: &AppState, connection_id: &str, database: &str, schema: &str, ) -> Result, String> { let object_types = ["PROCEDURE".to_string(), "FUNCTION".to_string()]; let objects = list_objects_core(state, connection_id, database, schema, None, None, None, Some(&object_types), None).await?; // Bound concurrent get_object_source calls (N+1) without requiring AppState: Clone. const CONCURRENCY: usize = 8; let mut functions = Vec::with_capacity(objects.len()); for chunk in objects.chunks(CONCURRENCY) { let chunk_results = futures::future::join_all( chunk .iter() .map(|object| load_function_info_via_object(state, connection_id, database, schema, object.clone())), ) .await; functions.extend(chunk_results.into_iter().flatten()); } Ok(functions) } fn schema_diff_routine_kind(object_type: &str) -> Option<(&'static str, db::ObjectSourceKind)> { let object_type_upper = object_type.to_ascii_uppercase(); if object_type_upper.contains("PROC") { Some(("PROCEDURE", db::ObjectSourceKind::Procedure)) } else if object_type_upper.contains("FUNC") { Some(("FUNCTION", db::ObjectSourceKind::Function)) } else { None } } async fn load_function_info_via_object( state: &AppState, connection_id: &str, database: &str, schema: &str, object: db::ObjectInfo, ) -> Option { let (function_type, source_kind) = schema_diff_routine_kind(&object.object_type)?; let definition = match get_object_source_core( state, connection_id, database, schema, &object.name, source_kind.clone(), object.signature.as_deref(), None, ) .await { Ok(source) if !source.source.trim().is_empty() => source.source, Ok(_) | Err(_) => { // Retry the alternate routine kind when the primary getter is empty/fails. let alternate = match source_kind { db::ObjectSourceKind::Procedure => db::ObjectSourceKind::Function, db::ObjectSourceKind::Function => db::ObjectSourceKind::Procedure, other => other, }; match get_object_source_core( state, connection_id, database, schema, &object.name, alternate, object.signature.as_deref(), None, ) .await { Ok(source) if !source.source.trim().is_empty() => source.source, // Skip objects with no readable source so empty definitions are not treated as loaded. _ => return None, } } }; Some(db::FunctionInfo { name: object.name, function_type: function_type.to_string(), data_type: String::new(), definition: strip_routine_definer_clause(&definition), arguments: object.signature.unwrap_or_default(), }) } /// MySQL's SHOW CREATE PROCEDURE/FUNCTION prefixes `CREATE DEFINER=`user`@`host``. /// The definer account typically differs across same-structure databases on /// different servers while the routine body is identical, so drop the clause /// before schema-diff comparison (same spirit as DBeaver's removeDefiner /// option). Definitions without the clause pass through unchanged; the /// `^CREATE DEFINER` anchor keeps definer mentions inside a routine body alone. fn strip_routine_definer_clause(definition: &str) -> String { static DEFINER_PREFIX: OnceLock = OnceLock::new(); let definer_prefix = DEFINER_PREFIX.get_or_init(|| { Regex::new(r#"(?is)^\s*CREATE\s+DEFINER\s*=\s*(`(?:[^`]|``)*`|"(?:[^"]|"")*"|[A-Za-z0-9_$]+)@(`(?:[^`]|``)*`|"(?:[^"]|"")*"|[A-Za-z0-9_$.%*-]+)"#).unwrap() }); match definer_prefix.find(definition) { Some(found) => format!("CREATE {}", definition[found.end()..].trim_start()), None => definition.to_string(), } } #[cfg(test)] mod schema_diff_routine_kind_tests { use super::schema_diff_routine_kind; use crate::db::ObjectSourceKind; #[test] fn classifies_procedure_and_function_object_types() { assert_eq!(schema_diff_routine_kind("PROCEDURE"), Some(("PROCEDURE", ObjectSourceKind::Procedure))); assert_eq!(schema_diff_routine_kind("StoredProc"), Some(("PROCEDURE", ObjectSourceKind::Procedure))); assert_eq!(schema_diff_routine_kind("FUNCTION"), Some(("FUNCTION", ObjectSourceKind::Function))); assert_eq!(schema_diff_routine_kind("user_function"), Some(("FUNCTION", ObjectSourceKind::Function))); assert!(schema_diff_routine_kind("TABLE").is_none()); assert!(schema_diff_routine_kind("VIEW").is_none()); } } #[cfg(test)] mod strip_routine_definer_clause_tests { use super::strip_routine_definer_clause; #[test] fn strips_backquoted_definer_prefix() { assert_eq!( strip_routine_definer_clause("CREATE DEFINER=`root`@`localhost` PROCEDURE `p`() BEGIN SELECT 1; END"), "CREATE PROCEDURE `p`() BEGIN SELECT 1; END" ); } #[test] fn strips_bare_definer_prefix() { assert_eq!( strip_routine_definer_clause("CREATE DEFINER=app_user@10.0.0.% FUNCTION `f`() RETURNS int RETURN 1"), "CREATE FUNCTION `f`() RETURNS int RETURN 1" ); } #[test] fn keeps_definitions_without_definer() { let def = "CREATE PROCEDURE `p`() BEGIN SELECT 1; END"; assert_eq!(strip_routine_definer_clause(def), def); } #[test] fn keeps_definer_mentions_inside_the_body() { let def = "CREATE PROCEDURE `p`() BEGIN -- CREATE DEFINER=`x`@`y` stays\nSELECT 1; END"; assert_eq!(strip_routine_definer_clause(def), def); } #[test] fn strips_definer_after_leading_whitespace() { assert_eq!( strip_routine_definer_clause(" CREATE DEFINER=`root`@`%` PROCEDURE `p`() BEGIN END"), "CREATE PROCEDURE `p`() BEGIN END" ); } } pub async fn list_sequences_core( state: &AppState, connection_id: &str, database: &str, schema: &str, with_last_values: bool, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_opengauss_family_config) => { db::postgres::list_opengauss_sequences(p, schema, with_last_values).await } PoolKind::Postgres(p) => db::postgres::list_sequences(p, schema, with_last_values).await, _ => Ok(vec![]), } }) .await } pub async fn list_rules_core( state: &AppState, connection_id: &str, database: &str, schema: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Postgres(p) => db::postgres::list_rules(p, schema).await, _ => Ok(vec![]), } }) .await } pub async fn list_extensions_core( state: &AppState, connection_id: &str, database: &str, schema: Option<&str>, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) { let pool_handle = state.pool_handle(&pool_key).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { return kingbase::list_extensions(client, database, schema, agent_metadata_timeout(db_config.as_ref())) .await; } } // HighGo and Vastbase use Agent pools but expose PostgreSQL's extension // catalogs. Reuse the native metadata fallback so their installed // extensions are not silently reported as an empty list. if let Some(config) = agent_postgres_extension_fallback_config(db_config.as_ref()) { if let Some(pool) = native_postgres_metadata_pool(state, connection_id, database, config).await? { return db::postgres::list_extensions(&pool, schema).await; } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Postgres(p) => db::postgres::list_extensions(p, schema).await, _ => Ok(vec![]), } }) .await } pub async fn list_available_extensions_core( state: &AppState, connection_id: &str, database: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) { let pool_handle = state.pool_handle(&pool_key).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { return kingbase::list_available_extensions( client, database, agent_metadata_timeout(db_config.as_ref()), ) .await; } } if let Some(config) = agent_postgres_extension_fallback_config(db_config.as_ref()) { if let Some(pool) = native_postgres_metadata_pool(state, connection_id, database, config).await? { return db::postgres::list_available_extensions(&pool).await; } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Postgres(p) => db::postgres::list_available_extensions(p).await, _ => Ok(vec![]), } }) .await } pub async fn list_owners_core( state: &AppState, connection_id: &str, database: &str, schema: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Postgres(p) => db::postgres::list_owners(p, schema).await, _ => Ok(vec![]), } }) .await } pub async fn get_table_owner_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Postgres(p) => db::postgres::get_table_owner(p, schema, table).await, _ => Ok(None), } }) .await } /// Whether to widen or normalize a single-table DDL fetch for its caller. /// /// Database export and table transfer render one relation at a time because /// they already iterate every relation themselves. A selected table structure /// export and interactive display both recurse through the PostgreSQL /// partition tree, while only display includes access statements. Oracle /// exports additionally request portable DDL normalization. #[derive(Clone, Copy)] struct TableDdlOptions { include_postgres_access: bool, include_partitions: bool, portable_oracle: bool, } impl TableDdlOptions { const SINGLE_RELATION: Self = Self { include_postgres_access: false, include_partitions: false, portable_oracle: false }; const RELATION_EXPORT: Self = Self { include_postgres_access: false, include_partitions: false, portable_oracle: true }; const EXPORT: Self = Self { include_postgres_access: false, include_partitions: true, portable_oracle: true }; const DISPLAY: Self = Self { include_postgres_access: true, include_partitions: true, portable_oracle: false }; } pub async fn get_table_ddl_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, object_type: Option, ) -> Result { get_table_ddl_core_with_options( state, connection_id, database, schema, table, object_type, TableDdlOptions::SINGLE_RELATION, None, ) .await } pub async fn get_table_export_ddl_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, object_type: Option, ) -> Result { get_table_ddl_core_with_options( state, connection_id, database, schema, table, object_type, TableDdlOptions::EXPORT, None, ) .await } pub(crate) async fn get_table_relation_export_ddl_core_for_session( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, object_type: Option, client_session_id: Option<&str>, ) -> Result { get_table_ddl_core_with_options( state, connection_id, database, schema, table, object_type, TableDdlOptions::RELATION_EXPORT, client_session_id, ) .await } pub async fn get_table_display_ddl_core( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, object_type: Option, ) -> Result { get_table_ddl_core_with_options( state, connection_id, database, schema, table, object_type, TableDdlOptions::DISPLAY, None, ) .await } async fn get_table_ddl_core_with_options( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, object_type: Option, options: TableDdlOptions, client_session_id: Option<&str>, ) -> Result { if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() { return Err("DDL is not supported for SQL Server linked server tables".to_string()); } if matches!(object_type, Some(db::ObjectSourceKind::View)) { let source = get_object_source_core( state, connection_id, database, schema, table, db::ObjectSourceKind::View, None, None, ) .await?; let db_config = connection_config(state, connection_id).await; let append_oracle_comments = should_append_oracle_style_comment_ddl(db_config.as_ref()); let schema = if append_oracle_comments { source.schema.as_deref().filter(|schema| !schema.trim().is_empty()).unwrap_or(schema) } else { schema }; let database_type = db_config.as_ref().map(|config| { if is_oracle_external_driver_config(config) { DatabaseType::Oracle } else { config.db_type } }); // Kingbase MySQL compatibility mode reports a backtick identifier // quote; thread it through so the view DDL wraps hyphenated schema // names in backticks instead of double quotes the server rejects. let identifier_quote = state.connection_identifier_quote(connection_id, Some(database)).await.ok().flatten(); let ddl = crate::object_source_sql::build_view_ddl_sql(crate::object_source_sql::BuildViewDdlInput { database_type, schema: if schema.trim().is_empty() { None } else { Some(schema.to_string()) }, name: table.to_string(), source: source.source, identifier_quote, }); // Oracle-family comments live in dictionary tables, not inside CREATE VIEW. // Append COMMENT ON so table-properties DDL / hover match the structure editor. if append_oracle_comments { return Ok(enrich_ddl_with_oracle_style_comments(state, connection_id, database, schema, table, &ddl).await); } return Ok(ddl); } if matches!(object_type, Some(db::ObjectSourceKind::MaterializedView)) { let source = get_object_source_core( state, connection_id, database, schema, table, db::ObjectSourceKind::MaterializedView, None, None, ) .await?; return Ok(source.source); } retry_metadata_connection_for_session(state, connection_id, Some(database), client_session_id, || { get_table_ddl_once(state, connection_id, database, schema, table, options, client_session_id) }) .await } /// `pg_ddl_with_partitions` when the caller wants the whole partition tree, /// otherwise plain single-relation `pg_ddl`. async fn pg_ddl_for_options( pool: &deadpool_postgres::Pool, schema: &str, table: &str, include_partitions: bool, ) -> Result { if include_partitions { pg_ddl_with_partitions(pool, schema, table).await } else { pg_ddl(pool, schema, table).await } } async fn get_table_ddl_once( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, options: TableDdlOptions, client_session_id: Option<&str>, ) -> Result { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await?; let db_config = connection_config(state, connection_id).await; { let pool_handle = state.pool_handle(&pool_key).await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { if is_oracle_external_driver_config(config.as_ref()) { let config = config.clone(); let session = session.clone(); return external_driver_oracle_ddl(session, config.as_ref(), database, schema, table).await; } if external_driver_uses_mysql_ddl(config.as_ref()) || (config.db_type == DatabaseType::Jdbc && mysql_external_driver_url(config.as_ref()) == Some(true)) { let config = config.clone(); let session = session.clone(); let result = external_driver_mysql_ddl(session.clone(), config.as_ref(), database, schema, table).await; if result.is_err() && config.db_type == DatabaseType::Jdbc { return external_driver_jdbc_ddl(session, config.as_ref(), database, schema, table).await; } return result; } if external_driver_uses_generic_ddl(config.as_ref()) { let config = config.clone(); let session = session.clone(); return external_driver_jdbc_ddl(session, config.as_ref(), database, schema, table).await; } } #[cfg(feature = "duckdb-sidecar")] if let Some(client) = extract_pool!(pool_handle.as_ref(), DuckDbWorker) { let client = client.clone(); return client.get_table_ddl(database.to_string(), schema.to_string(), table.to_string()).await; } if let Some(client) = extract_pool!(pool_handle.as_ref(), ClickHouse) { let clickhouse_database = clickhouse_metadata_database(database, schema); let result = db::clickhouse_driver::execute_query( &client, clickhouse_database, &format!("SHOW CREATE TABLE `{table}`"), ) .await?; return result .rows .first() .and_then(|r| r.first()) .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| "Table not found".to_string()); } if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; return build_sqlserver_ddl(&mut client, schema, table).await; } if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { if let Some(config) = db_config.as_ref().filter(|config| is_agent_postgres_metadata_fallback_config(config)) { match native_postgres_metadata_pool(state, connection_id, database, config).await { Ok(Some(pool)) => { match pg_ddl_for_options(&pool, schema, table, options.include_partitions).await { Ok(ddl) => return Ok(ddl), Err(error) => { log::warn!( "[schema][agent:get_table_ddl:postgres-compatible-native-fallback-failed] connection_id={} database={} schema={} table={} error={}", connection_id, database, schema, table, error ); } } } Ok(None) => {} Err(error) => { log::warn!( "[schema][agent:get_table_ddl:postgres-compatible-native-pool-failed] connection_id={} database={} schema={} table={} error={}", connection_id, database, schema, table, error ); } } } if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Oracle) { return oracle_agent_table_ddl( client, database, schema, table, options.portable_oracle, agent_metadata_timeout(db_config.as_ref()), ) .await; } if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Db2) { return db2_agent_table_ddl( client, database, schema, table, agent_metadata_timeout(db_config.as_ref()), ) .await; } let mut client = client.lock().await; return client.get_table_ddl(database, schema, table, agent_metadata_timeout(db_config.as_ref())).await; } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Mysql(p, _) => mysql_ddl(p, mysql_table_metadata_catalog(database, schema), table).await, PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_opengauss_family_config) => { match opengauss_table_ddl(p, schema, table).await { Ok(ddl) => Ok(ddl), Err(_) => pg_ddl_for_options(p, schema, table, options.include_partitions).await, } } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { match db::questdb::questdb_table_or_view_ddl(p, table).await { Ok(ddl) => Ok(ddl), Err(_) => pg_ddl_for_options(p, schema, table, options.include_partitions).await, } } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => { cloudberry_ddl(p, schema, table, options.include_partitions).await } PoolKind::Postgres(p) if db_config.as_ref().is_some_and(db::opentenbase::is_config) => { opentenbase_ddl(p, schema, table, options.include_partitions).await } PoolKind::Postgres(p) if options.include_postgres_access && db_config.as_ref().is_some_and(is_native_postgres_config) => { pg_display_ddl(p, schema, table).await } PoolKind::Postgres(p) => pg_ddl_for_options(p, schema, table, options.include_partitions).await, PoolKind::Sqlite(p) => sqlite_ddl(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await, PoolKind::Turso(client) => db::turso_driver::table_ddl(client, table).await, PoolKind::CloudflareD1(client) => db::cloudflare_d1_driver::table_ddl(client, table).await, _ => Err("DDL not supported for this database type".to_string()), } } async fn connection_config(state: &AppState, connection_id: &str) -> Option { state.configs.read().await.get(connection_id).cloned() } fn is_opengauss_constraint_config(config: &ConnectionConfig) -> bool { config.db_type == DatabaseType::OpenGauss || config.driver_profile.as_deref() == Some("opengauss") } fn is_opengauss_family_config(config: &ConnectionConfig) -> bool { matches!(config.db_type, DatabaseType::OpenGauss | DatabaseType::Gaussdb) || matches!(config.driver_profile.as_deref(), Some("opengauss" | "gaussdb")) } fn is_native_postgres_config(config: &ConnectionConfig) -> bool { config.db_type == DatabaseType::Postgres && matches!(config.driver_profile.as_deref(), None | Some("postgres")) } fn is_cloudberry_config(config: &ConnectionConfig) -> bool { matches!(config.driver_profile.as_deref(), Some("cloudberry")) } /// Whether a native PostgreSQL connection should list user-defined types. /// /// Only databases with a verified `pg_type` catalog contract are enabled. /// Other PG-protocol connections (Redshift, QuestDB, Cloudberry, KWDB, ...) /// keep the legacy object list even though they share `PoolKind::Postgres`. fn supports_pg_custom_type_objects(config: &ConnectionConfig) -> bool { matches!(config.db_type, DatabaseType::Postgres | DatabaseType::OpenGauss | DatabaseType::Gaussdb) } /// Whether a typed object-list request needs the pg_class relation branch. /// /// `None` means the caller wants the full object list (object browser “all /// objects” view), so every branch is selected. Group loads only request their /// own kinds (e.g. `["TABLE"]`), which skips the other catalog scans entirely. fn object_types_include_relations(object_types: Option<&[String]>) -> bool { object_types.is_none_or(|types| { types.iter().any(|t| { matches!( t.to_ascii_uppercase().as_str(), "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "SEQUENCE" | "FOREIGN_TABLE" | "PARTITIONED_TABLE" ) }) }) } fn object_types_include_routines(object_types: Option<&[String]>) -> bool { object_types .is_none_or(|types| types.iter().any(|t| matches!(t.to_ascii_uppercase().as_str(), "PROCEDURE" | "FUNCTION"))) } fn object_types_include_custom_types(object_types: Option<&[String]>) -> bool { object_types .is_none_or(|types| types.iter().any(|t| t.eq_ignore_ascii_case("TYPE") || t.eq_ignore_ascii_case("TYPE_BODY"))) } fn object_types_include_packages(object_types: Option<&[String]>) -> (bool, bool) { match object_types { None => (true, true), Some(types) => ( types.iter().any(|value| value.eq_ignore_ascii_case("PACKAGE")), types.iter().any(|value| value.eq_ignore_ascii_case("PACKAGE_BODY")), ), } } /// Whether the object-type filter exclusively asks for user-defined types. /// /// Used to keep agent errors visible: the native PostgreSQL fallback never /// lists custom types, so running it for a dedicated type request would mask a /// real catalog failure as an empty type group. fn object_types_only_custom_types(object_types: Option<&[String]>) -> bool { object_types.is_some_and(|types| { !types.is_empty() && types.iter().all(|t| t.eq_ignore_ascii_case("TYPE") || t.eq_ignore_ascii_case("TYPE_BODY")) }) } fn is_default_oracle_agent_config(config: &ConnectionConfig) -> bool { // Only the default go-oracle agent handles filtered/paged metadata; legacy profiles keep Rust fallback paging. matches!(config.db_type, DatabaseType::Oracle) && !matches!(config.driver_profile.as_deref(), Some("oracle-legacy" | "oracle-10g")) } fn uses_oracle_metadata_object_source(config: Option<&ConnectionConfig>, object_type: &db::ObjectSourceKind) -> bool { config.is_some_and(|config| config.db_type == DatabaseType::Oracle) && matches!( object_type, db::ObjectSourceKind::Sequence | db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody ) } fn supports_agent_table_paging(config: &ConnectionConfig) -> bool { // Keep paging opt-in until each legacy agent is known to apply metadata constraints server-side. matches!(config.db_type, DatabaseType::Tdengine) || is_default_oracle_agent_config(config) } fn agent_paging_likely_applied(enabled: bool, limit: Option, returned_len: usize) -> bool { enabled && limit.is_some_and(|limit| returned_len <= limit) } fn external_driver_paging_likely_applied(driver_id: &str, limit: Option, returned_len: usize) -> bool { driver_id == "jdbc" && limit.is_some_and(|limit| returned_len <= limit) } fn mysql_show_metadata_database_for_config<'a>(config: Option<&ConnectionConfig>, database: &'a str) -> &'a str { if config.is_some_and(db::manticoresearch::is_config) { "" } else { database } } fn filter_mysql_system_databases_for_config( databases: Vec, config: Option<&ConnectionConfig>, ) -> Vec { if !config.is_some_and(db::manticoresearch::is_config) { return databases; } databases.into_iter().filter(|database| !is_mysql_system_database(&database.name)).collect() } fn is_mysql_system_database(name: &str) -> bool { matches!(name.to_ascii_lowercase().as_str(), "information_schema" | "mysql" | "performance_schema" | "sys") } fn is_questdb_config(config: &ConnectionConfig) -> bool { matches!(config.db_type, DatabaseType::Questdb) || matches!(config.driver_profile.as_deref(), Some("questdb")) } fn sql_string(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } fn pg_ident(value: &str) -> String { format!("\"{}\"", value.replace('"', "\"\"")) } /// Whether a `pg_get_constraintdef` result ends in the ` NOT VALID` suffix /// Postgres appends for an unvalidated constraint. That suffix is only legal /// after `ALTER TABLE ADD CONSTRAINT`, never inside a `CREATE TABLE` column /// list. fn is_not_valid_constraintdef(definition: &str) -> bool { definition.to_ascii_uppercase().trim_end().ends_with("NOT VALID") } fn sqlserver_ident(value: &str) -> String { format!("[{}]", value.replace(']', "]]")) } fn sqlserver_n_string(value: &str) -> String { format!("N'{}'", value.replace('\'', "''")) } fn oracle_ident(value: &str) -> String { format!("\"{}\"", value.replace('"', "\"\"")) } fn mysql_ident(value: &str) -> String { format!("`{}`", value.replace('`', "``")) } fn mysql_qualified_name(database: &str, name: &str) -> String { if database.trim().is_empty() { mysql_ident(name) } else { format!("{}.{}", mysql_ident(database), mysql_ident(name)) } } fn is_mysql_external_driver_config(config: &ConnectionConfig) -> bool { if config.db_type != DatabaseType::Jdbc { return false; } let driver_class = config.jdbc_driver_class.as_deref().map(str::trim).filter(|value| !value.is_empty()); let mysql_url = mysql_external_driver_url(config); let mysql_driver = driver_class.map(|value| matches!(value, "com.mysql.cj.jdbc.Driver" | "com.mysql.jdbc.Driver")); match (mysql_url, mysql_driver) { (Some(url_matches), Some(driver_matches)) => url_matches && driver_matches, (Some(url_matches), None) => url_matches, (None, Some(driver_matches)) => driver_matches, (None, None) => false, } } fn mysql_external_driver_url(config: &ConnectionConfig) -> Option { config .connection_string .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.to_ascii_lowercase().starts_with("jdbc:mysql:")) } fn is_oracle_external_driver_config(config: &ConnectionConfig) -> bool { if config.db_type != DatabaseType::Jdbc { return false; } let connection_string = config.connection_string.as_deref().map(str::trim).filter(|value| !value.is_empty()); let driver_class = config.jdbc_driver_class.as_deref().map(str::trim).filter(|value| !value.is_empty()); let oracle_url = connection_string.map(|value| value.to_ascii_lowercase().starts_with("jdbc:oracle:")); let oracle_driver = driver_class.map(|value| { matches!(value.to_ascii_lowercase().as_str(), "oracle.jdbc.oracledriver" | "oracle.jdbc.driver.oracledriver") }); match (oracle_url, oracle_driver) { (Some(url_matches), Some(driver_matches)) => url_matches && driver_matches, (Some(url_matches), None) => url_matches, (None, Some(driver_matches)) => driver_matches, (None, None) => false, } } fn external_driver_uses_mysql_ddl(config: &ConnectionConfig) -> bool { is_mysql_external_driver_config(config) || gaussdb_uses_m_jdbc_driver(config) } async fn external_driver_gaussdb_m_indexes( session: std::sync::Arc, config: &ConnectionConfig, database: &str, schema: &str, table: &str, ) -> Result, String> { // Query information_schema.STATISTICS row by row (one row per index column, // ordered by SEQ_IN_INDEX). // Docs: https://support.huaweicloud.com/intl/en-us/centralized-m-comp-devg-v8-gaussdb/gaussdb-81-0134.html // The STATISTICS view has no EXPRESSION column in GaussDB M-mode. Expression // indexes are not supported via this path. let sql = format!( "SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX, NON_UNIQUE, \ INDEX_TYPE, INDEX_COMMENT, SUB_PART \ FROM information_schema.STATISTICS \ WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \ ORDER BY INDEX_NAME, SEQ_IN_INDEX", sql_string(schema), sql_string(table), ); log::debug!("[gaussdb-m][list_indexes] sql={sql}"); let timeout = agent_metadata_timeout(Some(config)); let result: db::QueryResult = session .invoke_with_timeout( "executeQuery", serde_json::json!({ "connection": config, "database": database, "schema": schema, "sql": sql, "maxRows": 1000, "fetchSize": 1000, "timeoutSecs": 60, }), timeout, ) .await?; log::debug!("[gaussdb-m][list_indexes] row_count={}", result.rows.len()); let col_index: std::collections::HashMap = result.columns.iter().enumerate().map(|(i, name)| (name.to_uppercase(), i)).collect(); let get_str = |row: &[serde_json::Value], key: &str| -> String { let upper = key.to_uppercase(); col_index.get(&upper).and_then(|&i| row.get(i)).and_then(|v| v.as_str()).unwrap_or("").to_string() }; let get_i64 = |row: &[serde_json::Value], key: &str| -> Option { let upper = key.to_uppercase(); col_index.get(&upper).and_then(|&i| row.get(i)).and_then(|v| v.as_i64()) }; let mut indexes: Vec = Vec::new(); let mut current_name = String::new(); let mut current_columns: Vec = Vec::new(); let mut current_is_expression: Vec = Vec::new(); let mut current_non_unique: i64 = 1; let mut current_index_type: Option = None; let mut current_comment: Option = None; for row in &result.rows { let name = get_str(row, "INDEX_NAME"); if name.is_empty() { log::debug!("[gaussdb-m][list_indexes] skipping row with empty INDEX_NAME: {:?}", row); continue; } if name != current_name { // Finalize previous index if !current_name.is_empty() { let is_primary = current_name.to_lowercase().ends_with("_pkey") || current_name.eq_ignore_ascii_case("primary"); indexes.push(db::IndexInfo { name: current_name.clone(), columns: current_columns.clone(), is_unique: current_non_unique == 0, is_primary, filter: None, index_type: current_index_type.clone(), included_columns: None, comment: current_comment.clone(), key_is_expression: current_is_expression.clone(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }); } // Start new index current_name = name; current_columns = Vec::new(); current_is_expression = Vec::new(); current_non_unique = get_i64(row, "NON_UNIQUE").unwrap_or(1); current_index_type = { let t = get_str(row, "INDEX_TYPE"); if t.is_empty() { None } else { Some(t) } }; current_comment = { let c = get_str(row, "INDEX_COMMENT"); if c.is_empty() { None } else { Some(c) } }; } // Build column name with prefix/sub_part suffix let column_name = get_str(row, "COLUMN_NAME"); if column_name.is_empty() { continue; } // SUB_PART is bigint in STATISTICS; JDBC plugin sends it as JSON number, // but may also be null or string. let sub_part: String = col_index .get("SUB_PART") .and_then(|&i| row.get(i)) .and_then(|v| { if v.is_null() { None } else if let Some(n) = v.as_i64() { Some(n.to_string()) } else { v.as_str().map(|s| s.to_string()) } }) .unwrap_or_default(); if !sub_part.is_empty() && sub_part != "0" && sub_part != "NULL" { // Prefix index: COLUMN_NAME(SUB_PART) like "name(10)" current_columns.push(format!("{}({})", column_name, sub_part)); } else { current_columns.push(column_name); } current_is_expression.push(false); } // Finalize last index if !current_name.is_empty() { let is_primary = current_name.to_lowercase().ends_with("_pkey") || current_name.eq_ignore_ascii_case("primary"); indexes.push(db::IndexInfo { name: current_name, columns: current_columns, is_unique: current_non_unique == 0, is_primary, filter: None, index_type: current_index_type, included_columns: None, comment: current_comment, key_is_expression: current_is_expression, column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }); } Ok(indexes) } fn gaussdb_m_view_object_source_sql( config: &ConnectionConfig, _database: &str, schema: &str, name: &str, kind: &db::ObjectSourceKind, ) -> Option { (gaussdb_uses_m_jdbc_driver(config) && matches!(kind, db::ObjectSourceKind::View)) .then(|| mysql_object_source_sql(schema, name, kind)) } fn mysql_external_driver_ddl_sql(database: &str, schema: &str, table: &str) -> String { format!("SHOW CREATE TABLE {}", mysql_qualified_name(mysql_table_metadata_catalog(database, schema), table)) } fn mysql_external_driver_ddl_from_query_result( result: db::QueryResult, named_ddl_column: &str, ) -> Result { let row = result.rows.first().ok_or_else(|| "DDL not found".to_string())?; let named_index = result.columns.iter().position(|column| column.trim().eq_ignore_ascii_case(named_ddl_column)); let ddl = named_index .into_iter() .chain(std::iter::once(1)) .filter_map(|index| query_result_cell_string(row, index)) .find(|value| !value.trim().is_empty()) .ok_or_else(|| "Failed to read DDL".to_string())?; if named_ddl_column.eq_ignore_ascii_case("Create Table") { Ok(normalize_mysql_display_ddl(ddl)) } else { Ok(ensure_display_ddl_terminated(ddl)) } } fn sqlite_object_type(kind: &db::ObjectSourceKind) -> &'static str { match kind { db::ObjectSourceKind::View | db::ObjectSourceKind::MaterializedView => "view", db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function | db::ObjectSourceKind::Trigger | db::ObjectSourceKind::Event | db::ObjectSourceKind::Sequence | db::ObjectSourceKind::Synonym | db::ObjectSourceKind::Job | db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody | db::ObjectSourceKind::Type | db::ObjectSourceKind::TypeBody => "routine", } } fn sqlserver_object_type_filter(kind: &db::ObjectSourceKind) -> &'static str { match kind { db::ObjectSourceKind::View => "'V'", db::ObjectSourceKind::Procedure => "'P'", db::ObjectSourceKind::Function => "'FN','IF','TF','FS','FT'", db::ObjectSourceKind::Trigger => "'TR'", db::ObjectSourceKind::Event | db::ObjectSourceKind::Sequence | db::ObjectSourceKind::Synonym | db::ObjectSourceKind::Job | db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody | db::ObjectSourceKind::Type | db::ObjectSourceKind::TypeBody | db::ObjectSourceKind::MaterializedView => "''", } } pub fn sqlserver_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourceKind) -> String { let object_type_filter = sqlserver_object_type_filter(kind); if schema.trim().is_empty() { // 保持历史语义:空 schema 不做“按默认 schema 解析”(OBJECT_ID 单参数形式), // 仍然走 schema+name 文本等值,空字符串 schema 不可能匹配 sys.schemas,结果为空。 // 前端在打开源码前已用数据库名兜底 schema,因此调用方不会依赖默认 schema 解析。 format!( "SELECT m.definition FROM sys.sql_modules m \ JOIN sys.objects o ON o.object_id = m.object_id \ JOIN sys.schemas s ON s.schema_id = o.schema_id \ WHERE s.name = {} AND o.name = {} AND o.type IN ({})", sql_string(schema), sql_string(name), object_type_filter ) } else { // 用 OBJECT_ID 解析限定名得到稳定 object_id 再关联 sys.sql_modules(与 DBeaver // `sys.sql_modules WHERE object_id = ...` 模式一致),避免文本等值谓词依赖 // collation / 同名前缀对象的身份判定。schema 为空时不做默认 schema 解析。 format!( "SELECT m.definition FROM sys.sql_modules m \ JOIN sys.objects o ON o.object_id = m.object_id \ WHERE o.object_id = {} AND o.type IN ({})", db::sqlserver::sqlserver_object_id_expression(schema, name), object_type_filter ) } } pub fn postgres_object_source_sql( schema: &str, name: &str, kind: &db::ObjectSourceKind, signature: Option<&str>, ) -> String { postgres_object_source_sql_inner(schema, name, kind, signature, true, false, true) } fn postgres_trigger_object_source_sql(schema: &str, name: &str, relation_name: Option<&str>) -> String { let relation_filter = relation_name .filter(|value| !value.trim().is_empty()) .map(|value| format!(" AND c.relname = {}", sql_string(value))) .unwrap_or_default(); format!( "SELECT pg_catalog.pg_get_triggerdef(t.oid, true) \ FROM pg_catalog.pg_trigger t \ JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid \ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ WHERE n.nspname = {} AND t.tgname = {} AND NOT t.tgisinternal{} \ ORDER BY t.oid LIMIT 1", sql_string(schema), sql_string(name), relation_filter ) } fn opengauss_object_source_sql( schema: &str, name: &str, kind: &db::ObjectSourceKind, signature: Option<&str>, ) -> String { postgres_object_source_sql_inner(schema, name, kind, signature, true, true, false) } fn opengauss_sequence_object_source_sql(schema: &str, name: &str, include_cache: bool) -> String { let cache_clause = if include_cache { "' cache ' || COALESCE((pg_sequence_last_value(c.oid)).cache_value::text, '1') || E'\\n' || " } else { "" }; format!( "SELECT concat_ws(E'\\n\\n', \ '-- auto-generated definition' || E'\\n' || \ 'create ' || CASE WHEN c.relkind IN ('L','Z') THEN 'large ' ELSE '' END || \ 'sequence ' || quote_ident(c.relname) || E'\\n' || \ ' increment by ' || COALESCE(s.increment::text, '1') || E'\\n' || \ ' minvalue ' || COALESCE(s.minimum_value::text, '1') || E'\\n' || \ ' maxvalue ' || COALESCE(s.maximum_value::text, '9223372036854775807') || E'\\n' || \ ' start with ' || COALESCE(s.start_value::text, '1') || E'\\n' || \ {cache_clause} \ CASE WHEN upper(COALESCE(s.cycle_option::text, 'NO')) = 'YES' \ THEN ' cycle;' ELSE ' no cycle;' END, \ 'alter ' || CASE WHEN c.relkind IN ('L','Z') THEN 'large ' ELSE '' END || \ 'sequence ' || quote_ident(c.relname) || ' owner to ' || quote_ident(pg_get_userbyid(c.relowner)) || ';', \ CASE WHEN owned.relname IS NOT NULL AND a.attname IS NOT NULL \ THEN 'alter ' || CASE WHEN c.relkind IN ('L','Z') THEN 'large ' ELSE '' END || \ 'sequence ' || quote_ident(c.relname) || ' owned by ' || quote_ident(owned.relname) || '.' || quote_ident(a.attname) || ';' \ END \ ) \ FROM pg_catalog.pg_class c \ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ JOIN information_schema.sequences s \ ON s.sequence_schema = n.nspname AND s.sequence_name = c.relname \ LEFT JOIN pg_catalog.pg_depend d \ ON d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype = 'a' \ LEFT JOIN pg_catalog.pg_class owned ON owned.oid = d.refobjid \ LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid \ WHERE n.nspname = {schema} AND c.relname = {name} AND c.relkind IN ('S','L','z','Z') \ ORDER BY c.oid LIMIT 1", schema = sql_string(schema), name = sql_string(name) ) } /// Servers without `pg_get_function_identity_arguments` (Redshift-compatible, /// some legacy PostgreSQL forks like TBase) have their object *list* signature /// built with the older `pg_get_function_arguments` formatter instead, which /// includes `DEFAULT ...` clauses for parameters with default values. Matching /// that signature against `pg_get_function_identity_arguments` (which never /// includes `DEFAULT` clauses) then always misses for routines with default /// parameters. This mirrors the signature filter but uses the same legacy /// formatter so it matches what the list query actually produced. fn postgres_function_object_source_sql_with_legacy_signature( schema: &str, name: &str, kind: &db::ObjectSourceKind, signature: Option<&str>, ) -> String { let prokind = if matches!(kind, db::ObjectSourceKind::Procedure) { "p" } else { "f" }; let signature_filter = signature .map(|value| format!(" AND pg_get_function_arguments(p.oid) = {}", sql_string(value))) .unwrap_or_default(); format!( "SELECT pg_get_functiondef(p.oid) \ FROM pg_proc p \ JOIN pg_namespace n ON n.oid = p.pronamespace \ WHERE n.nspname = {} AND p.proname = {} AND p.prokind = '{}'{} \ ORDER BY p.oid LIMIT 1", sql_string(schema), sql_string(name), prokind, signature_filter ) } fn postgres_function_object_source_sql_without_prokind( schema: &str, name: &str, unwrap_opengauss_record: bool, ) -> String { let source_expression = if unwrap_opengauss_record { "(pg_get_functiondef(p.oid)).definition" } else { "pg_get_functiondef(p.oid)" }; format!( "SELECT {source_expression} \ FROM pg_proc p \ JOIN pg_namespace n ON n.oid = p.pronamespace \ WHERE n.nspname = {} AND p.proname = {} AND NOT p.proisagg AND NOT p.proiswindow \ ORDER BY p.oid LIMIT 1", sql_string(schema), sql_string(name) ) } fn opengauss_routine_source_fallback_sqls( schema: &str, name: &str, object_type: &db::ObjectSourceKind, signature: Option<&str>, primary_err: &str, ) -> Vec<(&'static str, String)> { if !matches!(object_type, db::ObjectSourceKind::Function) { return vec![("text-return", postgres_object_source_sql(schema, name, object_type, signature))]; } let mut fallbacks = Vec::with_capacity(3); if !postgres_missing_prokind_error(primary_err) { fallbacks.push(("text-return", postgres_object_source_sql(schema, name, object_type, signature))); } // Legacy Gauss-family catalogs vary independently in pg_get_functiondef's return type and prokind support. // Keep both no-prokind expressions so a server with both compatibility differences still succeeds. fallbacks.push(( "record-return without prokind", postgres_function_object_source_sql_without_prokind(schema, name, true), )); fallbacks.push(( "text-return without prokind", postgres_function_object_source_sql_without_prokind(schema, name, false), )); fallbacks } fn postgres_object_source_sql_inner( schema: &str, name: &str, kind: &db::ObjectSourceKind, signature: Option<&str>, include_relispopulated: bool, unwrap_opengauss_record: bool, isolate_view_search_path: bool, ) -> String { match kind { db::ObjectSourceKind::View | db::ObjectSourceKind::MaterializedView => { let materialized_populated_clause = if include_relispopulated { " || CASE WHEN c.relispopulated THEN ' WITH DATA' ELSE ' WITH NO DATA' END" } else { "" }; let viewdef = if isolate_view_search_path { "pg_catalog.pg_get_viewdef(c.oid, 0)" } else { "pg_get_viewdef(c.oid, 0)" }; let regexp_replace = if isolate_view_search_path { "pg_catalog.regexp_replace" } else { "regexp_replace" }; let format_fn = if isolate_view_search_path { "pg_catalog.format" } else { "format" }; let materialized_viewdef = format!("{regexp_replace}({viewdef}, ';[[:space:]]*$', '')"); let materialized_source_expr = format!( "CASE WHEN {materialized_viewdef} ~* '^[[:space:]]*CREATE[[:space:]]+(OR[[:space:]]+REPLACE[[:space:]]+)?MATERIALIZED[[:space:]]+VIEW[[:space:]]+' \ THEN {materialized_viewdef} \ ELSE {format_fn}('CREATE MATERIALIZED VIEW %I.%I AS ', n.nspname, c.relname) || {materialized_viewdef}{materialized_populated_clause} \ END" ); let search_path_cte = if isolate_view_search_path { "WITH dbx_search_path AS (SELECT pg_catalog.set_config('search_path', '', true) AS applied) " } else { "" }; let search_path_join = if isolate_view_search_path { " CROSS JOIN dbx_search_path path_guard" } else { "" }; let search_path_guard = if isolate_view_search_path { " AND path_guard.applied IS NOT NULL" } else { "" }; format!( "{search_path_cte}SELECT CASE WHEN c.relkind = 'm' THEN {} \ ELSE {format_fn}('CREATE OR REPLACE VIEW %I.%I AS ', n.nspname, c.relname) || {viewdef} \ END \ FROM pg_catalog.pg_class c \ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ {search_path_join} \ WHERE n.nspname = {} AND c.relname = {} AND c.relkind IN ('v','m'){search_path_guard} \ ORDER BY c.oid LIMIT 1", materialized_source_expr, sql_string(schema), sql_string(name) ) } db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function => { let prokind = if matches!(kind, db::ObjectSourceKind::Procedure) { "p" } else { "f" }; let source_expression = if unwrap_opengauss_record { "(pg_get_functiondef(p.oid)).definition" } else { "pg_get_functiondef(p.oid)" }; let signature_filter = signature .map(|value| format!(" AND pg_get_function_identity_arguments(p.oid) = {}", sql_string(value))) .unwrap_or_default(); format!( "SELECT {source_expression} \ FROM pg_proc p \ JOIN pg_namespace n ON n.oid = p.pronamespace \ WHERE n.nspname = {} AND p.proname = {} AND p.prokind = '{}'{} \ ORDER BY p.oid LIMIT 1", sql_string(schema), sql_string(name), prokind, signature_filter ) } db::ObjectSourceKind::Sequence => { if unwrap_opengauss_record { return opengauss_sequence_object_source_sql(schema, name, true); } format!( "SELECT concat_ws(E'\\n\\n', \ '-- auto-generated definition' || E'\\n' || \ 'create sequence ' || quote_ident(c.relname) || E'\\n' || \ ' as ' || pg_catalog.format_type(s.seqtypid, NULL) || ';', \ 'alter sequence ' || quote_ident(c.relname) || ' owner to ' || quote_ident(pg_get_userbyid(c.relowner)) || ';', \ CASE WHEN owned.relname IS NOT NULL AND a.attname IS NOT NULL \ THEN 'alter sequence ' || quote_ident(c.relname) || ' owned by ' || quote_ident(owned.relname) || '.' || quote_ident(a.attname) || ';' \ END \ ) \ FROM pg_catalog.pg_class c \ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ JOIN pg_catalog.pg_sequence s ON s.seqrelid = c.oid \ LEFT JOIN pg_catalog.pg_depend d \ ON d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype = 'a' \ LEFT JOIN pg_catalog.pg_class owned ON owned.oid = d.refobjid \ LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid \ WHERE n.nspname = {} AND c.relname = {} AND c.relkind = 'S' \ ORDER BY c.oid LIMIT 1", sql_string(schema), sql_string(name) ) } db::ObjectSourceKind::Trigger | db::ObjectSourceKind::Event | db::ObjectSourceKind::Synonym | db::ObjectSourceKind::Job | db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody | db::ObjectSourceKind::Type | db::ObjectSourceKind::TypeBody => "SELECT NULL WHERE FALSE".to_string(), } } pub fn oracle_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourceKind) -> String { // Scheduler jobs are currently an Xugu-specific Agent object. Do not // manufacture an Oracle DBMS_METADATA request for a kind Oracle does not // expose through this generic source-SQL helper. if matches!(kind, db::ObjectSourceKind::Job) { return String::new(); } let object_type = match kind { db::ObjectSourceKind::View => "VIEW", db::ObjectSourceKind::MaterializedView => "MATERIALIZED_VIEW", db::ObjectSourceKind::Procedure => "PROCEDURE", db::ObjectSourceKind::Function => "FUNCTION", db::ObjectSourceKind::Trigger => "TRIGGER", db::ObjectSourceKind::Event => "EVENT", db::ObjectSourceKind::Sequence => "SEQUENCE", db::ObjectSourceKind::Synonym => "SYNONYM", db::ObjectSourceKind::Job => "", db::ObjectSourceKind::Package => "PACKAGE", db::ObjectSourceKind::PackageBody => "PACKAGE_BODY", db::ObjectSourceKind::Type => "TYPE", db::ObjectSourceKind::TypeBody => "TYPE_BODY", }; if schema.trim().is_empty() { format!("SELECT DBMS_METADATA.GET_DDL({}, {}) FROM DUAL", sql_string(object_type), sql_string(name)) } else { format!( "SELECT DBMS_METADATA.GET_DDL({}, {}, {}) FROM DUAL", sql_string(object_type), sql_string(name), sql_string(schema) ) } } pub fn sqlite_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourceKind) -> String { format!( "SELECT sql FROM {}.sqlite_master WHERE type = {} AND name = {}", db::sqlite::sqlite_quote_schema_ident(schema), sql_string(sqlite_object_type(kind)), sql_string(name) ) } async fn sqlite_object_source( pool: &db::sqlite::SqliteHandle, schema: &str, name: &str, kind: &db::ObjectSourceKind, ) -> Result { let pool = pool.clone(); let schema = schema.to_string(); let name = sql_string(name); let object_type = sql_string(sqlite_object_type(kind)); tokio::task::spawn_blocking(move || { pool.with_connection(|conn| { let schema = db::sqlite::sqlite_quote_schema_ident_for_connection(conn, &schema)?; let sql = format!("SELECT sql FROM {schema}.sqlite_master WHERE type = {object_type} AND name = {name}"); conn.query_row(&sql, [], |row| row.get::<_, String>(0)).map_err(|e| e.to_string()) }) }) .await .map_err(|e| e.to_string())? } pub fn mysql_object_source_sql(database: &str, name: &str, kind: &db::ObjectSourceKind) -> String { let qualified_name = mysql_qualified_name(database, name); match kind { db::ObjectSourceKind::View => format!("SHOW CREATE VIEW {qualified_name}"), db::ObjectSourceKind::Procedure => format!("SHOW CREATE PROCEDURE {qualified_name}"), db::ObjectSourceKind::Function => format!("SHOW CREATE FUNCTION {qualified_name}"), db::ObjectSourceKind::Trigger => format!("SHOW CREATE TRIGGER {qualified_name}"), db::ObjectSourceKind::Event => format!("SHOW CREATE EVENT {qualified_name}"), db::ObjectSourceKind::Sequence | db::ObjectSourceKind::Synonym | db::ObjectSourceKind::Job | db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody | db::ObjectSourceKind::Type | db::ObjectSourceKind::TypeBody => String::new(), // Doris and StarRocks expose materialized views via `SHOW CREATE MATERIALIZED VIEW`. // MySQL itself never reaches this arm in normal use: the desktop capabilities map at // apps/desktop/src/lib/database/databaseObjectCapabilities.ts has no "mysql" entry, // so the UI never sends MaterializedView for a real MySQL connection. If something // else forces the kind through, MySQL 8.x will surface a syntax error instead of // silently returning empty, which is the desired fail-loud behaviour. db::ObjectSourceKind::MaterializedView => { format!("SHOW CREATE MATERIALIZED VIEW {qualified_name}") } } } /// Column index of the DDL text in the row returned by the statements generated /// by [`mysql_object_source_sql`]. /// /// The shape of the result is dialect-dependent: /// - `SHOW CREATE VIEW`, Doris/StarRocks `SHOW CREATE MATERIALIZED VIEW` → /// `(Name, DDL)` → DDL at index `1`. /// - `SHOW CREATE PROCEDURE`, `SHOW CREATE FUNCTION`, `SHOW CREATE TRIGGER` → /// `(Name, sql_mode, DDL, …)` → DDL at index `2`. /// - `SHOW CREATE EVENT` → `(Event, sql_mode, time_zone, Create Event, …)` → /// DDL at index `3` (it has an extra `time_zone` column before the DDL). /// /// Encoded as a function so the index can be unit-tested without a live DB. pub(crate) fn mysql_object_source_ddl_column_index(kind: &db::ObjectSourceKind) -> usize { match kind { db::ObjectSourceKind::View | db::ObjectSourceKind::MaterializedView => 1, db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function | db::ObjectSourceKind::Trigger | db::ObjectSourceKind::Sequence | db::ObjectSourceKind::Synonym | db::ObjectSourceKind::Job | db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody | db::ObjectSourceKind::Type | db::ObjectSourceKind::TypeBody => 2, db::ObjectSourceKind::Event => 3, } } fn postgres_view_source_fallback_sql_inner(schema: &str, name: &str, isolate_search_path: bool) -> String { if isolate_search_path { format!( "WITH dbx_search_path AS (SELECT pg_catalog.set_config('search_path', '', true) AS applied) \ SELECT v.definition \ FROM pg_catalog.pg_views v \ CROSS JOIN dbx_search_path path_guard \ WHERE v.schemaname = {} AND v.viewname = {} AND path_guard.applied IS NOT NULL \ LIMIT 1", sql_string(schema), sql_string(name) ) } else { format!( "SELECT definition \ FROM pg_catalog.pg_views \ WHERE schemaname = {} AND viewname = {} \ LIMIT 1", sql_string(schema), sql_string(name) ) } } pub fn postgres_view_source_fallback_sql(schema: &str, name: &str) -> String { postgres_view_source_fallback_sql_inner(schema, name, true) } fn first_string_cell(result: db::QueryResult) -> Result { result .rows .first() .and_then(|row| row.iter().find_map(|value| value.as_str().map(str::to_string))) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| "Object source not found".to_string()) } fn parse_hex_u32(value: &str, offset: usize) -> Option { let end = offset.checked_add(8)?; u32::from_str_radix(value.get(offset..end)?, 16).ok() } fn is_sql_routine_definition(source: &str) -> bool { let mut words = source.split_ascii_whitespace(); if !words.next().is_some_and(|word| word.eq_ignore_ascii_case("CREATE")) { return false; } let Some(next) = words.next() else { return false; }; let kind = if next.eq_ignore_ascii_case("OR") { if !words.next().is_some_and(|word| word.eq_ignore_ascii_case("REPLACE")) { return false; } words.next() } else { Some(next) }; kind.is_some_and(|word| word.eq_ignore_ascii_case("FUNCTION") || word.eq_ignore_ascii_case("PROCEDURE")) } fn decode_opengauss_functiondef_record(source: &str) -> Option { const RECORD_HEADER_HEX_LEN: usize = 48; const INT4_OID: u32 = 23; const TEXT_OID: u32 = 25; let hex = source.strip_prefix("0x").or_else(|| source.strip_prefix("0X"))?; if hex.len() < RECORD_HEADER_HEX_LEN || !hex.is_ascii() || !hex.len().is_multiple_of(2) { return None; } if parse_hex_u32(hex, 0)? != 2 || parse_hex_u32(hex, 8)? != INT4_OID || parse_hex_u32(hex, 16)? != 4 || parse_hex_u32(hex, 24).is_none() || parse_hex_u32(hex, 32)? != TEXT_OID { return None; } let definition_len = usize::try_from(parse_hex_u32(hex, 40)?).ok()?; let expected_len = RECORD_HEADER_HEX_LEN.checked_add(definition_len.checked_mul(2)?)?; if hex.len() != expected_len { return None; } let definition_hex = &hex[RECORD_HEADER_HEX_LEN..]; let mut definition = Vec::with_capacity(definition_len); for pair in definition_hex.as_bytes().chunks_exact(2) { let pair = std::str::from_utf8(pair).ok()?; definition.push(u8::from_str_radix(pair, 16).ok()?); } let definition = String::from_utf8(definition).ok()?; is_sql_routine_definition(&definition).then_some(definition) } fn normalize_routine_object_source(source: String) -> String { decode_opengauss_functiondef_record(&source).unwrap_or(source) } async fn mysql_object_source( pool: &db::mysql::MySqlPool, database: &str, name: &str, kind: &db::ObjectSourceKind, ) -> Result { let primary_sql = mysql_object_source_sql(database, name, kind); let primary_column_index = mysql_object_source_ddl_column_index(kind); let mut conn = db::mysql::get_conn_with_timeout(pool, db::connection_timeout()).await?; match read_mysql_object_source_row(&mut conn, &primary_sql, primary_column_index).await { Ok(source) => Ok(source), Err(primary_err) if matches!(kind, db::ObjectSourceKind::MaterializedView) => { // StarRocks predating PR 73396 rejects SHOW CREATE MATERIALIZED VIEW for // sync MVs. Fall back to the persistent definition exposed by // information_schema.materialized_views. The fallback returns a single // column (MATERIALIZED_VIEW_DEFINITION) so the column index is always 0. let fallback_sql = db::starrocks::materialized_view_definition_sql(database, name); read_mysql_object_source_row(&mut conn, &fallback_sql, 0).await.map_err(|fallback_err| { format!( "SHOW CREATE MATERIALIZED VIEW failed ({primary_err}); \ fallback query against information_schema.materialized_views failed ({fallback_err})" ) }) } Err(e) => Err(e), } } async fn read_mysql_object_source_row( conn: &mut mysql_async::Conn, sql: &str, ddl_column_index: usize, ) -> Result { use mysql_async::prelude::*; let result = conn.query_iter(sql).await.map_err(|e| e.to_string())?; let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; let row = rows.first().ok_or("Object source not found")?; row.get_opt::(ddl_column_index) .and_then(|result| result.ok()) .or_else(|| { row.get_opt::, usize>(ddl_column_index) .and_then(|result| result.ok()) .map(|b| String::from_utf8_lossy(&b).to_string()) }) .ok_or_else(|| "Failed to read object source".to_string()) } /// Whether a connection may serve custom type details (phase 2). Kept /// separate from listing support so a future per-kind DDL capability can be /// toggled independently. fn supports_custom_type_details(config: &ConnectionConfig) -> bool { matches!( config.db_type, DatabaseType::Postgres | DatabaseType::OpenGauss | DatabaseType::Gaussdb | DatabaseType::Kingbase | DatabaseType::Vastbase ) } pub async fn get_custom_type_details_core( state: &AppState, connection_id: &str, database: &str, schema: &str, name: &str, ) -> Result { retry_metadata_connection(state, connection_id, Some(database), || { get_custom_type_details_once(state, connection_id, database, schema, name) }) .await } async fn get_custom_type_details_once( state: &AppState, connection_id: &str, database: &str, schema: &str, name: &str, ) -> Result { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; let Some(config) = db_config.as_ref() else { return Err("connection not found".to_string()); }; if !supports_custom_type_details(config) { return Err(format!("custom type details are not supported for {:?} connections", config.db_type)); } { let pool_handle = state.pool_handle(&pool_key).await; if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { let timeout_duration = agent_metadata_timeout(db_config.as_ref()); let mut client = client.lock().await; return client .get_custom_type_details::(database, schema, name, timeout_duration) .await; } } let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { PoolKind::Postgres(p) => db::postgres::get_custom_type_details(p, schema, name).await, _ => Err("custom type details are not supported for this connection type".to_string()), } } pub async fn get_object_source_core( state: &AppState, connection_id: &str, database: &str, schema: &str, name: &str, object_type: db::ObjectSourceKind, signature: Option<&str>, relation_name: Option<&str>, ) -> Result { let source = retry_metadata_connection(state, connection_id, Some(database), || { get_object_source_once( state, connection_id, database, schema, name, object_type.clone(), signature, relation_name, ) }) .await?; Ok(finalize_object_source(source)) } pub async fn get_event_info_core( state: &AppState, connection_id: &str, database: &str, _schema: &str, name: &str, ) -> Result { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match pool { PoolKind::Mysql(pool, _) => db::mysql::get_event_info(&pool, database, name).await, PoolKind::ExternalDriver { config, session, .. } => { let quote = |value: &str| format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")); let sql = format!("SELECT EVENT_SCHEMA, EVENT_NAME, DEFINER, TIME_ZONE, EVENT_TYPE, EXECUTE_AT, INTERVAL_VALUE, INTERVAL_FIELD, STARTS, ENDS, STATUS, ON_COMPLETION, EVENT_COMMENT, EVENT_DEFINITION, CREATED, LAST_ALTERED, LAST_EXECUTED FROM information_schema.EVENTS WHERE EVENT_SCHEMA = {} AND EVENT_NAME = {} LIMIT 1", quote(database), quote(name)); let result: db::QueryResult = session.invoke_with_timeout("executeQuery", serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": _schema, "sql": sql, "maxRows": 1 }), agent_metadata_timeout(Some(&config))).await?; let row = result.rows.first().ok_or_else(|| format!("MySQL event not found: {database}.{name}"))?; let text = |column: &str| { result.columns.iter().position(|c| c.eq_ignore_ascii_case(column)).and_then(|i| row.get(i)).and_then( |v| { if v.is_null() { None } else { Some(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())) } }, ) }; Ok(db::MysqlEventInfo { name: text("EVENT_NAME").unwrap_or_else(|| name.to_string()), schema: text("EVENT_SCHEMA").unwrap_or_else(|| database.to_string()), definer: text("DEFINER"), time_zone: text("TIME_ZONE"), event_type: text("EVENT_TYPE"), execute_at: text("EXECUTE_AT"), interval_value: text("INTERVAL_VALUE"), interval_field: text("INTERVAL_FIELD"), starts: text("STARTS"), ends: text("ENDS"), status: text("STATUS"), on_completion: text("ON_COMPLETION"), comment: text("EVENT_COMMENT"), event_body: text("EVENT_DEFINITION"), event_definition: text("EVENT_DEFINITION"), created_at: text("CREATED"), updated_at: text("LAST_ALTERED"), last_executed: text("LAST_EXECUTED"), source: None, }) } _ => Err("MySQL event details are only supported for MySQL connections".into()), } } fn finalize_object_source(mut source: db::ObjectSource) -> db::ObjectSource { if matches!(source.object_type, db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function) { source.source = normalize_routine_object_source(source.source); } if matches!(source.object_type, db::ObjectSourceKind::Event) { source.editable = Some(false); } source } async fn get_object_source_once( state: &AppState, connection_id: &str, database: &str, schema: &str, name: &str, object_type: db::ObjectSourceKind, signature: Option<&str>, relation_name: Option<&str>, ) -> Result { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; let source = { let pool_handle = state.pool_handle(&pool_key).await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = pool_handle.as_ref() { let config = config.clone(); let session = session.clone(); if let Some(sql) = gaussdb_m_view_object_source_sql(config.as_ref(), database, schema, name, &object_type) { let result: db::QueryResult = session .invoke_with_timeout( "executeQuery", serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": schema, "sql": sql, "maxRows": 1, }), agent_metadata_timeout(Some(config.as_ref())), ) .await?; let source = mysql_external_driver_ddl_from_query_result(result, "Create View")?; return Ok(db::ObjectSource { name: name.to_string(), object_type, schema: if schema.is_empty() { None } else { Some(schema.to_string()) }, source, editable: None, }); } let result: db::ObjectSource = session .invoke_with_timeout( "getObjectSource", serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": schema, "name": name, "object_type": &object_type, }), agent_metadata_timeout(Some(config.as_ref())), ) .await?; return Ok(result); } if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) { let mut client = lock_sqlserver_metadata_client(&client).await?; let result = db::sqlserver::execute_query(&mut client, &sqlserver_object_source_sql(schema, name, &object_type)) .await; drop(client); if matches!(result.as_ref(), Err(err) if should_discard_pool_after_error(Some(DatabaseType::SqlServer), err)) { state.remove_pool_by_key(&pool_key).await; } first_string_cell(result?)? } else if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) { if uses_oracle_metadata_object_source(db_config.as_ref(), &object_type) { oracle_agent_object_source( client, database, schema, name, &object_type, agent_metadata_timeout(db_config.as_ref()), ) .await? } else { let mut client = client.lock().await; let result: db::ObjectSource = client .get_object_source_for_relation( database, schema, name, &object_type, relation_name, agent_metadata_timeout(db_config.as_ref()), ) .await?; return Ok(result); } } else { match pool_handle.as_ref().ok_or("Pool not found")? { PoolKind::Mysql(pool, _) => { mysql_object_source(pool, mysql_table_metadata_catalog(database, schema), name, &object_type) .await? } PoolKind::Postgres(pool) if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OpenGauss) && matches!(object_type, db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody) => { db::postgres::opengauss_package_source( pool, schema, name, matches!(object_type, db::ObjectSourceKind::PackageBody), ) .await? } PoolKind::Postgres(pool) if db_config.as_ref().is_some_and(is_questdb_config) => { // only view db::questdb::questdb_object_source(pool, name).await? } PoolKind::Postgres(pool) => { let unwrap_opengauss_record = db_config.as_ref().is_some_and(is_opengauss_family_config); let isolate_view_search_path = postgres_view_source_uses_isolated_search_path( db_config.as_ref().map(|config| &config.db_type), ); postgres_object_source( pool, schema, name, &object_type, signature, relation_name, unwrap_opengauss_record, isolate_view_search_path, ) .await? } PoolKind::Sqlite(pool) => sqlite_object_source(pool, schema, name, &object_type).await?, #[cfg(feature = "duckdb-sidecar")] PoolKind::DuckDbWorker(client) => { let client = client.clone(); let database = database.to_string(); let schema = schema.to_string(); let name = name.to_string(); let object_type = object_type.clone(); client.get_object_source(database, schema, name, object_type).await? } PoolKind::Rqlite(client) => { return db::rqlite_driver::object_source(client, name, &object_type).await; } PoolKind::Turso(client) => { return db::turso_driver::object_source(client, name, &object_type).await; } PoolKind::ClickHouse(client) if matches!(object_type, db::ObjectSourceKind::View) => { let result = db::clickhouse_driver::execute_query( client, database, &format!("SHOW CREATE TABLE {}", mysql_ident(name)), ) .await?; first_string_cell(result)? } PoolKind::CloudflareD1(client) => { return db::cloudflare_d1_driver::object_source(client, name, &object_type).await; } _ => return Err("Object source is not supported for this database type".to_string()), } } }; let editable = if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OpenGauss) && matches!(object_type, db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody) { // gs_source returns the original CREATE text. Re-executing CREATE for an // existing package is not a safe edit operation unless the user changes // it to CREATE OR REPLACE explicitly, so keep the initial implementation read-only. Some(false) } else if matches!(object_type, db::ObjectSourceKind::Trigger) && db_config.as_ref().is_some_and(|config| { matches!( config.db_type, DatabaseType::Postgres | DatabaseType::Redshift | DatabaseType::Gaussdb | DatabaseType::Kwdb | DatabaseType::OpenGauss | DatabaseType::Questdb | DatabaseType::Kingbase | DatabaseType::Highgo | DatabaseType::Uxdb | DatabaseType::Vastbase ) }) { Some(false) } else { None }; Ok(db::ObjectSource { name: name.to_string(), object_type, schema: if schema.is_empty() { None } else { Some(schema.to_string()) }, source, editable, }) } fn oracle_owner_filter(schema: &str) -> String { let schema = schema.trim(); if schema.is_empty() { "USER".to_string() } else { sql_string(&schema.to_uppercase()) } } pub fn oracle_list_objects_sql(schema: &str) -> String { format!( "SELECT object_name, CASE object_type WHEN 'PACKAGE BODY' THEN 'PACKAGE_BODY' ELSE object_type END AS object_type, owner \ FROM all_objects \ WHERE owner = {} AND object_type IN ('TABLE', 'VIEW', 'PROCEDURE', 'FUNCTION', 'SEQUENCE', 'PACKAGE', 'PACKAGE BODY') \ ORDER BY CASE object_type WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'PROCEDURE' THEN 2 WHEN 'FUNCTION' THEN 3 WHEN 'SEQUENCE' THEN 4 WHEN 'PACKAGE' THEN 5 ELSE 6 END, object_name", oracle_owner_filter(schema) ) } async fn oracle_agent_list_objects( client: Arc, database: &str, schema: &str, timeout_duration: Option, ) -> Result, String> { let sql = oracle_list_objects_sql(schema); let params = agent_execute_query_params( &sql, if database.is_empty() { None } else { Some(database) }, if schema.is_empty() { None } else { Some(schema) }, QueryExecutionOptions { max_rows: Some(10_000), ..Default::default() }, ); let mut client = client.lock().await; let result: db::QueryResult = client.execute_query_with_timeout(params, timeout_duration).await?; let mut objects: Vec = result .rows .into_iter() .filter_map(|row| { let name = row.first()?.as_str()?.to_string(); let object_type = row.get(1)?.as_str()?.to_string(); let schema = row.get(2).and_then(|value| value.as_str()).map(str::to_string); Some(db::ObjectInfo { name, object_type, schema, valid: None, signature: None, custom_type_kind: None, has_members: None, comment: None, created_at: None, updated_at: None, parent_schema: None, parent_name: None, trigger: None, xugu_type_members_expandable: None, }) }) .collect(); load_oracle_table_comments_for_objects(&mut client, database, schema, &mut objects, timeout_duration).await?; Ok(objects) } async fn oracle_agent_object_source( client: Arc, database: &str, schema: &str, name: &str, object_type: &db::ObjectSourceKind, timeout_duration: Option, ) -> Result { let sql = oracle_object_source_sql(schema, name, object_type); let params = agent_execute_query_params( &sql, if database.is_empty() { None } else { Some(database) }, if schema.is_empty() { None } else { Some(schema) }, QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, ); let mut client = client.lock().await; let result: db::QueryResult = client.execute_query_with_timeout(params, timeout_duration).await?; first_string_cell(result) } async fn oracle_agent_table_ddl( client: Arc, database: &str, schema: &str, table: &str, portable: bool, timeout_duration: Option, ) -> Result { let mut client = client.lock().await; let ddl = client.get_table_ddl_with_options::(database, schema, table, portable, timeout_duration).await?; match append_oracle_table_comment_ddl(&mut client, database, schema, table, &ddl, timeout_duration).await { Ok(ddl) => Ok(ddl), Err(error) => { log::debug!( "[schema][oracle:get_table_ddl:comments-fallback-failed] schema={} table={} error={}", schema, table, error ); Ok(ddl) } } } async fn append_oracle_table_comment_ddl( client: &mut db::agent_driver::AgentDriverClient, database: &str, schema: &str, table: &str, ddl: &str, timeout_duration: Option, ) -> Result { let table_comment = oracle_table_comments_for_names(client, database, schema, &[table.to_string()], timeout_duration) .await? .into_iter() .find(|(name, _)| name.eq_ignore_ascii_case(table)) .map(|(_, comment)| comment); let columns = client.get_columns::>(database, schema, table, timeout_duration).await.unwrap_or_default(); Ok(append_oracle_comments_to_ddl(ddl, schema, table, table_comment.as_deref(), &columns)) } fn append_oracle_comments_to_ddl( ddl: &str, schema: &str, table: &str, table_comment: Option<&str>, columns: &[db::ColumnInfo], ) -> String { let mut result = crate::object_source_sql::ensure_oracle_ddl_terminated(ddl); if result.trim().is_empty() { return result; } let existing_ddl_upper = ddl.to_ascii_uppercase(); let table_ref = if schema.trim().is_empty() { oracle_ident(table) } else { format!("{}.{}", oracle_ident(schema), oracle_ident(table)) }; if !existing_ddl_upper.contains("COMMENT ON TABLE") { if let Some(comment) = table_comment.map(str::trim).filter(|comment| !comment.is_empty()) { result.push_str(&format!("\nCOMMENT ON TABLE {table_ref} IS {};", sql_string(comment))); } } if !existing_ddl_upper.contains("COMMENT ON COLUMN") { for column in columns { if let Some(comment) = column.comment.as_deref().map(str::trim).filter(|comment| !comment.is_empty()) { result.push_str(&format!( "\nCOMMENT ON COLUMN {table_ref}.{} IS {};", oracle_ident(&column.name), sql_string(comment) )); } } } result } fn should_append_oracle_style_comment_ddl(config: Option<&ConnectionConfig>) -> bool { config.is_some_and(|config| { matches!(config.db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle | DatabaseType::Dameng) || is_oracle_external_driver_config(config) }) } /// Enrich display DDL with dictionary comments for Oracle-family engines. /// Failures are logged and the original DDL is returned unchanged. async fn enrich_ddl_with_oracle_style_comments( state: &AppState, connection_id: &str, database: &str, schema: &str, table: &str, ddl: &str, ) -> String { let columns = match get_columns_core(state, connection_id, database, schema, table).await { Ok(columns) => columns, Err(error) => { log::debug!( "[schema][oracle:display-ddl:columns-for-comments-failed] connection_id={} schema={} table={} error={}", connection_id, schema, table, error ); Vec::new() } }; let table_comment = match get_table_comment_core(state, connection_id, database, schema, table).await { Ok(comment) => comment, Err(error) => { log::debug!( "[schema][oracle:display-ddl:table-comment-failed] connection_id={} schema={} table={} error={}", connection_id, schema, table, error ); None } }; append_oracle_comments_to_ddl(ddl, schema, table, table_comment.as_deref(), &columns) } async fn external_driver_oracle_table_comment( session: std::sync::Arc, config: &ConnectionConfig, database: &str, schema: &str, table: &str, ) -> Result, String> { let result: db::QueryResult = session .invoke_with_timeout( "executeQuery", serde_json::json!({ "connection": config, "database": database, "schema": schema, "sql": oracle_table_comment_sql(schema, table), "maxRows": 1 }), agent_metadata_timeout(Some(config)), ) .await?; oracle_table_comment_from_query_result(result) } async fn db2_agent_table_ddl( client: Arc, database: &str, schema: &str, table: &str, timeout_duration: Option, ) -> Result { let mut client = client.lock().await; let ddl = client.get_table_ddl::(database, schema, table, timeout_duration).await?; match append_db2_comments_to_ddl(&mut client, database, schema, table, &ddl, timeout_duration).await { Ok(ddl) => Ok(ddl), Err(error) => { log::debug!( "[schema][db2:get_table_ddl:comments-fallback-failed] schema={} table={} error={}", schema, table, error ); Ok(ddl) } } } async fn append_db2_comments_to_ddl( client: &mut db::agent_driver::AgentDriverClient, database: &str, schema: &str, table: &str, ddl: &str, timeout_duration: Option, ) -> Result { let table_comment = db2_table_comment(client, database, schema, table, timeout_duration).await; let column_comments = db2_column_comments(client, database, schema, table, timeout_duration).await; let mut columns = client.get_columns::>(database, schema, table, timeout_duration).await.unwrap_or_default(); if !column_comments.is_empty() { for column in &mut columns { if column.comment.as_deref().is_none_or(|c| c.trim().is_empty() || c.trim().eq_ignore_ascii_case("null")) { if let Some(remark) = column_comments.get(&column.name.to_uppercase()) { column.comment = Some(remark.clone()); } } } } Ok(append_oracle_comments_to_ddl(ddl, schema, table, table_comment.as_deref(), &columns)) } async fn db2_table_comment( client: &mut db::agent_driver::AgentDriverClient, database: &str, schema: &str, table: &str, timeout_duration: Option, ) -> Option { // 优先使用原始值查询,支持 quoted/mixed-case 对象;如果查不到再 fallback 到大写 for (schema_name, table_name) in [(schema.trim(), table.trim()), (&schema.trim().to_uppercase(), &table.trim().to_uppercase())] { let schema_filter = if schema_name.is_empty() { "CURRENT SCHEMA".to_string() } else { sql_string(schema_name) }; let sql = format!( "SELECT REMARKS FROM SYSCAT.TABLES WHERE TABSCHEMA = {} AND TABNAME = {} AND REMARKS IS NOT NULL", schema_filter, sql_string(table_name), ); if let Ok(result) = client .execute_query_with_timeout::( agent_execute_query_params( &sql, if database.is_empty() { None } else { Some(database) }, if schema.is_empty() { None } else { Some(schema) }, QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, ), timeout_duration, ) .await { if let Some(comment) = result.rows.first().and_then(|row| row.first()).and_then(|v| v.as_str()).map(|s| s.to_string()) { return Some(comment); } } } None } async fn db2_column_comments( client: &mut db::agent_driver::AgentDriverClient, database: &str, schema: &str, table: &str, timeout_duration: Option, ) -> HashMap { // 优先使用原始值查询,支持 quoted/mixed-case 对象;如果查不到再 fallback 到大写 let mut comments = HashMap::new(); for (schema_name, table_name) in [(schema.trim(), table.trim()), (&schema.trim().to_uppercase(), &table.trim().to_uppercase())] { let schema_filter = if schema_name.is_empty() { "CURRENT SCHEMA".to_string() } else { sql_string(schema_name) }; let sql = format!( "SELECT COLNAME, REMARKS FROM SYSCAT.COLUMNS WHERE TABSCHEMA = {} AND TABNAME = {} AND REMARKS IS NOT NULL", schema_filter, sql_string(table_name), ); let result = match client .execute_query_with_timeout::( agent_execute_query_params( &sql, if database.is_empty() { None } else { Some(database) }, if schema.is_empty() { None } else { Some(schema) }, QueryExecutionOptions { ..Default::default() }, ), timeout_duration, ) .await { Ok(result) => result, Err(_) => continue, }; for row in &result.rows { let col_name = row.first().and_then(|v| v.as_str()).unwrap_or("").trim(); let remark = row.get(1).and_then(|v| v.as_str()).unwrap_or("").trim(); if !col_name.is_empty() && !remark.is_empty() { comments.entry(col_name.to_uppercase()).or_insert_with(|| remark.to_string()); } } if !comments.is_empty() { break; } } comments } fn postgres_view_source_uses_isolated_search_path(database_type: Option<&DatabaseType>) -> bool { database_type == Some(&DatabaseType::Postgres) } async fn postgres_object_source( pool: &deadpool_postgres::Pool, schema: &str, name: &str, object_type: &db::ObjectSourceKind, signature: Option<&str>, relation_name: Option<&str>, unwrap_opengauss_record: bool, isolate_view_search_path: bool, ) -> Result { let sql = if matches!(object_type, db::ObjectSourceKind::Trigger) { postgres_trigger_object_source_sql(schema, name, relation_name) } else if unwrap_opengauss_record { opengauss_object_source_sql(schema, name, object_type, signature) } else if isolate_view_search_path { postgres_object_source_sql(schema, name, object_type, signature) } else { postgres_object_source_sql_inner(schema, name, object_type, signature, true, false, false) }; match db::postgres::execute_query(pool, &sql).await.and_then(first_string_cell) { Ok(source) => Ok(source), Err(primary_err) if postgres_missing_relispopulated_error(&primary_err) && matches!(object_type, db::ObjectSourceKind::View | db::ObjectSourceKind::MaterializedView) => { let fallback_sql = postgres_object_source_sql_inner( schema, name, object_type, signature, false, false, isolate_view_search_path, ); db::postgres::execute_query(pool, &fallback_sql) .await .and_then(first_string_cell) .map_err(|fallback_err| format!("{primary_err}; relispopulated fallback failed: {fallback_err}")) } Err(primary_err) if unwrap_opengauss_record && matches!(object_type, db::ObjectSourceKind::Sequence) && opengauss_sequence_cache_metadata_error(&primary_err) => { let fallback_sql = opengauss_sequence_object_source_sql(schema, name, false); db::postgres::execute_query(pool, &fallback_sql) .await .and_then(first_string_cell) .map_err(|fallback_err| format!("{primary_err}; sequence cache fallback failed: {fallback_err}")) } Err(primary_err) if unwrap_opengauss_record && matches!(object_type, db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function) => { let mut errors = vec![primary_err]; for (label, fallback_sql) in opengauss_routine_source_fallback_sqls(schema, name, object_type, signature, &errors[0]) { match db::postgres::execute_query(pool, &fallback_sql).await.and_then(first_string_cell) { Ok(source) => return Ok(source), Err(fallback_err) => errors.push(format!("{label} fallback failed: {fallback_err}")), } } Err(errors.join("; ")) } Err(primary_err) if postgres_missing_prokind_error(&primary_err) && matches!(object_type, db::ObjectSourceKind::Function) => { let fallback_sql = postgres_function_object_source_sql_without_prokind(schema, name, false); db::postgres::execute_query(pool, &fallback_sql) .await .and_then(first_string_cell) .map_err(|fallback_err| format!("{primary_err}; prokind fallback failed: {fallback_err}")) } Err(primary_err) if !unwrap_opengauss_record && primary_err == "Object source not found" && signature.is_some() && matches!(object_type, db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function) => { let fallback_sql = postgres_function_object_source_sql_with_legacy_signature(schema, name, object_type, signature); db::postgres::execute_query(pool, &fallback_sql) .await .and_then(first_string_cell) .map_err(|fallback_err| format!("{primary_err}; legacy signature fallback failed: {fallback_err}")) } Err(primary_err) if matches!(object_type, db::ObjectSourceKind::View) => { let fallback_sql = postgres_view_source_fallback_sql_inner(schema, name, isolate_view_search_path); db::postgres::execute_query(pool, &fallback_sql) .await .and_then(first_string_cell) .map_err(|fallback_err| format!("{primary_err}; fallback failed: {fallback_err}")) } Err(err) => Err(err), } } fn postgres_missing_prokind_error(err: &str) -> bool { let lower = err.to_ascii_lowercase(); let mentions_prokind = lower.contains("p.prokind") || lower.contains("\"p\".\"prokind\"") || lower.contains("\"prokind\""); if !mentions_prokind { return false; } // PostgreSQL localizes the undefined-column message (for example, Chinese // servers report "字段 p.prokind 不存在"). Keep the column context so an // unrelated relation named `prokind` cannot trigger this compatibility path. lower.contains("sqlstate 42703") || (lower.contains("does not exist") && lower.contains("column")) || (err.contains("不存在") && (err.contains("字段") || err.contains("列 p.prokind"))) } fn opengauss_sequence_cache_metadata_error(err: &str) -> bool { let lower = err.to_ascii_lowercase(); lower.contains("pg_sequence_last_value") || lower.contains("cache_value") } fn postgres_missing_relispopulated_error(err: &str) -> bool { let lower = err.to_ascii_lowercase(); lower.contains("does not exist") && (lower.contains("column c.relispopulated") || lower.contains("column \"c\".\"relispopulated\"") || lower.contains("column \"relispopulated\"")) } #[cfg(test)] mod object_source_tests { use super::*; use crate::types::ObjectSourceKind; fn opengauss_functiondef_record_hex(headerlines: u32, definition: &[u8]) -> String { let mut bytes = Vec::with_capacity(24 + definition.len()); bytes.extend_from_slice(&2_u32.to_be_bytes()); bytes.extend_from_slice(&23_u32.to_be_bytes()); bytes.extend_from_slice(&4_u32.to_be_bytes()); bytes.extend_from_slice(&headerlines.to_be_bytes()); bytes.extend_from_slice(&25_u32.to_be_bytes()); bytes.extend_from_slice(&u32::try_from(definition.len()).unwrap().to_be_bytes()); bytes.extend_from_slice(definition); format!("0x{}", crate::db::hex_encode(&bytes)) } #[tokio::test] async fn reads_sqlite_object_source_from_dotted_attached_schema() { let pool = db::sqlite::connect_path(":memory:").await.expect("connect primary database"); db::sqlite::attach_database(&pool, "analytics.db", ":memory:").expect("attach database"); db::sqlite::execute_query(&pool, "CREATE VIEW \"analytics.db\".active_users AS SELECT 1 AS id") .await .expect("create attached view"); let source = sqlite_object_source(&pool, "analytics.db", "active_users", &ObjectSourceKind::View) .await .expect("read attached view source"); assert!(source.contains("CREATE VIEW active_users")); } #[test] fn builds_sqlserver_object_source_sql_from_object_id_identity() { // 同名前缀对象:请求名原样进入 QUOTENAME 限定名,由 OBJECT_ID 解析为单一 // object_id,杜绝 F_GetEnumName / F_GetEnumName1 / F_GetEnumName10 这类 // 名称互为前缀的对象被文本谓词误匹配。 for name in ["F_GetEnumName", "F_GetEnumName1", "F_GetEnumName10"] { assert_eq!( sqlserver_object_source_sql("dbo", name, &ObjectSourceKind::Function), format!( "SELECT m.definition FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id \ WHERE o.object_id = OBJECT_ID(QUOTENAME(N'dbo') + N'.' + QUOTENAME(N'{name}')) \ AND o.type IN ('FN','IF','TF','FS','FT')" ) ); } } #[test] fn builds_sqlserver_object_source_sql_for_schema_scoped_routines() { assert_eq!( sqlserver_object_source_sql("dbo", "refresh_cache", &ObjectSourceKind::Procedure), "SELECT m.definition FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id WHERE o.object_id = OBJECT_ID(QUOTENAME(N'dbo') + N'.' + QUOTENAME(N'refresh_cache')) AND o.type IN ('P')" ); } #[test] fn sqlserver_object_source_sql_keeps_object_type_filter_per_kind() { assert_eq!( sqlserver_object_source_sql("dbo", "active_users", &ObjectSourceKind::View), "SELECT m.definition FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id WHERE o.object_id = OBJECT_ID(QUOTENAME(N'dbo') + N'.' + QUOTENAME(N'active_users')) AND o.type IN ('V')" ); assert_eq!( sqlserver_object_source_sql("dbo", "trg_audit", &ObjectSourceKind::Trigger), "SELECT m.definition FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id WHERE o.object_id = OBJECT_ID(QUOTENAME(N'dbo') + N'.' + QUOTENAME(N'trg_audit')) AND o.type IN ('TR')" ); } #[test] fn sqlserver_object_source_sql_scopes_identity_by_schema() { // 相同对象名、不同 schema 必须解析到不同 identity(schema 进入限定名)。 assert_eq!( sqlserver_object_source_sql("other_schema", "F_GetEnumName1", &ObjectSourceKind::Function), "SELECT m.definition FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id WHERE o.object_id = OBJECT_ID(QUOTENAME(N'other_schema') + N'.' + QUOTENAME(N'F_GetEnumName1')) AND o.type IN ('FN','IF','TF','FS','FT')" ); } #[test] fn sqlserver_object_source_sql_keeps_historical_empty_schema_semantics() { // 空 schema 不落入 OBJECT_ID 的单参数形式(那会按用户默认 schema 解析): // 保持 schema+name 文本等值,空字符串 schema 不可能存在于 sys.schemas, // 因此结果恒为“未找到”,与历史行为一致,不引入默认 schema 解析。 assert_eq!( sqlserver_object_source_sql("", "refresh_cache", &ObjectSourceKind::Procedure), "SELECT m.definition FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id JOIN sys.schemas s ON s.schema_id = o.schema_id WHERE s.name = '' AND o.name = 'refresh_cache' AND o.type IN ('P')" ); } #[test] fn sqlserver_object_source_sql_escapes_object_identity_literals() { // 单引号在 SQL literal 中双写;] 等 identifier 特殊字符交由 QUOTENAME 处理。 assert_eq!( sqlserver_object_source_sql("schema'with-quote", "function]name", &ObjectSourceKind::Function), "SELECT m.definition FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id WHERE o.object_id = OBJECT_ID(QUOTENAME(N'schema''with-quote') + N'.' + QUOTENAME(N'function]name')) AND o.type IN ('FN','IF','TF','FS','FT')" ); } #[test] fn builds_postgres_object_source_sql_for_views_and_functions() { let view_sql = postgres_object_source_sql("public", "active_users", &ObjectSourceKind::View, None); assert!(view_sql .starts_with("WITH dbx_search_path AS (SELECT pg_catalog.set_config('search_path', '', true) AS applied)")); assert!(view_sql.contains("pg_catalog.pg_get_viewdef(c.oid, 0)")); assert!(view_sql.contains("CROSS JOIN dbx_search_path path_guard")); assert!(view_sql.contains("path_guard.applied IS NOT NULL")); assert!(view_sql.contains("CREATE MATERIALIZED VIEW")); assert!(view_sql.contains("CREATE OR REPLACE VIEW")); assert!(view_sql.contains("CASE WHEN c.relispopulated THEN ' WITH DATA' ELSE ' WITH NO DATA' END")); assert!(view_sql.contains("n.nspname = 'public'")); assert!(view_sql.contains("c.relname = 'active_users'")); assert_eq!( postgres_object_source_sql("public", "recalc_score", &ObjectSourceKind::Function, None), "SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' AND p.proname = 'recalc_score' AND p.prokind = 'f' ORDER BY p.oid LIMIT 1" ); assert_eq!( postgres_object_source_sql("public", "recalc_score", &ObjectSourceKind::Function, Some("integer, integer")), "SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' AND p.proname = 'recalc_score' AND p.prokind = 'f' AND pg_get_function_identity_arguments(p.oid) = 'integer, integer' ORDER BY p.oid LIMIT 1" ); assert_eq!( postgres_function_object_source_sql_with_legacy_signature( "public", "recalc_score", &ObjectSourceKind::Procedure, Some("i_id numeric DEFAULT 0, OUT o_code integer"), ), "SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' AND p.proname = 'recalc_score' AND p.prokind = 'p' AND pg_get_function_arguments(p.oid) = 'i_id numeric DEFAULT 0, OUT o_code integer' ORDER BY p.oid LIMIT 1" ); let opengauss_view_sql = opengauss_object_source_sql("public", "active_users", &ObjectSourceKind::View, None); assert!(!opengauss_view_sql.contains("set_config('search_path'")); let compatible_view_sql = postgres_object_source_sql_inner( "public", "active_users", &ObjectSourceKind::View, None, true, false, false, ); assert!(!compatible_view_sql.contains("set_config('search_path'")); } #[test] fn isolates_view_search_path_only_for_native_postgres() { assert!(postgres_view_source_uses_isolated_search_path(Some(&DatabaseType::Postgres))); assert!(!postgres_view_source_uses_isolated_search_path(Some(&DatabaseType::Redshift))); assert!(!postgres_view_source_uses_isolated_search_path(Some(&DatabaseType::OpenGauss))); assert!(!postgres_view_source_uses_isolated_search_path(Some(&DatabaseType::Kingbase))); assert!(!postgres_view_source_uses_isolated_search_path(None)); } #[test] fn builds_postgres_object_source_sql_for_table_trigger() { let sql = postgres_trigger_object_source_sql("audit", "trg_orders_update", Some("orders")); assert!(sql.contains("pg_get_triggerdef(t.oid, true)")); assert!(sql.contains("n.nspname = 'audit'")); assert!(sql.contains("c.relname = 'orders'")); assert!(sql.contains("t.tgname = 'trg_orders_update'")); assert!(sql.contains("NOT t.tgisinternal")); } #[test] fn builds_postgres_object_source_sql_without_relispopulated_for_legacy_catalogs() { let sql = postgres_object_source_sql_inner( "public", "active_users", &ObjectSourceKind::MaterializedView, None, false, false, true, ); assert!(sql.contains("CREATE MATERIALIZED VIEW")); assert!(sql.contains("pg_catalog.pg_get_viewdef(c.oid, 0)")); assert!(sql.contains("pg_catalog.set_config('search_path', '', true)")); assert!(sql.contains("path_guard.applied IS NOT NULL")); assert!(!sql.contains("relispopulated")); } #[test] fn builds_postgres_function_source_sql_without_prokind_for_legacy_catalogs() { let sql = postgres_function_object_source_sql_without_prokind("public", "recalc_score", false); assert_eq!( sql, "SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' AND p.proname = 'recalc_score' AND NOT p.proisagg AND NOT p.proiswindow ORDER BY p.oid LIMIT 1" ); } #[test] fn builds_opengauss_routine_source_sql_from_record_definition() { assert_eq!( opengauss_object_source_sql("public", "recalc_score", &ObjectSourceKind::Function, None), "SELECT (pg_get_functiondef(p.oid)).definition FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' AND p.proname = 'recalc_score' AND p.prokind = 'f' ORDER BY p.oid LIMIT 1" ); assert_eq!( opengauss_object_source_sql( "public", "refresh_cache", &ObjectSourceKind::Procedure, Some("integer"), ), "SELECT (pg_get_functiondef(p.oid)).definition FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' AND p.proname = 'refresh_cache' AND p.prokind = 'p' AND pg_get_function_identity_arguments(p.oid) = 'integer' ORDER BY p.oid LIMIT 1" ); assert_eq!( postgres_function_object_source_sql_without_prokind("public", "recalc_score", true), "SELECT (pg_get_functiondef(p.oid)).definition FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' AND p.proname = 'recalc_score' AND NOT p.proisagg AND NOT p.proiswindow ORDER BY p.oid LIMIT 1" ); } #[test] fn decodes_opengauss_binary_function_definition_record() { let definition = "CREATE OR REPLACE FUNCTION pg_catalog.pg_table_size(regclass)\n RETURNS bigint\n LANGUAGE internal\n STRICT NOT FENCED NOT SHIPPABLE\nAS $function$pg_table_size$function$;\n"; let encoded = concat!( "0x0000000200000017000000040000000400000019000000a8", "435245415445204f52205245504c4143452046554e4354494f4e2070675f636174616c6f672e70675f7461626c655f73697a6528726567636c617373290a", "2052455455524e5320626967696e740a204c414e475541474520696e7465726e616c0a20535452494354204e4f542046454e434544204e4f5420534849505041424c450a", "4153202466756e6374696f6e2470675f7461626c655f73697a652466756e6374696f6e243b0a" ); assert_eq!(decode_opengauss_functiondef_record(encoded).as_deref(), Some(definition)); assert_eq!(normalize_routine_object_source(encoded.to_string()), definition); } #[test] fn preserves_text_and_malformed_opengauss_function_definitions() { let postgres = "CREATE OR REPLACE FUNCTION public.recalc_score() RETURNS integer LANGUAGE sql AS $$ SELECT 1 $$;"; assert_eq!(normalize_routine_object_source(postgres.to_string()), postgres); let ordinary_hex = "0x4352454154452046554e4354494f4e"; assert_eq!(normalize_routine_object_source(ordinary_hex.to_string()), ordinary_hex); let mut truncated = opengauss_functiondef_record_hex(4, postgres.as_bytes()); truncated.truncate(truncated.len() - 2); assert_eq!(normalize_routine_object_source(truncated.clone()), truncated); let non_routine = opengauss_functiondef_record_hex(4, b"SELECT 1"); assert_eq!(normalize_routine_object_source(non_routine.clone()), non_routine); let invalid_utf8 = opengauss_functiondef_record_hex(4, &[0xff, 0xfe]); assert_eq!(normalize_routine_object_source(invalid_utf8.clone()), invalid_utf8); } #[test] fn builds_opengauss_sequence_source_without_pg_sequence_catalog() { let sql = opengauss_object_source_sql("public", "order_id_seq", &ObjectSourceKind::Sequence, None); assert!(sql.contains("information_schema.sequences")); assert!(sql.contains("s.sequence_schema = n.nspname")); assert!(sql.contains("s.sequence_name = c.relname")); assert!(sql.contains("c.relkind IN ('S','L','z','Z')")); assert!(sql.contains("CASE WHEN c.relkind IN ('L','Z') THEN 'large '")); assert!(sql.contains("increment by")); assert!(sql.contains("start with")); assert!(sql.contains("(pg_sequence_last_value(c.oid)).cache_value::text")); assert!(sql.contains("cycle;")); assert!(!sql.contains("pg_catalog.pg_sequence")); let fallback_sql = opengauss_sequence_object_source_sql("public", "order_id_seq", false); assert!(fallback_sql.contains("c.relkind IN ('S','L','z','Z')")); assert!(fallback_sql.contains("CASE WHEN c.relkind IN ('L','Z') THEN 'large '")); assert!(!fallback_sql.contains("pg_sequence_last_value")); assert!(!fallback_sql.contains("cache_value")); } #[test] fn detects_opengauss_sequence_cache_metadata_fallback_errors() { assert!(opengauss_sequence_cache_metadata_error("cannot execute pg_sequence_last_value() on a standby node")); assert!(opengauss_sequence_cache_metadata_error("column notation .cache_value applied to type text")); assert!(!opengauss_sequence_cache_metadata_error("permission denied for sequence order_id_seq")); } #[test] fn composes_opengauss_text_return_and_missing_prokind_fallbacks() { let text_return = opengauss_routine_source_fallback_sqls( "public", "recalc_score", &ObjectSourceKind::Function, None, "column notation .definition applied to type text", ); assert_eq!(text_return.len(), 3); assert_eq!(text_return[0].0, "text-return"); assert!(text_return[0].1.contains("p.prokind = 'f'")); assert_eq!(text_return[2].0, "text-return without prokind"); assert!(!text_return[2].1.contains("p.prokind")); assert!(!text_return[2].1.contains(".definition")); let missing_prokind = opengauss_routine_source_fallback_sqls( "public", "recalc_score", &ObjectSourceKind::Function, None, "column p.prokind does not exist", ); assert_eq!(missing_prokind.len(), 2); assert_eq!(missing_prokind[0].0, "record-return without prokind"); assert_eq!(missing_prokind[1].0, "text-return without prokind"); } #[test] fn keeps_legacy_materialized_viewdef_when_it_already_contains_create_statement() { let sql = postgres_object_source_sql("public", "active_users", &ObjectSourceKind::MaterializedView, None); assert!( sql.contains( "~* '^[[:space:]]*CREATE[[:space:]]+(OR[[:space:]]+REPLACE[[:space:]]+)?MATERIALIZED[[:space:]]+VIEW[[:space:]]+'" ) ); assert!(sql.contains( "THEN pg_catalog.regexp_replace(pg_catalog.pg_get_viewdef(c.oid, 0), ';[[:space:]]*$', '') ELSE pg_catalog.format('CREATE MATERIALIZED VIEW" )); } #[test] fn detects_legacy_postgres_relispopulated_errors() { assert!(postgres_missing_relispopulated_error("ERROR: column c.relispopulated does not exist")); assert!(!postgres_missing_relispopulated_error("ERROR: relation public.relispopulated does not exist")); } #[test] fn detects_legacy_postgres_prokind_errors() { assert!(postgres_missing_prokind_error("ERROR: column p.prokind does not exist")); assert!(postgres_missing_prokind_error("ERROR: column \"p\".\"prokind\" does not exist")); assert!(postgres_missing_prokind_error("错误: 字段 p.prokind 不存在\n提示: 也许您想要引用列 \"p.probin\"。")); assert!(postgres_missing_prokind_error("ERROR: undefined column p.prokind (SQLSTATE 42703)")); assert!(!postgres_missing_prokind_error("ERROR: relation public.prokind does not exist")); assert!(!postgres_missing_prokind_error("错误: 关系 public.prokind 不存在\n提示: 请检查列 p.probin。")); assert!(!postgres_missing_prokind_error("ERROR: permission denied for column p.prokind")); } #[test] fn builds_postgres_view_source_sql_without_regclass_cast() { let sql = postgres_object_source_sql("tenant's schema", "active users", &ObjectSourceKind::View, None); assert!(!sql.contains("::regclass")); assert!(sql.contains("pg_get_viewdef(c.oid, 0)")); assert!(sql.contains("format('CREATE OR REPLACE VIEW %I.%I AS ', n.nspname, c.relname)")); assert!(sql.contains("n.nspname = 'tenant''s schema'")); assert!(sql.contains("c.relname = 'active users'")); assert!(sql.contains("c.relkind IN ('v','m')")); } #[test] fn builds_postgres_view_source_fallback_sql_from_pg_views() { assert_eq!( postgres_view_source_fallback_sql("tenant's schema", "active users"), "WITH dbx_search_path AS (SELECT pg_catalog.set_config('search_path', '', true) AS applied) SELECT v.definition FROM pg_catalog.pg_views v CROSS JOIN dbx_search_path path_guard WHERE v.schemaname = 'tenant''s schema' AND v.viewname = 'active users' AND path_guard.applied IS NOT NULL LIMIT 1" ); assert_eq!( postgres_view_source_fallback_sql_inner("tenant's schema", "active users", false), "SELECT definition FROM pg_catalog.pg_views WHERE schemaname = 'tenant''s schema' AND viewname = 'active users' LIMIT 1" ); } #[test] fn builds_oracle_object_source_sql_using_metadata_api() { assert_eq!( oracle_object_source_sql("HR", "ACTIVE_USERS", &ObjectSourceKind::View), "SELECT DBMS_METADATA.GET_DDL('VIEW', 'ACTIVE_USERS', 'HR') FROM DUAL" ); assert_eq!( oracle_object_source_sql("HR", "PAYROLL", &ObjectSourceKind::PackageBody), "SELECT DBMS_METADATA.GET_DDL('PACKAGE_BODY', 'PAYROLL', 'HR') FROM DUAL" ); assert_eq!( oracle_object_source_sql("", "PAYROLL", &ObjectSourceKind::Package), "SELECT DBMS_METADATA.GET_DDL('PACKAGE', 'PAYROLL') FROM DUAL" ); assert_eq!( oracle_object_source_sql("HR", "ORDER_SEQ", &ObjectSourceKind::Sequence), "SELECT DBMS_METADATA.GET_DDL('SEQUENCE', 'ORDER_SEQ', 'HR') FROM DUAL" ); assert_eq!(oracle_object_source_sql("HR", "NIGHTLY_JOB", &ObjectSourceKind::Job), ""); } #[test] fn builds_oracle_list_objects_sql_with_packages() { let sql = oracle_list_objects_sql("hr"); assert!(sql.contains("'PACKAGE'")); assert!(sql.contains("'PACKAGE BODY'")); assert!(sql.contains("'SEQUENCE'")); assert!(sql.contains("CASE object_type WHEN 'PACKAGE BODY' THEN 'PACKAGE_BODY'")); assert!(sql.contains("owner = 'HR'")); } #[test] fn appends_oracle_table_and_column_comments_to_ddl() { let column = db::ColumnInfo { name: "DISPLAY\"NAME".to_string(), data_type: "VARCHAR2(100)".to_string(), is_nullable: true, column_default: None, is_primary_key: false, extra: None, comment: Some("User's display name".to_string()), numeric_precision: None, numeric_scale: None, character_maximum_length: None, enum_values: None, ..Default::default() }; let mut ignored = column.clone(); ignored.name = "EMPTY_COMMENT".to_string(); ignored.comment = Some(" ".to_string()); let ddl = append_oracle_comments_to_ddl( "CREATE TABLE \"HR\".\"USERS\" (\n \"ID\" NUMBER\n);\n", "HR", "USERS", Some("User table"), &[column, ignored], ); assert!(ddl.contains("CREATE TABLE \"HR\".\"USERS\"")); assert!(ddl.contains("COMMENT ON TABLE \"HR\".\"USERS\" IS 'User table';")); assert!(ddl.contains("COMMENT ON COLUMN \"HR\".\"USERS\".\"DISPLAY\"\"NAME\" IS 'User''s display name';")); assert!(!ddl.contains("EMPTY_COMMENT\" IS")); } #[test] fn appends_oracle_comments_to_view_ddl() { let column = db::ColumnInfo { name: "STATUS".to_string(), data_type: "VARCHAR2(20)".to_string(), is_nullable: true, column_default: None, is_primary_key: false, extra: None, comment: Some("Order status".to_string()), numeric_precision: None, numeric_scale: None, character_maximum_length: None, enum_values: None, ..Default::default() }; let ddl = append_oracle_comments_to_ddl( "CREATE OR REPLACE VIEW \"HR\".\"ACTIVE_ORDERS\" AS\nSELECT \"STATUS\" FROM \"HR\".\"ORDERS\"", "HR", "ACTIVE_ORDERS", Some("Open orders view"), &[column], ); assert!(ddl.contains("CREATE OR REPLACE VIEW \"HR\".\"ACTIVE_ORDERS\" AS")); assert!(ddl.contains("COMMENT ON TABLE \"HR\".\"ACTIVE_ORDERS\" IS 'Open orders view';")); assert!(ddl.contains("COMMENT ON COLUMN \"HR\".\"ACTIVE_ORDERS\".\"STATUS\" IS 'Order status';")); } #[test] fn oracle_view_comment_ddl_preserves_statement_boundaries() { use crate::object_source_sql::{build_view_ddl_sql, BuildViewDdlInput}; use crate::sql::split_sql_statements_for_database; let column = db::ColumnInfo { name: "ID".into(), comment: Some("Column's comment".into()), ..Default::default() }; for (source, terminated) in [ ("SELECT 1 FROM DUAL", "SELECT 1 FROM DUAL;"), ("SELECT 1 FROM DUAL;", "SELECT 1 FROM DUAL;"), ("SELECT 1 FROM DUAL -- tail", "SELECT 1 FROM DUAL -- tail\n;"), ("SELECT 1 FROM DUAL -- tail;\n", "SELECT 1 FROM DUAL -- tail;\n;"), ("SELECT 1 FROM DUAL -- tail /", "SELECT 1 FROM DUAL -- tail /\n;"), ("SELECT 1 FROM DUAL; -- tail;", "SELECT 1 FROM DUAL; -- tail;"), ("SELECT 1 FROM DUAL; /* tail; */", "SELECT 1 FROM DUAL; /* tail; */"), ("SELECT 1 FROM DUAL /* tail; */", "SELECT 1 FROM DUAL /* tail; */;"), ("SELECT '--;' AS \"--ID\" FROM DUAL -- tail", "SELECT '--;' AS \"--ID\" FROM DUAL -- tail\n;"), ] { for database_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle, DatabaseType::Dameng] { for full_source in [false, true] { let prefix = "CREATE VIEW \"HR\".\"ACTIVE_ORDERS\" AS\n"; let ddl = build_view_ddl_sql(BuildViewDdlInput { database_type: Some(database_type), schema: Some("HR".into()), name: "ACTIVE_ORDERS".into(), source: if full_source { format!("{prefix}{source}") } else { source.to_string() }, identifier_quote: None, }); let expected_base = format!("{prefix}{terminated}"); assert_eq!(ddl, expected_base, "{database_type:?}: {source}"); for table_comment in [None, Some("View's comment")] { for columns in [&[][..], std::slice::from_ref(&column)] { let enriched = append_oracle_comments_to_ddl(&ddl, "HR", "ACTIVE_ORDERS", table_comment, columns); let mut expected = expected_base.clone(); if table_comment.is_some() { expected.push_str("\nCOMMENT ON TABLE \"HR\".\"ACTIVE_ORDERS\" IS 'View''s comment';"); } if !columns.is_empty() { expected.push_str( "\nCOMMENT ON COLUMN \"HR\".\"ACTIVE_ORDERS\".\"ID\" IS 'Column''s comment';", ); } assert_eq!(enriched, expected); let statements = split_sql_statements_for_database(&enriched, database_type); assert_eq!( statements.len(), 1 + usize::from(table_comment.is_some()) + columns.len(), "{enriched}" ); assert!(!statements[0].contains("COMMENT ON"), "{enriched}"); } } } } } assert_eq!(append_oracle_comments_to_ddl("", "HR", "EMPTY", Some("ignored"), &[column]), ""); } #[test] fn does_not_duplicate_existing_oracle_comment_ddl() { let column = db::ColumnInfo { name: "DISPLAY_NAME".to_string(), data_type: "VARCHAR2(100)".to_string(), is_nullable: true, column_default: None, is_primary_key: false, extra: None, comment: Some("New column comment".to_string()), numeric_precision: None, numeric_scale: None, character_maximum_length: None, enum_values: None, ..Default::default() }; let ddl = append_oracle_comments_to_ddl( "CREATE TABLE \"HR\".\"USERS\" (\"ID\" NUMBER);\nCOMMENT ON TABLE \"HR\".\"USERS\" IS 'Existing';\nCOMMENT ON COLUMN \"HR\".\"USERS\".\"ID\" IS 'Existing';", "HR", "USERS", Some("New table comment"), &[column], ); assert_eq!(ddl.matches("COMMENT ON TABLE").count(), 1); assert_eq!(ddl.matches("COMMENT ON COLUMN").count(), 1); assert!(!ddl.contains("New table comment")); assert!(!ddl.contains("New column comment")); } } #[cfg(test)] mod ddl_tests { use super::*; fn column(name: &str, data_type: &str) -> db::ColumnInfo { db::ColumnInfo { name: name.to_string(), data_type: data_type.to_string(), is_nullable: true, column_default: None, is_primary_key: false, extra: None, comment: None, numeric_precision: None, numeric_scale: None, character_maximum_length: None, enum_values: None, ..Default::default() } } fn assert_table_ddl_options( options: TableDdlOptions, include_partitions: bool, portable_oracle: bool, include_postgres_access: bool, ) { assert_eq!(options.include_partitions, include_partitions); assert_eq!(options.portable_oracle, portable_oracle); assert_eq!(options.include_postgres_access, include_postgres_access); } #[test] fn table_structure_export_includes_partition_tree() { assert_table_ddl_options(TableDdlOptions::EXPORT, true, true, false); assert_table_ddl_options(TableDdlOptions::RELATION_EXPORT, false, true, false); assert_table_ddl_options(TableDdlOptions::DISPLAY, true, false, true); } #[test] fn postgres_table_ddl_includes_column_comments() { let mut display_name = column("display_name", "text"); display_name.comment = Some("User's display name".to_string()); let columns = vec![display_name]; let ddl = render_postgres_table_ddl("public", "users", &columns, &[], &[], None); assert!(ddl.contains("COMMENT ON COLUMN \"public\".\"users\".\"display_name\" IS 'User''s display name';")); } #[test] fn postgres_table_ddl_includes_table_comment() { let columns = vec![column("id", "integer")]; let ddl = render_postgres_table_ddl("public", "users", &columns, &[], &[], Some("User table")); assert!(ddl.contains("COMMENT ON TABLE \"public\".\"users\" IS 'User table';")); } #[test] fn postgres_display_ddl_preserves_owner_revokes_and_grant_chain() { use db::postgres::{PostgresTableAccessInfo, PostgresTablePrivilegeInfo}; let privilege = |grantor: &str, grantee: &str, privilege_type: &str, is_grantable: bool, column_name: Option<&str>| { PostgresTablePrivilegeInfo { grantor: grantor.to_string(), grantee: grantee.to_string(), privilege_type: privilege_type.to_string(), is_grantable, column_name: column_name.map(str::to_string), } }; let access = PostgresTableAccessInfo { owner: "table\"owner".to_string(), owner_default_privileges: vec!["DELETE", "INSERT", "SELECT", "UPDATE"] .into_iter() .map(str::to_string) .collect(), privileges: vec![ privilege("table\"owner", "table\"owner", "SELECT", false, None), privilege("table\"owner", "z manager", "SELECT", true, None), privilege("table\"owner", "z manager", "SELECT", true, Some("customer_name")), privilege("z manager", "a delegate", "SELECT", true, None), privilege("a delegate", "reader role", "SELECT", false, Some("customer_name")), privilege("z manager", "reader role", "SELECT", false, Some("customer_name")), privilege("table\"owner", "PUBLIC", "INSERT", false, Some("customer_name")), privilege("table\"owner", "PUBLIC", "INSERT", false, Some("amount")), ], }; let ddl = append_postgres_access_ddl( "CREATE TABLE \"app\".\"orders\" (\n \"id\" bigint\n);\n".to_string(), "app", "orders", &access, ); assert!(ddl.contains("ALTER TABLE \"app\".\"orders\" OWNER TO \"table\"\"owner\";")); assert!(ddl.contains("REVOKE DELETE, INSERT, UPDATE ON TABLE \"app\".\"orders\" FROM \"table\"\"owner\";")); assert!(ddl.contains("GRANT INSERT (\"amount\", \"customer_name\") ON TABLE \"app\".\"orders\" TO PUBLIC;")); assert!(ddl.contains("GRANT SELECT (\"customer_name\") ON TABLE \"app\".\"orders\" TO \"reader role\";")); let owner_role = ddl.find("SET ROLE \"table\"\"owner\";").unwrap(); let manager_role = ddl.find("SET ROLE \"z manager\";").unwrap(); let delegate_role = ddl.find("SET ROLE \"a delegate\";").unwrap(); assert!(owner_role < manager_role && manager_role < delegate_role, "ddl: {ddl}"); assert!(ddl[owner_role..manager_role] .contains("GRANT SELECT ON TABLE \"app\".\"orders\" TO \"z manager\" WITH GRANT OPTION;")); assert!(ddl[owner_role..manager_role].contains( "GRANT SELECT (\"customer_name\") ON TABLE \"app\".\"orders\" TO \"z manager\" WITH GRANT OPTION;" )); assert!(ddl[manager_role..delegate_role] .contains("GRANT SELECT ON TABLE \"app\".\"orders\" TO \"a delegate\" WITH GRANT OPTION;")); assert!(ddl[delegate_role..] .contains("GRANT SELECT (\"customer_name\") ON TABLE \"app\".\"orders\" TO \"reader role\";")); } #[test] fn postgres_display_ddl_can_revoke_all_owner_ordinary_privileges() { let access = db::postgres::PostgresTableAccessInfo { owner: "locked_owner".to_string(), owner_default_privileges: vec!["INSERT", "SELECT", "UPDATE"].into_iter().map(str::to_string).collect(), privileges: vec![], }; let ddl = append_postgres_access_ddl( "CREATE TABLE \"app\".\"locked\" (\"id\" bigint);".to_string(), "app", "locked", &access, ); assert!(ddl.contains("SET ROLE \"locked_owner\";")); assert!(ddl.contains("REVOKE INSERT, SELECT, UPDATE ON TABLE \"app\".\"locked\" FROM \"locked_owner\";")); assert!(ddl.ends_with("RESET ROLE;")); } #[test] fn postgres_table_ddl_omits_table_comment_when_empty() { let columns = vec![column("id", "integer")]; let ddl = render_postgres_table_ddl("public", "users", &columns, &[], &[], Some("")); assert!(!ddl.contains("COMMENT ON TABLE")); } #[test] fn postgres_table_ddl_preserves_table_comment_whitespace() { let columns = vec![column("id", "integer")]; let ddl = render_postgres_table_ddl("public", "users", &columns, &[], &[], Some(" User table ")); assert!(ddl.contains("COMMENT ON TABLE \"public\".\"users\" IS ' User table ';")); } #[test] fn postgres_table_ddl_includes_generated_identity() { let mut id = column("id", "integer"); id.is_nullable = false; id.is_primary_key = true; id.extra = Some("generated by default as identity".to_string()); let ddl = render_postgres_table_ddl("public", "users", &[id], &[], &[], None); assert!(ddl.contains("\"id\" integer generated by default as identity NOT NULL"), "ddl: {ddl}"); } #[test] fn postgres_table_ddl_preserves_named_unique_and_primary_constraints() { let mut id = column("id", "bigint"); id.is_nullable = false; id.is_primary_key = true; let indexes = vec![ db::IndexInfo { name: "pk_accounts".to_string(), columns: vec!["id".to_string()], is_unique: true, is_primary: true, filter: None, index_type: Some("btree".to_string()), included_columns: None, comment: None, key_is_expression: Vec::new(), column_opclasses: Vec::new(), key_options: Vec::new(), constraint_backed: true, }, db::IndexInfo { name: "uq_accounts_code".to_string(), columns: vec!["code".to_string()], is_unique: true, is_primary: false, filter: None, index_type: Some("btree".to_string()), included_columns: None, comment: None, key_is_expression: Vec::new(), column_opclasses: Vec::new(), key_options: Vec::new(), constraint_backed: true, }, db::IndexInfo { name: "idx_accounts_display_name".to_string(), columns: vec!["display_name".to_string()], is_unique: true, is_primary: false, filter: None, index_type: Some("btree".to_string()), included_columns: None, comment: None, key_is_expression: Vec::new(), column_opclasses: Vec::new(), key_options: Vec::new(), constraint_backed: false, }, ]; let constraints = vec![ db::ConstraintInfo { name: "pk_accounts".to_string(), constraint_type: "PRIMARY KEY".to_string(), definition: "PRIMARY KEY (id)".to_string(), columns: vec!["id".to_string()], ref_schema: None, ref_table: None, ref_columns: Vec::new(), match_type: None, on_update: None, on_delete: None, deferrable: false, initially_deferred: false, enabled: true, valid: true, }, db::ConstraintInfo { name: "uq_accounts_code".to_string(), constraint_type: "UNIQUE".to_string(), definition: "UNIQUE (code) DEFERRABLE INITIALLY DEFERRED".to_string(), columns: vec!["code".to_string()], ref_schema: None, ref_table: None, ref_columns: Vec::new(), match_type: None, on_update: None, on_delete: None, deferrable: true, initially_deferred: true, enabled: true, valid: true, }, ]; let ddl = render_postgres_table_ddl_with_constraints_and_partition_info( "public", "accounts", &[id], &indexes, &[], &constraints, &[], None, &db::postgres::PostgresTablePartitionInfo::default(), &db::postgres::PostgresTablePartitionLocalObjects::default(), ); assert!(ddl.contains("CONSTRAINT \"pk_accounts\" PRIMARY KEY (id)"), "ddl: {ddl}"); assert!( ddl.contains("CONSTRAINT \"uq_accounts_code\" UNIQUE (code) DEFERRABLE INITIALLY DEFERRED"), "ddl: {ddl}" ); assert!(!ddl.contains("CREATE UNIQUE INDEX \"pk_accounts\""), "ddl: {ddl}"); assert!(!ddl.contains("CREATE UNIQUE INDEX \"uq_accounts_code\""), "ddl: {ddl}"); assert!(ddl.contains("CREATE UNIQUE INDEX \"idx_accounts_display_name\""), "ddl: {ddl}"); } #[test] fn postgres_table_ddl_renders_owned_serial_markers_without_external_defaults() { for (column_name, data_type, serial_type) in [ ("small\"id", "smallint", "smallserial"), ("regular\"id", "integer", "serial"), ("large\"id", "bigint", "bigserial"), ] { let mut id = column(column_name, data_type); id.is_nullable = false; let sequence_name = format!("{column_name}_seq").replace('"', "\"\""); id.column_default = Some(format!("nextval('\"tenant\"\"schema\".\"{sequence_name}\"'::regclass)")); id.extra = Some(serial_type.to_string()); let ddl = render_postgres_table_ddl("tenant\"schema", "order\"items", &[id], &[], &[], None); assert!(ddl.contains(&format!("{} {serial_type} NOT NULL", pg_ident(column_name))), "ddl: {ddl}"); assert!(!ddl.contains("nextval("), "ddl: {ddl}"); assert!(ddl.starts_with("CREATE TABLE \"tenant\"\"schema\".\"order\"\"items\""), "ddl: {ddl}"); } } #[test] fn postgres_table_ddl_preserves_unmarked_nextval_defaults() { let mut id = column("id", "bigint"); id.column_default = Some("nextval('shared.custom_id_source'::regclass)".to_string()); let ddl = render_postgres_table_ddl("public", "orders", &[id], &[], &[], None); assert!(ddl.contains("\"id\" bigint DEFAULT nextval('shared.custom_id_source'::regclass)"), "ddl: {ddl}"); } #[test] fn postgres_table_ddl_keeps_generated_columns_distinct_from_serial_markers() { let mut generated = column("total", "numeric"); generated.column_default = Some("should_not_be_rendered".to_string()); generated.extra = Some("generated always as (price * quantity) stored".to_string()); let ddl = render_postgres_table_ddl("public", "orders", &[generated], &[], &[], None); assert!(ddl.contains("\"total\" numeric generated always as (price * quantity) stored"), "ddl: {ddl}"); assert!(!ddl.contains("should_not_be_rendered"), "ddl: {ddl}"); } #[test] fn postgres_table_ddl_includes_partition_key_for_parent_only() { for partition_key in [ "RANGE (created_at)", "LIST (\"Tenant ID\")", "HASH ((lower(code)))", "RANGE (date_trunc('month'::text, created_at))", ] { let ddl = render_postgres_table_ddl_with_partition_info( "public", "events", &[column("created_at", "timestamp without time zone")], &[], &[], &[], None, &db::postgres::PostgresTablePartitionInfo { key: Some(partition_key.to_string()), ..Default::default() }, &db::postgres::PostgresTablePartitionLocalObjects::default(), ); assert!(ddl.ends_with(&format!(") PARTITION BY {partition_key};\n")), "ddl: {ddl}"); assert!(!ddl.contains("PARTITION OF")); } } #[test] fn postgres_table_ddl_keeps_ordinary_table_unchanged() { let ddl = render_postgres_table_ddl_with_partition_info( "public", "users", &[column("id", "integer")], &[], &[], &[], None, &db::postgres::PostgresTablePartitionInfo::default(), &db::postgres::PostgresTablePartitionLocalObjects::default(), ); assert!(ddl.ends_with(");\n"), "ddl: {ddl}"); assert!(!ddl.contains("PARTITION BY")); } #[test] fn postgres_table_ddl_appends_opclass_to_expression_index_key() { // The per-column `pg_get_indexdef(indexrelid, colno, pretty)` returns only the // bare expression (PostgreSQL sets `attrsOnly = (colno != 0)`, so the opclass // block is skipped — see `ruleutils.c`). The opclass is read separately from // `indclass` into `column_opclasses`, so table DDL must append it to an // expression key just like a real column. let id = column("id", "integer"); let indexes = vec![db::IndexInfo { name: "users_lower_email_trgm_idx".to_string(), columns: vec!["lower(email)".to_string()], is_unique: false, is_primary: false, filter: None, index_type: Some("gin".to_string()), included_columns: None, comment: None, key_is_expression: vec![true], column_opclasses: vec![Some("gin_trgm_ops".to_string())], key_options: Vec::new(), constraint_backed: false, }]; let ddl = render_postgres_table_ddl("public", "users", &[id], &indexes, &[], None); assert!( ddl.contains("USING gin (lower(email) gin_trgm_ops)"), "expected expression key with appended opclass, got: {ddl}" ); } #[test] fn postgres_table_ddl_renders_partition_children_and_subpartitions() { let mut id = column("id", "integer"); id.is_primary_key = true; let indexes = vec![db::IndexInfo { name: "events_payload_idx".to_string(), columns: vec!["payload".to_string()], is_unique: false, is_primary: false, filter: None, index_type: Some("btree".to_string()), included_columns: None, comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }]; let partition_info = db::postgres::PostgresTablePartitionInfo { is_partition: true, parent_schema: Some("public".to_string()), parent_table: Some("events".to_string()), bound: Some("FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')".to_string()), key: Some("HASH (payload)".to_string()), ..Default::default() }; let partition_local_objects = db::postgres::PostgresTablePartitionLocalObjects { has_primary_key: true, foreign_keys: BTreeSet::new(), indexes: BTreeSet::from(["events_payload_idx".to_string()]), ..Default::default() }; let ddl = render_postgres_table_ddl_with_partition_info( "public", "events_2026", &[id, column("payload", "text")], &indexes, &[], &[], None, &partition_info, &partition_local_objects, ); assert!(ddl.starts_with( "CREATE TABLE \"public\".\"events_2026\" PARTITION OF \"public\".\"events\" (\n PRIMARY KEY (\"id\")\n)" )); assert!(ddl.contains("FOR VALUES FROM ('2026-01-01') TO ('2027-01-01') PARTITION BY HASH (payload);")); assert!(ddl.contains("CREATE INDEX \"events_payload_idx\"")); assert!(!ddl.contains("\"payload\" text")); } #[test] fn postgres_partition_ddl_preserves_local_unique_without_local_primary_key() { let indexes = vec![db::IndexInfo { name: "events_2026_code_key".to_string(), columns: vec!["code".to_string()], is_unique: true, is_primary: false, filter: None, index_type: Some("btree".to_string()), included_columns: None, comment: None, key_is_expression: Vec::new(), column_opclasses: Vec::new(), key_options: Vec::new(), constraint_backed: true, }]; let constraints = vec![db::ConstraintInfo { name: "events_2026_code_key".to_string(), constraint_type: "UNIQUE".to_string(), definition: "UNIQUE (code)".to_string(), columns: vec!["code".to_string()], ref_schema: None, ref_table: None, ref_columns: Vec::new(), match_type: None, on_update: None, on_delete: None, deferrable: false, initially_deferred: false, enabled: true, valid: true, }]; let partition_info = db::postgres::PostgresTablePartitionInfo { is_partition: true, parent_schema: Some("public".to_string()), parent_table: Some("events".to_string()), bound: Some("DEFAULT".to_string()), ..Default::default() }; let partition_local_objects = db::postgres::PostgresTablePartitionLocalObjects { unique_constraints: BTreeSet::from(["events_2026_code_key".to_string()]), indexes: BTreeSet::from(["events_2026_code_key".to_string()]), ..Default::default() }; let ddl = render_postgres_table_ddl_with_constraints_and_partition_info( "public", "events_2026", &[column("code", "text")], &indexes, &[], &constraints, &[], None, &partition_info, &partition_local_objects, ); assert!(ddl.contains("CONSTRAINT \"events_2026_code_key\" UNIQUE (code)"), "ddl: {ddl}"); assert!(!ddl.contains("CREATE UNIQUE INDEX"), "ddl: {ddl}"); } #[test] fn postgres_partition_ddl_skips_inherited_constraints_and_indexes() { let mut id = column("id", "integer"); id.is_primary_key = true; let indexes = vec![db::IndexInfo { name: "events_2026_pkey".to_string(), columns: vec!["id".to_string()], is_unique: true, is_primary: true, filter: None, index_type: Some("btree".to_string()), included_columns: None, comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }]; let partition_info = db::postgres::PostgresTablePartitionInfo { is_partition: true, parent_schema: Some("public".to_string()), parent_table: Some("events".to_string()), bound: Some("DEFAULT".to_string()), key: None, ..Default::default() }; let ddl = render_postgres_table_ddl_with_partition_info( "public", "events_default", &[id], &indexes, &[], &[], None, &partition_info, &db::postgres::PostgresTablePartitionLocalObjects::default(), ); assert_eq!(ddl, "CREATE TABLE \"public\".\"events_default\" PARTITION OF \"public\".\"events\" DEFAULT;\n"); } #[test] fn postgres_table_ddl_renders_check_constraints_for_ordinary_tables() { let ddl = render_postgres_table_ddl_with_partition_info( "public", "users", &[column("age", "integer")], &[], &[], &[("users_age_check".to_string(), "CHECK (age >= 0)".to_string())], None, &db::postgres::PostgresTablePartitionInfo::default(), &db::postgres::PostgresTablePartitionLocalObjects::default(), ); assert!(ddl.contains("CONSTRAINT \"users_age_check\" CHECK (age >= 0)"), "ddl: {ddl}"); } #[test] fn postgres_partition_ddl_only_renders_local_check_constraints() { let mut partition_local_objects = db::postgres::PostgresTablePartitionLocalObjects::default(); partition_local_objects.check_constraints.insert("child_only_check".to_string()); let ddl = render_postgres_table_ddl_with_partition_info( "public", "events_2026", &[column("payload", "text")], &[], &[], &[ ("parent_check".to_string(), "CHECK (payload IS NOT NULL)".to_string()), ("child_only_check".to_string(), "CHECK (payload <> '')".to_string()), ], None, &db::postgres::PostgresTablePartitionInfo { is_partition: true, parent_schema: Some("public".to_string()), parent_table: Some("events".to_string()), bound: Some("DEFAULT".to_string()), ..Default::default() }, &partition_local_objects, ); assert!(ddl.contains("CONSTRAINT \"child_only_check\" CHECK (payload <> '')"), "ddl: {ddl}"); assert!(!ddl.contains("parent_check"), "ddl: {ddl}"); } #[test] fn postgres_partition_ddl_overrides_local_column_default() { let mut status = column("status", "text"); status.column_default = Some("'archived'::text".to_string()); let mut partition_local_objects = db::postgres::PostgresTablePartitionLocalObjects::default(); partition_local_objects .column_defaults .insert("status".to_string(), db::postgres::PostgresColumnDefaultState::Overridden); let ddl = render_postgres_table_ddl_with_partition_info( "public", "events_2026", &[status], &[], &[], &[], None, &db::postgres::PostgresTablePartitionInfo { is_partition: true, parent_schema: Some("public".to_string()), parent_table: Some("events".to_string()), bound: Some("DEFAULT".to_string()), ..Default::default() }, &partition_local_objects, ); assert!(ddl.contains("\"status\" WITH OPTIONS DEFAULT 'archived'::text"), "ddl: {ddl}"); // The partition's own column list is otherwise omitted (inherited // from the parent), so a plain (non-override) column declaration // must not appear alongside the WITH OPTIONS clause. assert!(!ddl.contains("\"status\" text"), "ddl: {ddl}"); } #[test] fn postgres_partition_ddl_emits_drop_default_for_locally_dropped_column() { // A partition that ran `ALTER TABLE ONLY child ALTER COLUMN status // DROP DEFAULT` has no default of its own to report here — the // column's `column_default` is `None`, distinct from the // "overridden" case (which has its own, different, Some value). let status = column("status", "text"); let mut partition_local_objects = db::postgres::PostgresTablePartitionLocalObjects::default(); partition_local_objects .column_defaults .insert("status".to_string(), db::postgres::PostgresColumnDefaultState::Dropped); let ddl = render_postgres_table_ddl_with_partition_info( "public", "events_2026", &[status], &[], &[], &[], None, &db::postgres::PostgresTablePartitionInfo { is_partition: true, parent_schema: Some("public".to_string()), parent_table: Some("events".to_string()), bound: Some("DEFAULT".to_string()), ..Default::default() }, &partition_local_objects, ); assert!( ddl.contains("ALTER TABLE ONLY \"public\".\"events_2026\" ALTER COLUMN \"status\" DROP DEFAULT;"), "ddl: {ddl}" ); assert!(!ddl.contains("WITH OPTIONS DEFAULT"), "ddl: {ddl}"); } #[test] fn postgres_table_ddl_renders_foreign_table_with_server_and_options() { let ddl = render_postgres_table_ddl_with_partition_info( "public", "remote_users", &[column("id", "integer")], &[], &[], &[], None, &db::postgres::PostgresTablePartitionInfo { is_foreign: true, foreign_server: Some("loopback".to_string()), foreign_options: vec![("schema_name".to_string(), "public".to_string())], ..Default::default() }, &db::postgres::PostgresTablePartitionLocalObjects::default(), ); assert!(ddl.starts_with("CREATE FOREIGN TABLE \"public\".\"remote_users\""), "ddl: {ddl}"); assert!(ddl.contains("SERVER \"loopback\""), "ddl: {ddl}"); assert!(ddl.contains("OPTIONS (\"schema_name\" 'public')"), "ddl: {ddl}"); } #[test] fn postgres_partition_ddl_uses_foreign_table_syntax_for_foreign_partitions() { let ddl = render_postgres_table_ddl_with_partition_info( "public", "events_remote", &[column("id", "integer")], &[], &[], &[], None, &db::postgres::PostgresTablePartitionInfo { is_partition: true, parent_schema: Some("public".to_string()), parent_table: Some("events".to_string()), bound: Some("FOR VALUES FROM ('2027-01-01') TO ('2028-01-01')".to_string()), is_foreign: true, foreign_server: Some("loopback".to_string()), ..Default::default() }, &db::postgres::PostgresTablePartitionLocalObjects::default(), ); assert!(ddl.starts_with("CREATE FOREIGN TABLE \"public\".\"events_remote\" PARTITION OF"), "ddl: {ddl}"); assert!(ddl.contains("SERVER \"loopback\""), "ddl: {ddl}"); assert!(!ddl.contains("CREATE TABLE \"public\".\"events_remote\""), "ddl: {ddl}"); } #[test] fn postgres_table_ddl_keeps_composite_foreign_key_together() { let columns = vec![column("a", "integer"), column("b", "integer"), column("c", "integer")]; let foreign_keys = vec![ db::ForeignKeyInfo { name: "aaa_1".to_string(), column: "a".to_string(), ref_schema: Some("public".to_string()), ref_table: "aaa_2".to_string(), ref_column: "a".to_string(), on_update: None, on_delete: None, }, db::ForeignKeyInfo { name: "aaa_1".to_string(), column: "b".to_string(), ref_schema: Some("public".to_string()), ref_table: "aaa_2".to_string(), ref_column: "b".to_string(), on_update: None, on_delete: None, }, db::ForeignKeyInfo { name: "aaa_1".to_string(), column: "c".to_string(), ref_schema: Some("public".to_string()), ref_table: "aaa_2".to_string(), ref_column: "c".to_string(), on_update: None, on_delete: None, }, ]; let ddl = render_postgres_table_ddl("public", "aaa_1", &columns, &[], &foreign_keys, None); assert!(ddl.contains( "CONSTRAINT \"aaa_1\" FOREIGN KEY (\"a\", \"b\", \"c\") REFERENCES \"public\".\"aaa_2\"(\"a\", \"b\", \"c\")" )); assert_eq!(ddl.matches("CONSTRAINT \"aaa_1\" FOREIGN KEY").count(), 1); } #[test] fn postgres_table_ddl_appends_trigger_definitions() { let ddl = append_postgres_trigger_definitions( render_postgres_table_ddl("public", "users", &[column("id", "integer")], &[], &[], None), &[r#"CREATE TRIGGER users_set_updated_at BEFORE UPDATE ON "public"."users" FOR EACH ROW EXECUTE FUNCTION set_updated_at()"# .to_string()], ); assert!(ddl.contains("CREATE TABLE \"public\".\"users\"")); assert!(ddl.contains( "\n\nCREATE TRIGGER users_set_updated_at BEFORE UPDATE ON \"public\".\"users\" FOR EACH ROW EXECUTE FUNCTION set_updated_at();" )); } #[test] fn postgres_table_ddl_does_not_duplicate_trigger_statement_terminators() { let ddl = append_postgres_trigger_definitions( render_postgres_table_ddl("public", "users", &[column("id", "integer")], &[], &[], None), &[r#"CREATE TRIGGER users_audit AFTER INSERT ON "public"."users" FOR EACH ROW EXECUTE FUNCTION audit_user();"#.to_string()], ); assert!(!ddl.contains("audit_user();;"), "ddl: {ddl}"); } #[test] fn sqlserver_table_ddl_includes_column_comments() { let mut display_name = column("display]name", "nvarchar(100)"); display_name.comment = Some("User's display name".to_string()); let columns = vec![display_name]; let ddl = render_sqlserver_table_ddl("dbo", "users", &columns, &[], &[], None); assert!(ddl.contains("CREATE TABLE [dbo].[users] (\n [display]]name] nvarchar(100)\n);")); assert!(ddl.contains( "EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'User''s display name', @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'users', @level2type=N'COLUMN', @level2name=N'display]name';" )); } #[test] fn sqlserver_table_ddl_includes_table_comment() { let columns = vec![column("id", "int")]; let ddl = render_sqlserver_table_ddl("dbo", "users", &columns, &[], &[], Some("User table")); assert!(ddl.contains( "EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'User table', @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'users';" )); } #[test] fn sqlserver_table_ddl_omits_table_comment_when_empty() { let columns = vec![column("id", "int")]; let ddl = render_sqlserver_table_ddl("dbo", "users", &columns, &[], &[], Some("")); assert!(!ddl.contains("MS_Description")); } #[test] fn sqlserver_table_ddl_preserves_table_comment_whitespace() { let columns = vec![column("id", "int")]; let ddl = render_sqlserver_table_ddl("dbo", "users", &columns, &[], &[], Some(" User table ")); assert!(ddl.contains("@value=N' User table '")); } #[test] fn sqlserver_table_ddl_includes_identity_clause() { let mut id = column("FIDS", "int"); id.is_nullable = false; id.is_primary_key = true; id.extra = Some("identity(1,1)".to_string()); let ddl = render_sqlserver_table_ddl("dbo", "ZHLSBS", &[id], &[], &[], None); assert!(ddl.contains("[FIDS] int IDENTITY(1,1) NOT NULL"), "ddl: {ddl}"); } #[test] fn opengauss_table_ddl_uses_native_tabledef_function() { assert_eq!( opengauss_table_ddl_sql("tenant's schema", "active users"), "SELECT pg_get_tabledef('\"tenant''s schema\".\"active users\"')" ); } #[test] fn opengauss_table_ddl_appends_trigger_definitions() { let ddl = append_opengauss_trigger_definitions( "CREATE TABLE \"public\".\"users\" (\n \"id\" integer\n);".to_string(), &[r#"CREATE TRIGGER users_bi BEFORE INSERT ON "public"."users" FOR EACH ROW EXECUTE PROCEDURE fill_created_at()"# .to_string()], ); assert!(ddl.contains("CREATE TABLE \"public\".\"users\"")); assert!(ddl.contains( "\n\nCREATE TRIGGER users_bi BEFORE INSERT ON \"public\".\"users\" FOR EACH ROW EXECUTE PROCEDURE fill_created_at();" )); } #[test] fn opengauss_ddl_comment_normalization_escapes_unescaped_quotes() { // openGauss 6.x pg_get_tabledef concatenates the stored comment into // the COMMENT ON literal verbatim; embedded single quotes make the // statement invalid. Everything downstream (transfer table creation, // export, UI display) executes this DDL, so the quotes must be doubled. let ddl = concat!( "SET search_path = public;\n", "CREATE TABLE \"public\".\"dpms_doctor_surgery_record\" (\"del_flag\" char(1));\n", "COMMENT ON COLUMN \"public\".\"dpms_doctor_surgery_record\".\"del_flag\" IS '逻辑删除标志:'0'-未删除,'1'-已删除';" ); assert_eq!( normalize_opengauss_table_ddl_comments(ddl), concat!( "SET search_path = public;\n", "CREATE TABLE \"public\".\"dpms_doctor_surgery_record\" (\"del_flag\" char(1));\n", "COMMENT ON COLUMN \"public\".\"dpms_doctor_surgery_record\".\"del_flag\" IS '逻辑删除标志:''0''-未删除,''1''-已删除';" ) ); } #[test] fn opengauss_ddl_comment_normalization_leaves_valid_ddl_unchanged() { let ddl = concat!( "SET search_path = public;\n", "CREATE TABLE \"public\".\"notes\" (\"body\" text DEFAULT 'O''Hara');\n", "COMMENT ON TABLE \"public\".\"notes\" IS 'owner''s note';\n", "GRANT SELECT ON TABLE \"public\".\"notes\" TO \"auditor's role\";" ); assert_eq!(normalize_opengauss_table_ddl_comments(ddl), ddl); } #[test] fn mysql_display_ddl_gets_statement_terminator() { let ddl = "CREATE TABLE `users` (\n `id` int NOT NULL\n) ENGINE=InnoDB"; assert_eq!( ensure_display_ddl_terminated(ddl.to_string()), "CREATE TABLE `users` (\n `id` int NOT NULL\n) ENGINE=InnoDB;" ); } #[test] fn mysql_display_ddl_does_not_duplicate_existing_terminator() { let ddl = "CREATE TABLE `users` (`id` int);\n"; assert_eq!(ensure_display_ddl_terminated(ddl.to_string()), ddl); } #[test] fn mysql_display_ddl_repairs_double_encoded_comments() { let ddl = "CREATE TABLE `订单` (\n `id` bigint COMMENT '订单ID',\n `reviewed_at` datetime COMMENT '审核时间'\n) COMMENT='订单表'"; assert_eq!( normalize_mysql_display_ddl(ddl.to_string()), "CREATE TABLE `订单` (\n `id` bigint COMMENT '订单ID',\n `reviewed_at` datetime COMMENT '审核时间'\n) COMMENT='订单表';" ); } #[test] fn mysql_display_ddl_preserves_valid_text() { let ddl = "CREATE TABLE `orders` (`id` bigint COMMENT '订单ID') ENGINE=InnoDB"; assert_eq!( normalize_mysql_display_ddl(ddl.to_string()), "CREATE TABLE `orders` (`id` bigint COMMENT '订单ID') ENGINE=InnoDB;" ); } #[test] fn mysql_display_ddl_only_repairs_comment_clauses() { let ddl = "CREATE TABLE `comment` (\n `comment` varchar(64) DEFAULT '订单ID',\n `kind` enum('comment', '审核时间') COMMENT '审核时间'\n) /* COMMENT '订单ID' */"; assert_eq!( normalize_mysql_display_ddl(ddl.to_string()), "CREATE TABLE `comment` (\n `comment` varchar(64) DEFAULT '订单ID',\n `kind` enum('comment', '审核时间') COMMENT '审核时间'\n) /* COMMENT '订单ID' */;" ); } #[test] fn mysql_display_ddl_preserves_unterminated_literals() { let ddl = "CREATE TABLE `orders` (`note` varchar(64) DEFAULT 'unfinished COMMENT '订单ID'"; assert_eq!(normalize_mysql_display_ddl(ddl.to_string()), format!("{ddl};")); } struct FakeMysqlDdlExecutor { outcomes: std::collections::VecDeque>, executed: Vec, } impl MysqlDdlQueryExecutor for FakeMysqlDdlExecutor { async fn execute(&mut self, sql: &str) -> Result { self.executed.push(sql.to_string()); self.outcomes.pop_front().expect("test outcome for DDL query") } } fn mysql_server_error(code: u16, message: &str) -> MysqlDdlQueryError { MysqlDdlQueryError::Query(mysql_async::Error::Server(mysql_async::ServerError { code, message: message.to_string(), state: "HY000".to_string(), })) } #[tokio::test] async fn mysql_ddl_uses_one_qualified_query_on_success() { let mut executor = FakeMysqlDdlExecutor { outcomes: [Ok("CREATE TABLE `users` (`id` int)".to_string())].into(), executed: Vec::new(), }; let ddl = mysql_ddl_with_executor(&mut executor, "app", "users").await.unwrap(); assert_eq!(ddl, "CREATE TABLE `users` (`id` int);"); assert_eq!(executor.executed, ["SHOW CREATE TABLE `app`.`users`"]); } #[tokio::test] async fn mysql_ddl_retries_unqualified_after_no_such_table() { let mut executor = FakeMysqlDdlExecutor { outcomes: [ Err(mysql_server_error(1146, "Table 'retail`fas.account`details' doesn't exist")), Ok("CREATE TABLE `account``details` (`id` int)".to_string()), ] .into(), executed: Vec::new(), }; let ddl = mysql_ddl_with_executor(&mut executor, "retail`fas", "account`details").await.unwrap(); assert_eq!(ddl, "CREATE TABLE `account``details` (`id` int);"); assert_eq!( executor.executed, ["SHOW CREATE TABLE `retail``fas`.`account``details`", "SHOW CREATE TABLE `account``details`",] ); } #[tokio::test] async fn mysql_ddl_preserves_qualified_error_when_fallback_fails() { let first_error = mysql_server_error(1146, "qualified table doesn't exist"); let expected = first_error.to_string(); let mut executor = FakeMysqlDdlExecutor { outcomes: [Err(first_error), Err(mysql_server_error(1146, "unqualified table doesn't exist"))].into(), executed: Vec::new(), }; let error = mysql_ddl_with_executor(&mut executor, "app", "missing").await.unwrap_err(); assert_eq!(error, expected); assert_eq!(executor.executed, ["SHOW CREATE TABLE `app`.`missing`", "SHOW CREATE TABLE `missing`"]); } #[tokio::test] async fn mysql_ddl_does_not_retry_other_server_errors() { let first_error = mysql_server_error(1044, "access denied"); let expected = first_error.to_string(); let mut executor = FakeMysqlDdlExecutor { outcomes: [Err(first_error), Ok("unexpected fallback".to_string())].into(), executed: Vec::new(), }; let error = mysql_ddl_with_executor(&mut executor, "app", "users").await.unwrap_err(); assert_eq!(error, expected); assert_eq!(executor.executed, ["SHOW CREATE TABLE `app`.`users`"]); } #[tokio::test] async fn mysql_ddl_does_not_retry_without_a_database() { let first_error = mysql_server_error(1146, "table doesn't exist"); let expected = first_error.to_string(); let mut executor = FakeMysqlDdlExecutor { outcomes: [Err(first_error), Ok("unexpected fallback".to_string())].into(), executed: Vec::new(), }; let error = mysql_ddl_with_executor(&mut executor, "", "missing").await.unwrap_err(); assert_eq!(error, expected); assert_eq!(executor.executed, ["SHOW CREATE TABLE `missing`"]); } #[tokio::test] async fn mysql_ddl_does_not_retry_result_parsing_errors() { let mut executor = FakeMysqlDdlExecutor { outcomes: [ Err(MysqlDdlQueryError::Result("DDL not found".to_string())), Ok("unexpected fallback".to_string()), ] .into(), executed: Vec::new(), }; let error = mysql_ddl_with_executor(&mut executor, "app", "users").await.unwrap_err(); assert_eq!(error, "DDL not found"); assert_eq!(executor.executed, ["SHOW CREATE TABLE `app`.`users`"]); } } #[derive(Debug)] enum MysqlDdlQueryError { Query(mysql_async::Error), Result(String), } impl MysqlDdlQueryError { fn is_no_such_table(&self) -> bool { matches!(self, Self::Query(mysql_async::Error::Server(error)) if error.code == 1146) } } impl std::fmt::Display for MysqlDdlQueryError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Query(error) => error.fmt(formatter), Self::Result(error) => error.fmt(formatter), } } } trait MysqlDdlQueryExecutor { async fn execute(&mut self, sql: &str) -> Result; } struct MysqlDdlConnection<'a> { conn: &'a mut mysql_async::Conn, } impl MysqlDdlQueryExecutor for MysqlDdlConnection<'_> { async fn execute(&mut self, sql: &str) -> Result { use mysql_async::prelude::*; let result = self.conn.query_iter(sql).await.map_err(MysqlDdlQueryError::Query)?; let rows: Vec = result.collect_and_drop().await.map_err(MysqlDdlQueryError::Query)?; let row = rows.first().ok_or_else(|| MysqlDdlQueryError::Result("DDL not found".to_string()))?; row.get_opt::(1) .and_then(|result| result.ok()) .or_else(|| { row.get_opt::, usize>(1) .and_then(|result| result.ok()) .map(|bytes| String::from_utf8_lossy(&bytes).to_string()) }) .ok_or_else(|| MysqlDdlQueryError::Result("Failed to read DDL".to_string())) } } async fn mysql_ddl_with_executor( executor: &mut impl MysqlDdlQueryExecutor, database: &str, table: &str, ) -> Result { let sql = format!("SHOW CREATE TABLE {}", mysql_qualified_name(database, table)); let qualified_error = match executor.execute(&sql).await { Ok(ddl) => return Ok(normalize_mysql_display_ddl(ddl)), Err(error) => error, }; if database.trim().is_empty() || !qualified_error.is_no_such_table() { return Err(qualified_error.to_string()); } // Mycat 1.x routes by the logical qualifier but forwards it unchanged to a // physical schema; the metadata pool has already selected the logical database. let fallback_sql = format!("SHOW CREATE TABLE {}", mysql_ident(table)); match executor.execute(&fallback_sql).await { Ok(ddl) => Ok(normalize_mysql_display_ddl(ddl)), Err(_) => Err(qualified_error.to_string()), } } pub async fn mysql_ddl(pool: &db::mysql::MySqlPool, database: &str, table: &str) -> Result { // Use the health-checked getter so a stale pooled connection (server closed // it after an idle timeout, NAT/firewall dropped the TCP state, etc.) is // detected and replaced before issuing the query. Without this, the first // DDL request after a period of inactivity could surface a low-level // connection error that a manual refresh would have masked. let mut conn = db::mysql::get_conn_with_health_check(pool).await?; mysql_ddl_with_executor(&mut MysqlDdlConnection { conn: &mut conn }, database, table).await } async fn external_driver_mysql_ddl( session: std::sync::Arc, config: &ConnectionConfig, database: &str, schema: &str, table: &str, ) -> Result { let result: db::QueryResult = session .invoke_with_timeout( "executeQuery", serde_json::json!({ "connection": config, "database": database, "schema": schema, "sql": mysql_external_driver_ddl_sql(database, schema, table), "maxRows": 1 }), agent_metadata_timeout(Some(config)), ) .await?; mysql_external_driver_ddl_from_query_result(result, "Create Table") } async fn external_driver_oracle_ddl( session: std::sync::Arc, config: &ConnectionConfig, database: &str, schema: &str, table: &str, ) -> Result { let result: serde_json::Value = session .invoke_with_timeout( "getObjectSource", serde_json::json!({ "connection": config, "database": database, "schema": schema, "name": table, "object_type": "TABLE", }), agent_metadata_timeout(Some(config)), ) .await?; let ddl = result .get("source") .and_then(serde_json::Value::as_str) .filter(|source| !source.trim().is_empty()) .map(str::to_string) .ok_or_else(|| "JDBC Oracle plugin returned no table DDL".to_string())?; let schema = result .get("schema") .and_then(serde_json::Value::as_str) .filter(|schema| !schema.trim().is_empty()) .unwrap_or(schema); // DBMS_METADATA.GET_DDL omits dictionary comments; append COMMENT ON like the native agent path. let columns = session .invoke_with_timeout::>( "getColumns", serde_json::json!({ "connection": config, "database": database, "schema": schema, "table": table, }), agent_metadata_timeout(Some(config)), ) .await .unwrap_or_else(|error| { log::debug!( "[schema][jdbc-oracle:get_table_ddl:columns-for-comments-failed] schema={} table={} error={}", schema, table, error ); Vec::new() }); let table_comment = match external_driver_oracle_table_comment(session, config, database, schema, table).await { Ok(comment) => comment, Err(error) => { log::debug!( "[schema][jdbc-oracle:get_table_ddl:table-comment-failed] schema={} table={} error={}", schema, table, error ); None } }; Ok(append_oracle_comments_to_ddl(&ddl, schema, table, table_comment.as_deref(), &columns)) } /// External JDBC connections that match no vendor DDL dialect (JDBCX wrappers, /// custom protocol drivers, …) have table DDL rendered by the plugin from /// plain `DatabaseMetaData` via `getObjectSource`, mirroring the agent-side /// `DdlBuilder` output. fn external_driver_uses_generic_ddl(config: &ConnectionConfig) -> bool { config.db_type == DatabaseType::Jdbc && !is_oracle_external_driver_config(config) && !external_driver_uses_mysql_ddl(config) } async fn external_driver_jdbc_ddl( session: std::sync::Arc, config: &ConnectionConfig, database: &str, schema: &str, table: &str, ) -> Result { let result: serde_json::Value = session .invoke_with_timeout( "getObjectSource", serde_json::json!({ "connection": config, "database": database, "schema": schema, "name": table, "object_type": "TABLE", }), agent_metadata_timeout(Some(config)), ) .await?; result .get("source") .and_then(serde_json::Value::as_str) .filter(|source| !source.trim().is_empty()) .map(str::to_string) .ok_or_else(|| "JDBC plugin returned no table DDL".to_string()) } fn normalize_mysql_display_ddl(sql: String) -> String { ensure_display_ddl_terminated(repair_mysql_ddl_comments(&sql)) } fn repair_mysql_ddl_comments(sql: &str) -> String { let mut repaired = String::with_capacity(sql.len()); let mut cursor = 0; while let Some((comment_start, value_start, value_end)) = next_mysql_ddl_comment_literal(sql, cursor) { repaired.push_str(&sql[cursor..comment_start]); repaired.push_str(&sql[comment_start..value_start]); repaired.push_str(&db::mysql::fix_potential_double_encoding(&sql[value_start..value_end])); repaired.push('\''); cursor = value_end + 1; } repaired.push_str(&sql[cursor..]); repaired } fn next_mysql_ddl_comment_literal(sql: &str, from: usize) -> Option<(usize, usize, usize)> { let bytes = sql.as_bytes(); let mut index = from; while index + 7 <= bytes.len() { match bytes[index] { b'\'' | b'"' | b'`' => { index = mysql_quoted_value_end(bytes, index)?; continue; } b'#' => { index = mysql_line_comment_end(bytes, index + 1); continue; } b'-' if bytes.get(index + 1) == Some(&b'-') => { index = mysql_line_comment_end(bytes, index + 2); continue; } b'/' if bytes.get(index + 1) == Some(&b'*') => { index = mysql_block_comment_end(bytes, index + 2)?; continue; } _ => {} } if bytes[index..index + 7].eq_ignore_ascii_case(b"COMMENT") && (index == 0 || !is_mysql_identifier_byte(bytes[index - 1])) && (index + 7 == bytes.len() || !is_mysql_identifier_byte(bytes[index + 7])) { let comment_start = index; let mut quote = index + 7; while quote < bytes.len() && (bytes[quote].is_ascii_whitespace() || bytes[quote] == b'=') { quote += 1; } if bytes.get(quote) == Some(&b'\'') { let value_end = mysql_quoted_value_end(bytes, quote)?.saturating_sub(1); return Some((comment_start, quote + 1, value_end)); } index += 7; continue; } index += 1; } None } fn is_mysql_identifier_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$' } fn mysql_quoted_value_end(bytes: &[u8], quote: usize) -> Option { let delimiter = *bytes.get(quote)?; let mut index = quote + 1; while index < bytes.len() { match bytes[index] { b'\\' => index = (index + 2).min(bytes.len()), value if value == delimiter && bytes.get(index + 1) == Some(&delimiter) => index += 2, value if value == delimiter => return Some(index + 1), _ => index += 1, } } None } fn mysql_line_comment_end(bytes: &[u8], from: usize) -> usize { bytes[from..].iter().position(|byte| *byte == b'\n').map_or(bytes.len(), |offset| from + offset + 1) } fn mysql_block_comment_end(bytes: &[u8], from: usize) -> Option { bytes[from..].windows(2).position(|window| window == b"*/").map(|offset| from + offset + 2) } fn ensure_display_ddl_terminated(sql: String) -> String { let trimmed = sql.trim_end(); // SHOW CREATE TABLE returns a table definition, not a runnable script; DBX // displays/copies it as SQL, so include the default statement terminator. if trimmed.ends_with(';') { sql } else { format!("{trimmed};") } } /// DDL for a single relation. Callers that already iterate a relation set /// themselves (database export, table transfer) must use this rather than /// `pg_ddl_with_partitions` — recursing into partition children here would /// duplicate every partition's `CREATE TABLE` (once from the parent's DDL, /// once from the caller's own loop over that same child relation). pub async fn pg_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -> Result { let (columns, indexes, fkeys, constraints, table_comment, partition_info, trigger_definitions, check_constraints) = tokio::try_join!( db::postgres::get_columns(pool, schema, table), db::postgres::list_indexes(pool, schema, table), db::postgres::list_foreign_keys(pool, schema, table), db::postgres::list_constraints(pool, schema, table), async { db::postgres::get_table_comment(pool, schema, table).await }, db::postgres::get_table_partition_info(pool, schema, table), db::postgres::list_trigger_definitions(pool, schema, table), db::postgres::list_check_constraints(pool, schema, table), )?; let partition_local_objects = if partition_info.is_partition { db::postgres::get_table_partition_local_objects(pool, schema, table).await? } else { db::postgres::PostgresTablePartitionLocalObjects::default() }; Ok(append_postgres_trigger_definitions( render_postgres_table_ddl_with_constraints_and_partition_info( schema, table, &columns, &indexes, &fkeys, &constraints, &check_constraints, table_comment.as_deref(), &partition_info, &partition_local_objects, ), &trigger_definitions, )) } /// Like `pg_ddl`, but for a partitioned table also emits `CREATE TABLE ... /// PARTITION OF` for every existing partition, at any depth. Used by selected /// table structure exports and interactive "view DDL" paths — callers that /// iterate relations themselves must use `pg_ddl` instead (see its doc /// comment). Fetches the whole tree's metadata via a handful of batched, /// tree-wide queries (see `db::postgres::fetch_postgres_partition_tree` and /// its `_for_relations` siblings) instead of recursing per relation, which /// would rerun the full metadata query chain once per node and scale request /// count linearly with the number of partitions. pub async fn pg_ddl_with_partitions( pool: &deadpool_postgres::Pool, schema: &str, table: &str, ) -> Result { let tree = db::postgres::fetch_postgres_partition_tree(pool, schema, table).await?; let tree_oids: HashSet = tree.iter().map(|node| node.oid).collect(); // The requested relation is the tree root: it's the only node whose parent // (if it has one at all — it may itself be a partition of a table outside // this tree) isn't also a node we fetched. let Some(root) = tree.iter().find(|node| !node.parent_oid.is_some_and(|parent_oid| tree_oids.contains(&parent_oid))) else { // The tree query only matches relkind IN ('r','p','f'), so an empty // tree could mean the relation doesn't exist at all, or that it // exists but is a view/sequence/other non-table object. Look up its // relkind (if any) to tell those two cases apart in the error. return Err(match db::postgres::postgres_relation_relkind(pool, schema, table).await { Ok(Some(relkind)) => format!( "relation \"{schema}\".\"{table}\" is a {}, not a table/partition/foreign table", db::postgres::postgres_owner_object_type(&relkind) ), _ => format!("relation \"{schema}\".\"{table}\" was not found or is not a table/partition/foreign table"), }); }; let oids: Vec = tree.iter().map(|node| node.oid).collect(); let relations: Vec<(i64, String, String)> = tree.iter().map(|node| (node.oid, node.schema.clone(), node.table.clone())).collect(); let relation_pairs: Vec<(String, String)> = tree.iter().map(|node| (node.schema.clone(), node.table.clone())).collect(); let ( columns_by_oid, indexes_by_oid, fkeys_by_relation, comments_by_oid, triggers_by_oid, checks_by_oid, local_objects_by_oid, ) = tokio::try_join!( db::postgres::get_columns_for_relations(pool, &relations), db::postgres::list_indexes_for_relations(pool, &relations), db::postgres::list_foreign_keys_for_relations(pool, &relation_pairs), db::postgres::get_table_comments_for_relations(pool, &oids), db::postgres::list_trigger_definitions_for_relations(pool, &oids), db::postgres::list_check_constraints_for_relations(pool, &oids), db::postgres::get_table_partition_local_objects_for_relations(pool, &oids), )?; // `list_foreign_keys_for_relations` is keyed by (schema, table) since its // query is information_schema-based, unlike every other *_by_oid map // here. Remap it once so the render loop below can look fkeys up by oid // like everything else, instead of cloning a fresh (schema, table) key // on every node it renders. let relation_oid_by_pair: HashMap<(String, String), i64> = relations.into_iter().map(|(oid, schema, table)| ((schema, table), oid)).collect(); let fkeys_by_oid: HashMap> = fkeys_by_relation .into_iter() .filter_map(|(key, fkeys)| relation_oid_by_pair.get(&key).map(|oid| (*oid, fkeys))) .collect(); // Group children by parent oid, each group ordered by relname to match // `list_table_partitions`' `ORDER BY c.relname` (today's traversal order). let mut children_by_parent: HashMap> = HashMap::new(); for node in &tree { if let Some(parent_oid) = node.parent_oid { children_by_parent.entry(parent_oid).or_default().push(node); } } for children in children_by_parent.values_mut() { children.sort_by(|a, b| a.table.cmp(&b.table)); } let mut ddl = String::new(); render_postgres_partition_tree_node( root, &children_by_parent, &columns_by_oid, &indexes_by_oid, &fkeys_by_oid, &comments_by_oid, &triggers_by_oid, &checks_by_oid, &local_objects_by_oid, &mut ddl, ); Ok(ddl) } /// Renders `root` and every descendant reachable through `children_by_parent`. /// Iterative (an explicit stack, not function-call recursion) so that a /// pathologically deep partition hierarchy can't overflow the stack; `visited` /// additionally guards against a corrupted catalog (or non-PostgreSQL fork) /// whose `pg_inherits` data forms a cycle, which would otherwise loop forever. #[allow(clippy::too_many_arguments)] fn render_postgres_partition_tree_node( root: &db::postgres::PostgresPartitionTreeNode, children_by_parent: &HashMap>, columns_by_oid: &HashMap>, indexes_by_oid: &HashMap>, fkeys_by_oid: &HashMap>, comments_by_oid: &HashMap>, triggers_by_oid: &HashMap>, checks_by_oid: &HashMap>, local_objects_by_oid: &HashMap, ddl: &mut String, ) { let empty_columns = Vec::new(); let empty_indexes = Vec::new(); let empty_fkeys = Vec::new(); let empty_triggers = Vec::new(); let empty_checks = Vec::new(); let empty_local_objects = db::postgres::PostgresTablePartitionLocalObjects::default(); let mut visited: HashSet = HashSet::new(); let mut stack: Vec<&db::postgres::PostgresPartitionTreeNode> = vec![root]; while let Some(node) = stack.pop() { if !visited.insert(node.oid) { continue; } let columns = columns_by_oid.get(&node.oid).unwrap_or(&empty_columns); let indexes = indexes_by_oid.get(&node.oid).unwrap_or(&empty_indexes); let fkeys = fkeys_by_oid.get(&node.oid).unwrap_or(&empty_fkeys); let comment = comments_by_oid.get(&node.oid).and_then(|comment| comment.as_deref()); let triggers = triggers_by_oid.get(&node.oid).unwrap_or(&empty_triggers); let checks = checks_by_oid.get(&node.oid).unwrap_or(&empty_checks); let local_objects = local_objects_by_oid.get(&node.oid).unwrap_or(&empty_local_objects); if !ddl.is_empty() { ddl.push('\n'); } ddl.push_str(&append_postgres_trigger_definitions( render_postgres_table_ddl_with_partition_info( &node.schema, &node.table, columns, indexes, fkeys, checks, comment, &node.partition_info, local_objects, ), triggers, )); if let Some(children) = children_by_parent.get(&node.oid) { // Push in reverse so children are popped (and rendered) in their // original relname-sorted order. for child in children.iter().rev() { stack.push(child); } } } } async fn pg_display_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -> Result { let (ddl, access) = tokio::join!(pg_ddl_with_partitions(pool, schema, table), db::postgres::get_table_access(pool, schema, table)); let ddl = ddl?; match access { Ok(access) => Ok(append_postgres_access_ddl(ddl, schema, table, &access)), Err(error) => { log::warn!( "[schema][postgres:table-access-ddl-fallback] schema={} table={} error={}", schema, table, error ); Ok(ddl) } } } fn append_postgres_access_ddl( mut ddl: String, schema: &str, table: &str, access: &db::postgres::PostgresTableAccessInfo, ) -> String { let table_name = format!("{}.{}", pg_ident(schema), pg_ident(table)); ddl = ddl.trim_end().to_string(); if !ddl.ends_with(';') { ddl.push(';'); } ddl.push_str(&format!("\n\nALTER TABLE {table_name} OWNER TO {};", pg_ident(&access.owner))); let owner_privileges = access .privileges .iter() .filter(|privilege| { privilege.grantor == access.owner && privilege.grantee == access.owner && privilege.column_name.is_none() }) .map(|privilege| privilege.privilege_type.clone()) .collect::>(); let owner_revokes = access .owner_default_privileges .iter() .filter(|privilege| !owner_privileges.contains(*privilege)) .cloned() .collect::>(); let grants = normalized_postgres_grants(access); let grantor_order = postgres_grantor_order(&access.owner, !owner_revokes.is_empty(), &grants); // PostgreSQL records the active granting role, so each batch must run in that role's context. for grantor in grantor_order { ddl.push_str(&format!("\n\nSET ROLE {};", pg_ident(&grantor))); if grantor == access.owner && !owner_revokes.is_empty() { ddl.push_str(&format!( "\nREVOKE {} ON TABLE {table_name} FROM {};", owner_revokes.iter().cloned().collect::>().join(", "), pg_ident(&access.owner) )); } append_postgres_grants_for_role(&mut ddl, &table_name, &grantor, &grants); ddl.push_str("\nRESET ROLE;"); } ddl } #[derive(Debug, Clone, PartialEq, Eq)] struct PostgresGrant { grantor: String, grantee: String, privilege_type: String, is_grantable: bool, column_name: Option, } fn normalized_postgres_grants(access: &db::postgres::PostgresTableAccessInfo) -> Vec { let mut grants = BTreeMap::<(String, String, String, Option), bool>::new(); for privilege in &access.privileges { if privilege.grantor == access.owner && privilege.grantee == access.owner && privilege.column_name.is_none() { continue; } *grants .entry(( privilege.grantor.clone(), privilege.grantee.clone(), privilege.privilege_type.clone(), privilege.column_name.clone(), )) .or_default() |= privilege.is_grantable; } grants .into_iter() .map(|((grantor, grantee, privilege_type, column_name), is_grantable)| PostgresGrant { grantor, grantee, privilege_type, is_grantable, column_name, }) .collect() } fn postgres_grant_scope_covers(parent: &PostgresGrant, child: &PostgresGrant) -> bool { if parent.privilege_type != child.privilege_type { return false; } match (&parent.column_name, &child.column_name) { (None, _) => true, (Some(parent), Some(child)) => parent == child, (Some(_), None) => false, } } fn postgres_grantor_order(owner: &str, include_owner: bool, grants: &[PostgresGrant]) -> Vec { let mut remaining = grants.iter().map(|grant| grant.grantor.clone()).collect::>(); if include_owner { remaining.insert(owner.to_string()); } let mut dependencies = BTreeMap::>::new(); for child in grants.iter().filter(|grant| grant.grantor != owner) { for parent in grants.iter().filter(|grant| { grant.grantee == child.grantor && grant.is_grantable && grant.grantor != child.grantor && postgres_grant_scope_covers(grant, child) }) { dependencies.entry(child.grantor.clone()).or_default().insert(parent.grantor.clone()); } } let mut order = Vec::with_capacity(remaining.len()); if remaining.remove(owner) { order.push(owner.to_string()); } while !remaining.is_empty() { let ready = remaining .iter() .filter(|grantor| { dependencies.get(*grantor).is_none_or(|required| required.iter().all(|role| !remaining.contains(role))) }) .cloned() .collect::>(); if ready.is_empty() { order.extend(remaining); break; } for grantor in ready { remaining.remove(&grantor); order.push(grantor); } } order } fn append_postgres_grants_for_role(ddl: &mut String, table_name: &str, grantor: &str, grants: &[PostgresGrant]) { let mut table_grants = BTreeMap::<(String, bool), BTreeSet>::new(); let mut column_grants = BTreeMap::<(String, bool), BTreeMap>>::new(); for grant in grants.iter().filter(|grant| grant.grantor == grantor) { if let Some(column) = &grant.column_name { column_grants .entry((grant.grantee.clone(), grant.is_grantable)) .or_default() .entry(grant.privilege_type.clone()) .or_default() .insert(column.clone()); } else { table_grants .entry((grant.grantee.clone(), grant.is_grantable)) .or_default() .insert(grant.privilege_type.clone()); } } for ((grantee, is_grantable), privileges) in table_grants { append_postgres_grant_statement( ddl, table_name, &grantee, is_grantable, privileges.into_iter().collect::>().join(", "), ); } for ((grantee, is_grantable), privileges) in column_grants { let privileges = privileges .into_iter() .map(|(privilege, columns)| { format!( "{} ({})", privilege, columns.into_iter().map(|column| pg_ident(&column)).collect::>().join(", ") ) }) .collect::>() .join(", "); append_postgres_grant_statement(ddl, table_name, &grantee, is_grantable, privileges); } } fn append_postgres_grant_statement( ddl: &mut String, table_name: &str, grantee: &str, is_grantable: bool, privileges: String, ) { let grantee = if grantee == "PUBLIC" { grantee.to_string() } else { pg_ident(grantee) }; let grant_option = if is_grantable { " WITH GRANT OPTION" } else { "" }; ddl.push_str(&format!("\nGRANT {privileges} ON TABLE {table_name} TO {grantee}{grant_option};")); } pub async fn opengauss_table_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -> Result { let (ddl, trigger_definitions) = tokio::try_join!( async { first_string_cell(db::postgres::execute_query(pool, &opengauss_table_ddl_sql(schema, table)).await?) }, db::postgres::list_trigger_definitions(pool, schema, table), )?; // Repair the server's comment literals before anything downstream executes // this DDL: every consumer (transfer table creation, export, UI display) // must receive executable SQL, not the raw pg_get_tabledef output. let ddl = normalize_opengauss_table_ddl_comments(&ddl); Ok(append_opengauss_trigger_definitions(ddl, &trigger_definitions)) } pub fn opengauss_table_ddl_sql(schema: &str, table: &str) -> String { let qualified_name = format!("{}.{}", pg_ident(schema), pg_ident(table)); format!("SELECT pg_get_tabledef({})", sql_string(&qualified_name)) } /// openGauss 6.x `pg_get_tabledef` can concatenate comment text into a /// `COMMENT ON` literal without escaping embedded single quotes. Normalize /// only those generated comment statements; all other DDL text remains /// untouched. pub(crate) fn normalize_opengauss_table_ddl_comments(ddl: &str) -> String { let mut normalized = String::with_capacity(ddl.len()); for line in ddl.split_inclusive('\n') { let (line_body, line_ending) = match line.strip_suffix('\n') { Some(body) => match body.strip_suffix('\r') { Some(body) => (body, "\r\n"), None => (body, "\n"), }, None => (line, ""), }; let leading = line_body.len() - line_body.trim_start_matches(|ch: char| ch.is_ascii_whitespace()).len(); let statement = &line_body[leading..]; if let Some(statement) = normalize_opengauss_comment_statement(statement) { normalized.push_str(&line_body[..leading]); normalized.push_str(&statement); } else { normalized.push_str(line_body); } normalized.push_str(line_ending); } normalized } fn normalize_opengauss_comment_statement(statement: &str) -> Option { let uppercase = statement.to_ascii_uppercase(); if !uppercase.starts_with("COMMENT ON ") { return None; } let is_pos = find_opengauss_comment_is_keyword(statement, &uppercase)?; let value_start = is_pos + " IS ".len(); let value = &statement[value_start..]; let value_leading = value.len() - value.trim_start_matches(|ch: char| ch.is_ascii_whitespace()).len(); let value = &value[value_leading..]; let quote_offset = match value.as_bytes() { [b'\'', ..] => 0, [b'e' | b'E', b'\'', ..] => 1, _ => return None, }; let opening_quote = value_start + value_leading + quote_offset; let statement_end = statement.trim_end().len(); let literal_end = statement[..statement_end] .strip_suffix(';') .map_or(statement_end, |without_terminator| without_terminator.len()); if opening_quote >= literal_end { return None; } let closing_quote = statement[..literal_end].rfind('\'')?; if closing_quote <= opening_quote || !statement[closing_quote + 1..literal_end].trim().is_empty() { return None; } let literal = &statement[opening_quote..=closing_quote]; if opengauss_comment_literal_is_valid(literal) { return Some(statement.to_string()); } let raw_comment = &statement[opening_quote + 1..closing_quote]; let escaped_comment = raw_comment.replace('\'', "''"); let mut normalized = String::with_capacity(statement.len() + escaped_comment.len() - raw_comment.len()); normalized.push_str(&statement[..opening_quote + 1]); normalized.push_str(&escaped_comment); normalized.push_str(&statement[closing_quote..]); Some(normalized) } fn find_opengauss_comment_is_keyword(statement: &str, uppercase: &str) -> Option { let mut cursor = "COMMENT ON ".len(); while cursor + " IS ".len() <= statement.len() { if statement.as_bytes().get(cursor) == Some(&b'"') { cursor = skip_opengauss_quoted_identifier(statement, cursor); continue; } if uppercase.get(cursor..cursor + " IS ".len()) == Some(" IS ") { return Some(cursor); } cursor += statement[cursor..].chars().next()?.len_utf8(); } None } fn skip_opengauss_quoted_identifier(sql: &str, start: usize) -> usize { let bytes = sql.as_bytes(); let mut cursor = start + 1; while cursor < bytes.len() { if bytes[cursor] == b'"' { if bytes.get(cursor + 1) == Some(&b'"') { cursor += 2; } else { return cursor + 1; } } else { cursor += 1; } } bytes.len() } fn opengauss_comment_literal_is_valid(literal: &str) -> bool { let bytes = literal.as_bytes(); if bytes.len() < 2 || bytes.first() != Some(&b'\'') || bytes.last() != Some(&b'\'') { return false; } let mut cursor = 1; while cursor < bytes.len() - 1 { if bytes[cursor] == b'\'' { if cursor + 1 < bytes.len() - 1 && bytes[cursor + 1] == b'\'' { cursor += 2; } else { return false; } } else { cursor += 1; } } true } fn append_postgres_trigger_definitions(mut ddl: String, trigger_definitions: &[String]) -> String { for definition in trigger_definitions.iter().map(|definition| definition.trim()).filter(|definition| !definition.is_empty()) { ddl = ddl.trim_end().to_string(); if !ddl.ends_with(';') { ddl.push(';'); } ddl.push_str("\n\n"); ddl.push_str(definition); if !definition.ends_with(';') { ddl.push(';'); } } ddl } fn append_opengauss_trigger_definitions(mut ddl: String, trigger_definitions: &[String]) -> String { for definition in trigger_definitions.iter().map(|definition| definition.trim()).filter(|definition| !definition.is_empty()) { ddl = ddl.trim_end().to_string(); if !ddl.ends_with(';') { ddl.push(';'); } ddl.push_str("\n\n"); ddl.push_str(definition); if !definition.ends_with(';') { ddl.push(';'); } } ddl } /// Cloudberry's native `pg_get_tabledef` (a community-maintained PL/pgSQL /// function most Cloudberry/Greenplum installs have, not a built-in) renders /// exactly one relation — for a partitioned table it emits that table's own /// `CREATE TABLE ... PARTITION BY ...`, never its partitions' own `CREATE /// TABLE ... PARTITION OF ...` statements, regardless of whether the table /// was created with classic Greenplum or PostgreSQL-style declarative /// partition syntax. When `include_partitions` is requested, fetch the same /// partition tree the plain-Postgres path uses and append each descendant's /// own native DDL, so Cloudberry's more accurate native rendering (storage /// options, distribution policy, external-table clauses) is still used per /// relation instead of falling back to the generic renderer for the whole /// tree. async fn cloudberry_native_tree_ddl( pool: &deadpool_postgres::Pool, schema: &str, table: &str, include_partitions: bool, ) -> Result { let root_ddl = db::cloudberry::table_ddl(pool, schema, table).await?; if !include_partitions { return Ok(root_ddl); } let tree = db::postgres::fetch_postgres_partition_tree(pool, schema, table).await?; if tree.len() <= 1 { return Ok(root_ddl); } let tree_oids: HashSet = tree.iter().map(|node| node.oid).collect(); let Some(root) = tree.iter().find(|node| !node.parent_oid.is_some_and(|parent_oid| tree_oids.contains(&parent_oid))) else { return Ok(root_ddl); }; let mut children_by_parent: HashMap> = HashMap::new(); for node in &tree { if let Some(parent_oid) = node.parent_oid { children_by_parent.entry(parent_oid).or_default().push(node); } } for children in children_by_parent.values_mut() { children.sort_by(|a, b| a.table.cmp(&b.table)); } let mut descendants = Vec::new(); let mut visited: HashSet = HashSet::new(); let mut stack: Vec<&db::postgres::PostgresPartitionTreeNode> = vec![root]; while let Some(node) = stack.pop() { if !visited.insert(node.oid) { continue; } if node.oid != root.oid { descendants.push(node); } if let Some(children) = children_by_parent.get(&node.oid) { for child in children.iter().rev() { stack.push(child); } } } let mut ddl = root_ddl; for node in descendants { let child_ddl = db::cloudberry::table_ddl(pool, &node.schema, &node.table).await?; ddl.push('\n'); ddl.push_str(&child_ddl); } Ok(ddl) } pub async fn cloudberry_ddl( pool: &deadpool_postgres::Pool, schema: &str, table: &str, include_partitions: bool, ) -> Result { match cloudberry_native_tree_ddl(pool, schema, table, include_partitions).await { Ok(ddl) => Ok(ddl), Err(native_error) => { let base_ddl = pg_ddl_for_options(pool, schema, table, include_partitions).await.map_err(|fallback_error| { format!( "Cloudberry pg_get_tabledef failed: {native_error}; PostgreSQL DDL fallback failed: {fallback_error}" ) })?; let modifiers = db::cloudberry::table_modifiers(pool, schema, table).await.map_err(|fallback_error| { format!("Cloudberry pg_get_tabledef failed: {native_error}; modifier fallback failed: {fallback_error}") })?; db::cloudberry::append_table_modifiers(&base_ddl, &modifiers).map_err(|fallback_error| { format!( "Cloudberry pg_get_tabledef failed: {native_error}; DDL rendering fallback failed: {fallback_error}" ) }) } } } pub async fn opentenbase_ddl( pool: &deadpool_postgres::Pool, schema: &str, table: &str, include_partitions: bool, ) -> Result { let ddl = pg_ddl_for_options(pool, schema, table, include_partitions).await?; if include_partitions { // The rendered ddl may cover the whole partition tree (root plus // every partition), each as its own `CREATE [FOREIGN] TABLE` // statement, so distribution policies need to be looked up and // applied per relation rather than once for the root. let relations: Vec<(String, String)> = db::ddl_scan::top_level_statement_ranges(&ddl) .into_iter() .filter_map(|range| db::ddl_scan::parse_create_table_relation(&ddl[range])) .collect(); return match db::opentenbase::table_distribution_for_relations(pool, &relations).await { Ok(distributions) => match db::opentenbase::append_distribution_clauses(&ddl, &distributions) { Ok(ddl) => Ok(ddl), Err(error) => { log::warn!( "[schema][opentenbase:table-ddl-distribution-render-fallback] schema={} table={} error={}", schema, table, error ); Ok(ddl) } }, Err(error) => { log::warn!( "[schema][opentenbase:table-ddl-distribution-query-fallback] schema={} table={} error={}", schema, table, error ); Ok(ddl) } }; } match db::opentenbase::table_distribution(pool, schema, table).await { Ok(Some(distribution)) => match db::opentenbase::append_distribution_clause(&ddl, &distribution) { Ok(ddl) => Ok(ddl), Err(error) => { log::warn!( "[schema][opentenbase:table-ddl-distribution-render-fallback] schema={} table={} error={}", schema, table, error ); Ok(ddl) } }, Ok(None) => Ok(ddl), Err(error) => { log::warn!( "[schema][opentenbase:table-ddl-distribution-query-fallback] schema={} table={} error={}", schema, table, error ); Ok(ddl) } } } pub fn render_postgres_table_ddl( schema: &str, table: &str, columns: &[db::ColumnInfo], indexes: &[db::IndexInfo], fkeys: &[db::ForeignKeyInfo], table_comment: Option<&str>, ) -> String { render_postgres_table_ddl_with_partition_info( schema, table, columns, indexes, fkeys, &[], table_comment, &db::postgres::PostgresTablePartitionInfo::default(), &db::postgres::PostgresTablePartitionLocalObjects::default(), ) } fn render_postgres_table_ddl_with_partition_info( schema: &str, table: &str, columns: &[db::ColumnInfo], indexes: &[db::IndexInfo], fkeys: &[db::ForeignKeyInfo], check_constraints: &[(String, String)], table_comment: Option<&str>, partition_info: &db::postgres::PostgresTablePartitionInfo, partition_local_objects: &db::postgres::PostgresTablePartitionLocalObjects, ) -> String { render_postgres_table_ddl_with_constraints_and_partition_info( schema, table, columns, indexes, fkeys, &[], check_constraints, table_comment, partition_info, partition_local_objects, ) } fn render_postgres_table_ddl_with_constraints_and_partition_info( schema: &str, table: &str, columns: &[db::ColumnInfo], indexes: &[db::IndexInfo], fkeys: &[db::ForeignKeyInfo], constraints: &[db::ConstraintInfo], check_constraints: &[(String, String)], table_comment: Option<&str>, partition_info: &db::postgres::PostgresTablePartitionInfo, partition_local_objects: &db::postgres::PostgresTablePartitionLocalObjects, ) -> String { let table_name = format!("{}.{}", pg_ident(schema), pg_ident(table)); let partition_parent = partition_info .is_partition .then(|| { Some(( partition_info.parent_schema.as_deref()?, partition_info.parent_table.as_deref()?, partition_info.bound.as_deref()?, )) }) .flatten(); let is_partition = partition_parent.is_some(); let mut definition_lines = if is_partition { Vec::new() } else { columns .iter() .map(|c| { let serial_type = match c.extra.as_deref().map(str::trim) { Some("smallserial") => Some("smallserial"), Some("serial") => Some("serial"), Some("bigserial") => Some("bigserial"), _ => None, }; let mut line = format!(" {} {}", pg_ident(&c.name), serial_type.unwrap_or(&c.data_type)); let generated_clause = c .extra .as_deref() .map(str::trim) .filter(|value| !value.is_empty() && value.to_ascii_lowercase().starts_with("generated ")); if let Some(extra) = generated_clause { line.push_str(&format!(" {extra}")); } if !c.is_nullable { line.push_str(" NOT NULL"); } if generated_clause.is_none() && serial_type.is_none() { if let Some(ref def) = c.column_default { line.push_str(&format!(" DEFAULT {def}")); } } line }) .collect::>() }; let primary_constraints = constraints .iter() .filter(|constraint| constraint.constraint_type == "PRIMARY KEY" && !constraint.definition.trim().is_empty()) .collect::>(); let unique_constraints = constraints .iter() .filter(|constraint| constraint.constraint_type == "UNIQUE" && !constraint.definition.trim().is_empty()) .collect::>(); if !is_partition || partition_local_objects.has_primary_key { if primary_constraints.is_empty() { let pks: Vec<&str> = columns.iter().filter(|c| c.is_primary_key).map(|c| c.name.as_str()).collect(); if !pks.is_empty() { definition_lines.push(format!( " PRIMARY KEY ({})", pks.iter().map(|key| pg_ident(key)).collect::>().join(", ") )); } } else { for constraint in &primary_constraints { definition_lines.push(format!( " CONSTRAINT {} {}", pg_ident(&constraint.name), constraint.definition.trim() )); } } } for constraint in unique_constraints { if is_partition && !partition_local_objects.unique_constraints.contains(&constraint.name) { continue; } definition_lines.push(format!(" CONSTRAINT {} {}", pg_ident(&constraint.name), constraint.definition.trim())); } for fk_group in group_foreign_keys_by_name(fkeys) { let Some(first_fk) = fk_group.first() else { continue; }; if is_partition && !partition_local_objects.foreign_keys.contains(&first_fk.name) { continue; } let columns = fk_group.iter().map(|fk| pg_ident(&fk.column)).collect::>().join(", "); let ref_columns = fk_group.iter().map(|fk| pg_ident(&fk.ref_column)).collect::>().join(", "); let referenced_schema = first_fk.ref_schema.as_deref().filter(|value| !value.trim().is_empty()).unwrap_or(schema); let referenced_table = format!("{}.{}", pg_ident(referenced_schema), pg_ident(&first_fk.ref_table)); definition_lines.push(format!( " CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}({})", pg_ident(&first_fk.name), columns, referenced_table, ref_columns )); } // `pg_get_constraintdef` appends ` NOT VALID` for an unvalidated CHECK // constraint, but that suffix is only legal after `ALTER TABLE ADD // CONSTRAINT` — it's a syntax error inside a `CREATE TABLE` column list. // Emit those as a separate statement below instead, so the constraint's // unvalidated state round-trips instead of producing invalid DDL. let mut not_valid_check_constraints: Vec<(&str, &str)> = Vec::new(); for (name, definition) in check_constraints { if is_partition && !partition_local_objects.check_constraints.contains(name) { continue; } let definition = definition.trim(); if is_not_valid_constraintdef(definition) { not_valid_check_constraints.push((name.as_str(), definition)); continue; } definition_lines.push(format!(" CONSTRAINT {} {}", pg_ident(name), definition)); } if is_partition { // A partition can override a column's default independently of the // parent; PostgreSQL only accepts that override through `column_name // WITH OPTIONS DEFAULT ...` since the partition's own column list is // otherwise inherited (and thus omitted) from the parent's. for column in columns { if partition_local_objects.column_defaults.get(&column.name) != Some(&db::postgres::PostgresColumnDefaultState::Overridden) { continue; } let Some(default) = column.column_default.as_deref() else { continue; }; definition_lines.push(format!(" {} WITH OPTIONS DEFAULT {default}", pg_ident(&column.name))); } } let create = if partition_info.is_foreign { "CREATE FOREIGN TABLE" } else { "CREATE TABLE" }; let mut ddl = if let Some((parent_schema, parent_table, bound)) = partition_parent { let parent_name = format!("{}.{}", pg_ident(parent_schema), pg_ident(parent_table)); let definitions = if definition_lines.is_empty() { String::new() } else { format!(" (\n{}\n)", definition_lines.join(",\n")) }; format!("{create} {table_name} PARTITION OF {parent_name}{definitions} {bound}") } else { format!("{create} {table_name} (\n{}\n)", definition_lines.join(",\n")) }; if let Some(server) = partition_info.foreign_server.as_deref().filter(|server| !server.trim().is_empty()) { ddl.push_str(&format!(" SERVER {}", pg_ident(server))); if !partition_info.foreign_options.is_empty() { let options = partition_info .foreign_options .iter() .map(|(key, value)| format!("{} {}", pg_ident(key), sql_string(value))) .collect::>() .join(", "); ddl.push_str(&format!(" OPTIONS ({options})")); } } if let Some(partition_key) = partition_info.key.as_deref().filter(|key| !key.trim().is_empty()) { ddl.push_str(&format!(" PARTITION BY {partition_key}")); } ddl.push_str(";\n"); for (name, definition) in ¬_valid_check_constraints { ddl.push_str(&format!("\nALTER TABLE {table_name} ADD CONSTRAINT {} {};", pg_ident(name), definition)); } if is_partition { // A dropped default has no counterpart in the PARTITION OF column // list syntax used above for overrides — it must be replayed as a // standalone statement, or restore would silently reintroduce the // parent's default (PostgreSQL auto-copies it onto every partition // at creation time unless explicitly dropped). for column in columns { if partition_local_objects.column_defaults.get(&column.name) == Some(&db::postgres::PostgresColumnDefaultState::Dropped) { ddl.push_str(&format!( "\nALTER TABLE ONLY {table_name} ALTER COLUMN {} DROP DEFAULT;", pg_ident(&column.name) )); } } } if let Some(comment) = table_comment.filter(|comment| !comment.trim().is_empty()) { ddl.push_str(&format!("\nCOMMENT ON TABLE {table_name} IS {};", sql_string(comment))); } for col in columns { if let Some(comment) = col.comment.as_deref().filter(|comment| !comment.is_empty()) { ddl.push_str(&format!( "\nCOMMENT ON COLUMN {table_name}.{} IS {};", pg_ident(&col.name), sql_string(comment) )); } } for idx in indexes { if idx.is_primary || idx.constraint_backed { continue; } if is_partition && !partition_local_objects.indexes.contains(&idx.name) { continue; } let unique = if idx.is_unique { "UNIQUE " } else { "" }; let cols = idx .columns .iter() .enumerate() .map(|(i, c)| { // A real column is quoted via `pg_ident`; an expression/functional key part // arrives as raw expression text (the per-column `pg_get_indexdef` omits the // opclass — see `crates/dbx-drivers/src/db/postgres.rs`), so quoting the whole // thing as an identifier would turn it into a nonexistent column reference // (#6295). let is_expr = idx.key_is_expression.get(i).copied().unwrap_or(false); let mut col = if is_expr { c.clone() } else { pg_ident(c) }; // The opclass is read separately from `pg_index.indclass` for every key // position (including expression keys) and appended uniformly — it never // lives inside the expression text, so there is no duplication risk. if let Some(opclass) = idx.column_opclasses.get(i).and_then(|o| o.as_deref()) { col.push_str(&format!(" {opclass}")); } col }) .collect::>() .join(", "); let using = idx.index_type.as_deref().map(|t| format!(" USING {t}")).unwrap_or_default(); let include = idx .included_columns .as_deref() .filter(|c| !c.is_empty()) .map(|cols| format!(" INCLUDE ({})", cols.iter().map(|c| pg_ident(c)).collect::>().join(", "))) .unwrap_or_default(); let filter = idx.filter.as_deref().map(|f| format!(" WHERE {f}")).unwrap_or_default(); ddl.push_str(&format!( "\nCREATE {unique}INDEX {} ON {table_name}{using} ({cols}){include}{filter};", pg_ident(&idx.name) )); if let Some(ref c) = idx.comment { ddl.push_str(&format!( "\nCOMMENT ON INDEX {}.{} IS {};", pg_ident(schema), pg_ident(&idx.name), sql_string(c) )); } } ddl } fn sqlserver_identity_clause(extra: Option<&str>) -> Option { let extra = extra?.trim(); let lower = extra.to_ascii_lowercase(); if !lower.starts_with("identity") { return None; } let rest = extra["identity".len()..].trim_start(); if rest.is_empty() { return Some("IDENTITY".to_string()); } let args = rest.strip_prefix('(')?; let end = args.find(')')?; Some(format!("IDENTITY({})", args[..end].trim())) } fn group_foreign_keys_by_name(fkeys: &[db::ForeignKeyInfo]) -> Vec> { let mut groups: Vec> = Vec::new(); for fk in fkeys { if let Some(group) = groups.iter_mut().find(|group| group.first().is_some_and(|first| first.name == fk.name)) { group.push(fk); } else { groups.push(vec![fk]); } } groups } pub async fn build_sqlserver_ddl( client: &mut db::sqlserver::SqlServerClient, schema: &str, table: &str, ) -> Result { let columns = db::sqlserver::get_columns(client, schema, table).await?; let indexes = db::sqlserver::list_indexes(client, schema, table).await?; let fkeys = db::sqlserver::list_foreign_keys(client, schema, table).await?; let table_comment = db::sqlserver::get_table_comment(client, schema, table).await?; Ok(render_sqlserver_table_ddl(schema, table, &columns, &indexes, &fkeys, table_comment.as_deref())) } pub fn render_sqlserver_table_ddl( schema: &str, table: &str, columns: &[db::ColumnInfo], indexes: &[db::IndexInfo], fkeys: &[db::ForeignKeyInfo], table_comment: Option<&str>, ) -> String { let table_name = format!("{}.{}", sqlserver_ident(schema), sqlserver_ident(table)); let mut ddl = format!("CREATE TABLE {table_name} (\n"); let col_lines: Vec = columns .iter() .map(|c| { let mut line = format!(" {} {}", sqlserver_ident(&c.name), c.data_type); if let Some(identity) = sqlserver_identity_clause(c.extra.as_deref()) { line.push_str(&format!(" {identity}")); } if !c.is_nullable { line.push_str(" NOT NULL"); } if let Some(ref def) = c.column_default { line.push_str(&format!(" DEFAULT {def}")); } line }) .collect(); ddl.push_str(&col_lines.join(",\n")); let pks: Vec<&str> = columns.iter().filter(|c| c.is_primary_key).map(|c| c.name.as_str()).collect(); if !pks.is_empty() { ddl.push_str(&format!( ",\n PRIMARY KEY ({})", pks.iter().map(|k| sqlserver_ident(k)).collect::>().join(", ") )); } for fk in fkeys { ddl.push_str(&format!( ",\n CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}({})", sqlserver_ident(&fk.name), sqlserver_ident(&fk.column), sqlserver_ident(&fk.ref_table), sqlserver_ident(&fk.ref_column) )); } ddl.push_str("\n);\n"); if let Some(comment) = table_comment.filter(|comment| !comment.trim().is_empty()) { ddl.push_str(&format!( "\nEXEC sys.sp_addextendedproperty @name=N'MS_Description', @value={}, @level0type=N'SCHEMA', @level0name={}, @level1type=N'TABLE', @level1name={};", sqlserver_n_string(comment), sqlserver_n_string(schema), sqlserver_n_string(table) )); } for column in columns { if let Some(comment) = column.comment.as_deref().map(str::trim).filter(|comment| !comment.is_empty()) { ddl.push_str(&format!( "\nEXEC sys.sp_addextendedproperty @name=N'MS_Description', @value={}, @level0type=N'SCHEMA', @level0name={}, @level1type=N'TABLE', @level1name={}, @level2type=N'COLUMN', @level2name={};", sqlserver_n_string(comment), sqlserver_n_string(schema), sqlserver_n_string(table), sqlserver_n_string(&column.name) )); } } for idx in indexes { if idx.is_primary { continue; } let unique = if idx.is_unique { "UNIQUE " } else { "" }; let idx_type = idx.index_type.as_deref().map(|t| format!("{t} ")).unwrap_or_default(); let cols = idx.columns.iter().map(|c| sqlserver_ident(c)).collect::>().join(", "); let include = idx .included_columns .as_deref() .filter(|c| !c.is_empty()) .map(|cols| { format!(" INCLUDE ({})", cols.iter().map(|c| sqlserver_ident(c)).collect::>().join(", ")) }) .unwrap_or_default(); let filter = idx.filter.as_deref().map(|f| format!(" WHERE {f}")).unwrap_or_default(); ddl.push_str(&format!( "\nCREATE {unique}{idx_type}INDEX {} ON {table_name} ({cols}){include}{filter};", sqlserver_ident(&idx.name) )); } ddl }