From 4071882cb108ab69a97ba2866bdb375f88b83d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 21 Sep 2026 16:24:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(db):=20PostgreSQL=20=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E7=89=A9=E5=8C=96=E8=A7=86=E5=9B=BE=E7=BA=B3?= =?UTF-8?q?=E5=85=A5=E6=A0=91=E3=80=81=E5=8A=A8=E4=BD=9C=E4=B8=8E=E8=BD=AC?= =?UTF-8?q?=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PG 连接的表目录写死 `c.relkind = 'r'`,外部表('f')和分区表('p')根本列不出来, 物化视图也找不到入口(视图走 `information_schema.views`,它不含物化视图)——与 Navicat 的行为不一致,用户已经建好的外部表在 Navop 里等于不存在。 - 列表:`list_tables` / `list_tables_view` 放开 relkind 到 `'r','p','f'`,按 relkind 映射对象类型('f' → ForeignTable,'p'/'r' → Table),对象列表「类型」列分别显示 Table / Partitioned Table / Foreign Table;新增 `list_materialized_views`(走 `pg_class` relkind='m' + `pg_get_viewdef`),物化视图从普通视图里彻底分离。 - 树:新增 `DbNodeType::{ForeignTable, MaterializedViewsFolder, MaterializedView}`, 外部表与普通表/分区表同处「表」目录(对齐 Navicat),物化视图单独一个目录;目录由 `supports_materialized_views` 能力位控制,MySQL 等其它驱动不受影响。 - 动作:外部表与物化视图按 node_type 各自 scope 出菜单(Open / Rename / Truncate / Drop / Dump / Import / Export 等),删除与重命名按类型分派到 `drop_foreign_table` / `rename_foreign_table` / `drop_materialized_view`——不能把 `DROP TABLE` 打到外部表 上。设计表 / 复制表 / 结构转储等尚无实现的动作不注册,因此不会出现点了没反应的项。 - 结构比较与结构转储:新增 `TableObjectType::ForeignTable`,`is_ddl_comparable()` 只让 普通表参与,外部表自动从结构比较、数据比较、ER 图、整库 DDL 转储中排除(数据转储 仍可用);`resolve_sql_dump_target` 补上新类型的映射,避免 Dump 静默报错。 - schema 限定:`rename_foreign_table` / `drop_materialized_view` 接受 schema 并生成 schema 限定名——执行会话不会切 search_path,非 public schema 下的 DDL 原先会打错 对象(或直接报错)。 验证: - `cargo test -p db --lib` 1305 passed / 0 failed;`cargo test -p db_view --lib` 703 passed / 0 failed / 1 ignored;`cargo check --workspace --all-targets` 无 error/warning;对新增行做 clippy 比对,无新增告警。 - 新增真实库集成测试 `crates/db/tests/real_databases/postgres/foreign_matview.rs` (设置 `ONETCLI_TEST_POSTGRES_PASSWORD` 后才运行):在非 public schema 下真建外部表 (postgres_fdw)、分区表与物化视图,断言列表类型、对象面板类型与树的归属,并真实 执行 rename / drop 外部表和 drop 物化视图,最后校验 `list_views` 不混入物化视图。 `cargo test -p db --test real_postgres` 5 passed;测试自带清理,不在库里留 schema、 外部服务器或扩展。 - 未验证:GUI 端到端(右键菜单实际点击路径)没有真机回归,只覆盖到事件与 SQL 生成层。 --- crates/db/locales/db.yml | 4 + crates/db/src/compare/orchestrator.rs | 3 + crates/db/src/manager.rs | 113 +++- crates/db/src/mysql/plugin.rs | 1 + crates/db/src/plugin.rs | 148 +++++- crates/db/src/plugin_manifest.rs | 3 + crates/db/src/postgresql/plugin.rs | 500 +++++++++++++++++- crates/db/src/types.rs | 26 +- .../postgres/foreign_matview.rs | 386 ++++++++++++++ .../db/tests/real_databases/postgres/mod.rs | 1 + crates/db_view/locales/db_view.yml | 8 + crates/db_view/src/database_objects_tab.rs | 151 +++++- crates/db_view/src/database_view_plugin.rs | 39 +- crates/db_view/src/db_tree_event.rs | 87 ++- crates/db_view/src/db_tree_view.rs | 31 +- .../src/import_export/sql_dump_target.rs | 30 +- .../src/import_export/sql_dump_view.rs | 17 +- crates/db_view/src/sql_editor_view.rs | 4 + 18 files changed, 1489 insertions(+), 63 deletions(-) create mode 100644 crates/db/tests/real_databases/postgres/foreign_matview.rs diff --git a/crates/db/locales/db.yml b/crates/db/locales/db.yml index 9fe62a9c6..8059d97cb 100644 --- a/crates/db/locales/db.yml +++ b/crates/db/locales/db.yml @@ -683,6 +683,10 @@ ObjectView: en: "%{count} view(s)" zh-CN: "%{count} 个视图" zh-HK: "%{count} 個檢視" + materialized_views: + en: "%{count} materialized view(s)" + zh-CN: "%{count} 个物化视图" + zh-HK: "%{count} 個實體化檢視" functions: en: "%{count} function(s)" zh-CN: "%{count} 个函数" diff --git a/crates/db/src/compare/orchestrator.rs b/crates/db/src/compare/orchestrator.rs index 893f36ad3..dd4a5f425 100644 --- a/crates/db/src/compare/orchestrator.rs +++ b/crates/db/src/compare/orchestrator.rs @@ -541,6 +541,9 @@ pub fn table_schema_from_metadata( object_type: match table.object_type { TableObjectType::Table => SchemaObjectType::Table, TableObjectType::View => SchemaObjectType::View, + // 外部表不在结构比较范围内(调用方按 `is_ddl_comparable` 过滤), + // 万一传入也按表处理,避免凭空生成新对象类型。 + TableObjectType::ForeignTable => SchemaObjectType::Table, }, columns: columns .into_iter() diff --git a/crates/db/src/manager.rs b/crates/db/src/manager.rs index 94585c198..c60c5c603 100644 --- a/crates/db/src/manager.rs +++ b/crates/db/src/manager.rs @@ -20,7 +20,7 @@ use crate::runtime_contract::require_tokio_runtime; use crate::sqlite::SqlitePlugin; use crate::{ DbNode, DbNodeType, ExecOptions, SqlErrorInfo, SqlResult, SqlSource, TableDesign, - TableSaveResponse, + TableObjectType, TableSaveResponse, }; use dashmap::DashMap; use gpui::{AppContext, AsyncApp, Global, Task}; @@ -1431,12 +1431,40 @@ impl GlobalDbState { database: String, schema: Option, table_name: String, + ) -> anyhow::Result { + self.drop_table_like( + cx, + config_id, + database, + schema, + table_name, + TableObjectType::Table, + ) + .await + } + + /// 删除「表」目录下的对象。 + /// + /// 外部表必须使用 `DROP FOREIGN TABLE`,否则 PostgreSQL 会拒绝执行。 + pub async fn drop_table_like( + &self, + cx: &mut AsyncApp, + config_id: String, + database: String, + schema: Option, + table_name: String, + object_type: TableObjectType, ) -> anyhow::Result { let mut config = self .get_config(&config_id) .ok_or_else(|| anyhow::anyhow!("Connection not found: {}", config_id))?; let plugin = self.get_plugin(&config.database_type)?; - let sql = plugin.drop_table(&database, schema.as_deref(), &table_name); + let sql = match object_type { + TableObjectType::ForeignTable => { + plugin.drop_foreign_table(&database, schema.as_deref(), &table_name) + } + _ => plugin.drop_table(&database, schema.as_deref(), &table_name), + }; // For non-Oracle databases, modify config.database to switch database if config.database_type != DatabaseType::Oracle { @@ -1497,12 +1525,64 @@ impl GlobalDbState { database: String, old_name: String, new_name: String, + ) -> anyhow::Result { + self.rename_table_like( + cx, + config_id, + database, + None, + old_name, + new_name, + TableObjectType::Table, + ) + .await + } + + /// 重命名「表」目录下的对象(外部表需 `ALTER FOREIGN TABLE`)。 + pub async fn rename_table_like( + &self, + cx: &mut AsyncApp, + config_id: String, + database: String, + schema: Option, + old_name: String, + new_name: String, + object_type: TableObjectType, ) -> anyhow::Result { let mut config = self .get_config(&config_id) .ok_or_else(|| anyhow::anyhow!("Connection not found: {}", config_id))?; let plugin = self.get_plugin(&config.database_type)?; - let sql = plugin.rename_table(&database, &old_name, &new_name); + let sql = match object_type { + TableObjectType::ForeignTable => { + plugin.rename_foreign_table(&database, schema.as_deref(), &old_name, &new_name) + } + _ => plugin.rename_table(&database, &old_name, &new_name), + }; + + if config.database_type != DatabaseType::Oracle { + config.database = Some(database); + } + + let result = self.execute_with_session(cx, config, sql, None).await?; + + Self::wrapper_operation_result(result) + } + + /// 删除物化视图(`DROP MATERIALIZED VIEW`)。 + pub async fn drop_materialized_view( + &self, + cx: &mut AsyncApp, + config_id: String, + database: String, + schema: Option, + view_name: String, + ) -> anyhow::Result { + let mut config = self + .get_config(&config_id) + .ok_or_else(|| anyhow::anyhow!("Connection not found: {}", config_id))?; + let plugin = self.get_plugin(&config.database_type)?; + let sql = plugin.drop_materialized_view(&database, schema.as_deref(), &view_name); if config.database_type != DatabaseType::Oracle { config.database = Some(database); @@ -2868,6 +2948,21 @@ impl GlobalDbState { }) } + /// List materialized views(仅 PostgreSQL 等支持物化视图的数据库) + pub async fn list_materialized_views_view( + &self, + cx: &mut AsyncApp, + connection_id: String, + database: String, + schema: Option, + ) -> anyhow::Result { + with_plugin_session_db!(self, cx, connection_id, database.clone(), |plugin, conn| { + plugin + .list_materialized_views_view(&*conn, &database, schema) + .await + }) + } + /// List functions view pub async fn list_functions_view( &self, @@ -3124,11 +3219,17 @@ impl GlobalDbState { .list_tables_view(&*conn, &database, schema) .await .ok(), - DbNodeType::Table | DbNodeType::ColumnsFolder => plugin - .list_columns_view(&*conn, &database, schema, &table) + DbNodeType::Table | DbNodeType::ForeignTable | DbNodeType::ColumnsFolder => { + plugin + .list_columns_view(&*conn, &database, schema, &table) + .await + .ok() + } + DbNodeType::ViewsFolder => plugin.list_views_view(&*conn, &database).await.ok(), + DbNodeType::MaterializedViewsFolder => plugin + .list_materialized_views_view(&*conn, &database, schema) .await .ok(), - DbNodeType::ViewsFolder => plugin.list_views_view(&*conn, &database).await.ok(), DbNodeType::FunctionsFolder => plugin .list_functions_view_in_schema(&*conn, &database, schema) .await diff --git a/crates/db/src/mysql/plugin.rs b/crates/db/src/mysql/plugin.rs index 861d24958..e896e50fd 100644 --- a/crates/db/src/mysql/plugin.rs +++ b/crates/db/src/mysql/plugin.rs @@ -244,6 +244,7 @@ fn build_mysql_ui_manifest() -> DatabaseUiManifest { supports_schema: false, uses_schema_as_database: false, supports_views: true, + supports_materialized_views: false, supports_indexes: true, supports_users: true, supports_user_create: true, diff --git a/crates/db/src/plugin.rs b/crates/db/src/plugin.rs index 839775301..ca53a7a9c 100644 --- a/crates/db/src/plugin.rs +++ b/crates/db/src/plugin.rs @@ -291,6 +291,58 @@ impl SqlCompletionInfo { } } +fn table_like_node_type(object_type: TableObjectType) -> DbNodeType { + match object_type { + TableObjectType::ForeignTable => DbNodeType::ForeignTable, + _ => DbNodeType::Table, + } +} + +/// 构建「物化视图」目录节点及其子节点。 +/// +/// `folder_id` 同时作为子节点 id 前缀,与其他目录(Views/Functions)保持一致。 +fn build_materialized_views_folder( + node: &DbNode, + base_metadata: &HashMap, + views: Vec, + folder_id: String, +) -> DbNode { + let mut folder = DbNode::new( + folder_id.clone(), + "DbTree.MaterializedViews".to_string(), + DbNodeType::MaterializedViewsFolder, + node.connection_id.clone(), + node.database_type.clone(), + ) + .with_parent_context(node.id.clone()) + .with_metadata(base_metadata.clone()); + + if !views.is_empty() { + let mut children: Vec = views + .into_iter() + .map(|view| { + let mut metadata = base_metadata.clone(); + if let Some(comment) = view.comment.filter(|comment| !comment.is_empty()) { + metadata.insert("comment".to_string(), comment); + } + DbNode::new( + format!("{}:{}", folder_id, view.name), + view.name.clone(), + DbNodeType::MaterializedView, + node.connection_id.clone(), + node.database_type.clone(), + ) + .with_parent_context(folder_id.clone()) + .with_metadata(metadata) + }) + .collect(); + children.sort(); + folder.set_children(children); + } + + folder +} + fn routine_node( routine: FunctionInfo, node_type: DbNodeType, @@ -685,6 +737,30 @@ pub trait DatabasePlugin: Send + Sync { database: &str, ) -> Result; + // === Materialized View Operations === + /// 列举物化视图。 + /// + /// 默认返回空集:只有支持物化视图的数据库(如 PostgreSQL)才需要重写, + /// 并需同时打开 `DatabaseUiCapabilities::supports_materialized_views`。 + async fn list_materialized_views( + &self, + _connection: &dyn DbConnection, + _database: &str, + _schema: Option, + ) -> Result> { + Ok(Vec::new()) + } + + /// 物化视图对象列表视图(表格形式)。 + async fn list_materialized_views_view( + &self, + _connection: &dyn DbConnection, + _database: &str, + _schema: Option, + ) -> Result { + Ok(ObjectView::default()) + } + // === Function Operations === async fn list_functions( @@ -1104,7 +1180,7 @@ pub trait DatabasePlugin: Send + Sync { DbNode::new( format!("{}:table_folder:{}", id, table_info.name), table_info.name.clone(), - DbNodeType::Table, + table_like_node_type(table_info.object_type), node.connection_id.clone(), node.database_type.clone(), ) @@ -1162,6 +1238,20 @@ pub trait DatabasePlugin: Send + Sync { nodes.push(views_folder); } + // Materialized views folder(仅支持物化视图的数据库,如 PostgreSQL) + if capabilities.supports_materialized_views { + let matviews = self + .list_materialized_views(connection, database, schema.clone()) + .await + .unwrap_or_default(); + nodes.push(build_materialized_views_folder( + node, + &metadata, + matviews, + format!("{}:matviews_folder", id), + )); + } + // Functions folder if capabilities.supports_functions { let functions = self @@ -1341,6 +1431,7 @@ pub trait DatabasePlugin: Send + Sync { } DbNodeType::TablesFolder | DbNodeType::ViewsFolder + | DbNodeType::MaterializedViewsFolder | DbNodeType::FunctionsFolder | DbNodeType::ProceduresFolder | DbNodeType::SequencesFolder => { @@ -1393,7 +1484,35 @@ pub trait DatabasePlugin: Send + Sync { DbNode::new( format!("{}:{}", id, t.name), t.name.clone(), - DbNodeType::Table, + table_like_node_type(t.object_type), + node.connection_id.clone(), + node.database_type.clone(), + ) + .with_parent_context(id) + .with_metadata(meta) + }) + .collect(); + children.sort(); + Ok(children) + } + DbNodeType::MaterializedViewsFolder => { + if !self.capabilities().supports_materialized_views { + return Ok(Vec::new()); + } + let views = self + .list_materialized_views(connection, database, schema) + .await?; + let mut children: Vec = views + .into_iter() + .map(|v| { + let mut meta = node.metadata.clone(); + if let Some(comment) = v.comment.filter(|comment| !comment.is_empty()) { + meta.insert("comment".to_string(), comment); + } + DbNode::new( + format!("{}:{}", id, v.name), + v.name.clone(), + DbNodeType::MaterializedView, node.connection_id.clone(), node.database_type.clone(), ) @@ -2826,6 +2945,31 @@ pub trait DatabasePlugin: Send + Sync { /// Rename table fn rename_table(&self, database: &str, old_name: &str, new_name: &str) -> String; + /// 删除外部表(`DROP FOREIGN TABLE`)。 + /// + /// 默认退化为 `drop_table`:没有外部表概念的数据库不会走到这个分支。 + fn drop_foreign_table(&self, database: &str, schema: Option<&str>, table: &str) -> String { + self.drop_table(database, schema, table) + } + + /// 重命名外部表(`ALTER FOREIGN TABLE ... RENAME TO ...`)。 + /// + /// `schema` 非空时限定旧表名,避免依赖会话 `search_path` 而改错 schema 下的同名表。 + fn rename_foreign_table( + &self, + database: &str, + _schema: Option<&str>, + old_name: &str, + new_name: &str, + ) -> String { + self.rename_table(database, old_name, new_name) + } + + /// 删除物化视图(`DROP MATERIALIZED VIEW`)。 + fn drop_materialized_view(&self, database: &str, _schema: Option<&str>, view: &str) -> String { + self.drop_view(database, view) + } + /// Build native backup-table SQL. /// 默认实现使用 `CREATE TABLE ... AS SELECT ...`,数据库插件可按方言覆盖。 fn build_backup_table_sql( diff --git a/crates/db/src/plugin_manifest.rs b/crates/db/src/plugin_manifest.rs index 5aeff0a5a..dd3069e80 100644 --- a/crates/db/src/plugin_manifest.rs +++ b/crates/db/src/plugin_manifest.rs @@ -31,6 +31,8 @@ pub struct DatabaseUiCapabilities { pub supports_schema: bool, pub uses_schema_as_database: bool, pub supports_views: bool, + /// 是否支持物化视图(PostgreSQL 等)。默认关闭,避免在树中凭空出现空目录。 + pub supports_materialized_views: bool, pub supports_indexes: bool, pub supports_users: bool, pub supports_user_create: bool, @@ -59,6 +61,7 @@ impl Default for DatabaseUiCapabilities { supports_schema: false, uses_schema_as_database: false, supports_views: true, + supports_materialized_views: false, supports_indexes: true, supports_users: false, supports_user_create: false, diff --git a/crates/db/src/postgresql/plugin.rs b/crates/db/src/postgresql/plugin.rs index 3463af49f..6f6d7918a 100644 --- a/crates/db/src/postgresql/plugin.rs +++ b/crates/db/src/postgresql/plugin.rs @@ -91,6 +91,20 @@ impl PostgresPlugin { Self } + /// `schema.table` 引用;无 schema 时仅限定表名。 + /// + /// 固有方法(非 trait 方法),供本插件构造外部表等 SQL 时复用。 + fn qualify_table_reference(&self, schema: Option<&str>, table: &str) -> String { + match schema { + Some(schema) => format!( + "{}.{}", + self.quote_identifier(schema), + self.quote_identifier(table) + ), + None => self.quote_identifier(table), + } + } + fn comment_literal(comment: &str) -> String { if comment.is_empty() { "NULL".to_string() @@ -322,6 +336,7 @@ fn build_postgresql_ui_manifest() -> DatabaseUiManifest { supports_table_charset: true, supports_table_collation: true, supports_tablespace: true, + supports_materialized_views: true, ..DatabaseUiCapabilities::default() }, forms, @@ -344,6 +359,31 @@ fn postgres_metadata_row_value(row: &[Option], index: usize) -> String { .unwrap_or_default() } +/// 「表」目录覆盖的 `pg_class.relkind`:普通表 / 分区表 / 外部表。 +const POSTGRES_TABLE_RELKINDS: &str = "'r', 'p', 'f'"; + +/// 把 `pg_class.relkind` 映射为表对象类型。 +/// +/// 分区表按普通表处理(`DROP/ALTER TABLE` 均适用);外部表必须单独区分, +/// 否则会生成适配普通表的 `DROP/ALTER TABLE` DDL 并报错。 +fn postgres_table_object_type(relkind: Option<&str>) -> TableObjectType { + match relkind { + Some("f") => TableObjectType::ForeignTable, + _ => TableObjectType::Table, + } +} + +/// 对象列表「类型」列的展示文案。 +fn postgres_relation_type_label(relkind: Option<&str>) -> &'static str { + match relkind { + Some("v") => "View", + Some("m") => "Materialized View", + Some("p") => "Partitioned Table", + Some("f") => "Foreign Table", + _ => "Table", + } +} + fn parse_postgres_foreign_keys(rows: Vec>>) -> Vec { let mut foreign_keys = Vec::new(); let mut positions = HashMap::new(); @@ -1018,6 +1058,111 @@ fn postgresql_action_manifest() -> DatabaseActionManifest { vec![DbNodeType::Table], DatabaseActionPlacement::ContextMenu, ), + // === 外部表 === + // 外部表与普通表并列在「表」目录,动作 id 复用 Table/View 系列, + // 由 db_tree_event 按节点类型改走 DROP/ALTER FOREIGN TABLE; + // 设计表、复制表、结构转储对 PostgreSQL 外部表均不适用。 + action_with_scope( + DatabaseActionId::OpenTableData, + "Table.view_data", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::Both, + true, + Some(DatabaseActionToolbarScope::SelectedRow), + ), + action_with_scope( + DatabaseActionId::OpenTableData, + "Table.view_data", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::Toolbar, + true, + Some(DatabaseActionToolbarScope::CurrentNode), + ), + action( + DatabaseActionId::RenameTable, + "Table.rename_table", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::ContextMenu, + ), + action( + DatabaseActionId::TruncateTable, + "Table.truncate_table", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::ContextMenu, + ), + action_with_scope( + DatabaseActionId::DeleteTable, + "Table.delete_table", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::Both, + true, + Some(DatabaseActionToolbarScope::SelectedRow), + ), + action_with_scope( + DatabaseActionId::DeleteTable, + "Table.delete_table", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::Toolbar, + true, + Some(DatabaseActionToolbarScope::CurrentNode), + ), + action( + DatabaseActionId::DumpSqlData, + "ImportExport.export_data", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::ContextMenu, + ), + action( + DatabaseActionId::ImportData, + "ImportExport.import_data", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::ContextMenu, + ), + action( + DatabaseActionId::ExportData, + "ImportExport.export_table", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::ContextMenu, + ), + // === 物化视图 === + action_with_scope( + DatabaseActionId::OpenViewData, + "View.view_data", + vec![DbNodeType::MaterializedView], + DatabaseActionPlacement::Both, + true, + Some(DatabaseActionToolbarScope::SelectedRow), + ), + action_with_scope( + DatabaseActionId::OpenViewData, + "View.view_data", + vec![DbNodeType::MaterializedView], + DatabaseActionPlacement::Toolbar, + true, + Some(DatabaseActionToolbarScope::CurrentNode), + ), + action_with_scope( + DatabaseActionId::DeleteView, + "View.delete_view", + vec![DbNodeType::MaterializedView], + DatabaseActionPlacement::Both, + true, + Some(DatabaseActionToolbarScope::SelectedRow), + ), + action_with_scope( + DatabaseActionId::DeleteView, + "View.delete_view", + vec![DbNodeType::MaterializedView], + DatabaseActionPlacement::Toolbar, + true, + Some(DatabaseActionToolbarScope::CurrentNode), + ), + action( + DatabaseActionId::DumpSqlData, + "ImportExport.export_data", + vec![DbNodeType::MaterializedView], + DatabaseActionPlacement::ContextMenu, + ), action_with_scope( DatabaseActionId::OpenViewData, "View.view_data", @@ -1139,6 +1284,7 @@ impl DatabasePlugin for PostgresPlugin { supports_table_charset: true, supports_table_collation: true, supports_tablespace: true, + supports_materialized_views: true, ..DatabaseUiCapabilities::default() } } @@ -1582,15 +1728,19 @@ impl DatabasePlugin for PostgresPlugin { schema: Option, ) -> Result> { let schema_val = schema.unwrap_or_else(|| "public".to_string()); + // 普通表 r / 分区表 p / 外部表 f 都属于「表」目录;视图与物化视图另有目录。 let sql = format!( "SELECT c.relname AS tablename, n.nspname AS schemaname, + c.relkind AS relation_kind, obj_description(c.oid, 'pg_class') AS table_comment FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE n.nspname = '{}' AND c.relkind = 'r'", - schema_val.replace("'", "''") + WHERE n.nspname = '{}' AND c.relkind IN ({}) + ORDER BY c.relname", + schema_val.replace("'", "''"), + POSTGRES_TABLE_RELKINDS ); let result = connection @@ -1609,7 +1759,15 @@ impl DatabasePlugin for PostgresPlugin { "utf8mb4", )? .unwrap_or_default(), - object_type: TableObjectType::Table, + object_type: postgres_table_object_type( + crate::metadata_read::metadata_text( + &query_result, + row_index, + 2, + "utf8mb4", + )? + .as_deref(), + ), schema: crate::metadata_read::metadata_text( &query_result, row_index, @@ -1619,7 +1777,7 @@ impl DatabasePlugin for PostgresPlugin { comment: crate::metadata_read::metadata_text( &query_result, row_index, - 2, + 3, "utf8mb4", )? .filter(|s| !s.is_empty()), @@ -1680,9 +1838,9 @@ impl DatabasePlugin for PostgresPlugin { JOIN pg_namespace n ON c.relnamespace = n.oid LEFT JOIN pg_tablespace ts ON c.reltablespace = ts.oid WHERE n.nspname = '{}' - AND c.relkind = 'r' + AND c.relkind IN ({}) ORDER BY c.relname", - safe_schema + safe_schema, POSTGRES_TABLE_RELKINDS ); let result = connection @@ -1710,12 +1868,7 @@ impl DatabasePlugin for PostgresPlugin { .unwrap_or_else(|| "-".to_string()); // 类型转换 - let object_type = match cell(2)?.as_deref() { - Some("v") => "View", - Some("m") => "Materialized View", - Some("p") => "Partitioned Table", - _ => "Table", - }; + let object_type = postgres_relation_type_label(cell(2)?.as_deref()); object_view_rows.push(vec![ cell(0)?.unwrap_or_default(), // Name (index 0) @@ -2169,6 +2322,88 @@ impl DatabasePlugin for PostgresPlugin { }) } + async fn list_materialized_views( + &self, + connection: &dyn DbConnection, + _database: &str, + schema: Option, + ) -> Result> { + let schema_val = schema.unwrap_or_else(|| "public".to_string()); + // information_schema 不包含物化视图,必须回退到 pg_class(relkind = 'm')。 + let sql = format!( + "SELECT c.relname AS matview_name, \ + n.nspname AS schemaname, \ + pg_get_viewdef(c.oid, true) AS view_definition, \ + obj_description(c.oid, 'pg_class') AS view_comment \ + FROM pg_class c \ + JOIN pg_namespace n ON c.relnamespace = n.oid \ + WHERE n.nspname = '{}' AND c.relkind = 'm' \ + ORDER BY c.relname", + schema_val.replace("'", "''") + ); + + let result = connection + .query(&sql) + .await + .map_err(|e| anyhow::anyhow!("Failed to list materialized views: {}", e))?; + + if let SqlResult::Query(query_result) = result { + let mut views = Vec::new(); + for row_index in 0..query_result.rows.len() { + let cell = |column_index| { + crate::metadata_read::metadata_text( + &query_result, + row_index, + column_index, + "utf8mb4", + ) + }; + views.push(ViewInfo { + name: cell(0)?.unwrap_or_default(), + schema: cell(1)?, + definition: cell(2)?, + comment: cell(3)?.filter(|comment| !comment.is_empty()), + }); + } + Ok(views) + } else { + Err(anyhow::anyhow!("Unexpected result type")) + } + } + + async fn list_materialized_views_view( + &self, + connection: &dyn DbConnection, + database: &str, + schema: Option, + ) -> Result { + let views = self + .list_materialized_views(connection, database, schema) + .await?; + + let columns = vec![ + Column::localized("name", "ObjectView.columns.name").width(200.0), + Column::localized("definition", "ObjectView.columns.definition").width(400.0), + ]; + + let rows: Vec> = views + .iter() + .map(|view| { + vec![ + view.name.clone(), + view.definition.as_deref().unwrap_or("").to_string(), + ] + }) + .collect(); + + Ok(ObjectView { + db_node_type: DbNodeType::MaterializedView, + title: t!("ObjectView.counts.materialized_views", count = views.len()).to_string(), + columns, + rows, + }) + } + // === Function Operations === async fn list_functions( @@ -3044,6 +3279,36 @@ ORDER BY rolname;"# ) } + fn drop_foreign_table(&self, _database: &str, schema: Option<&str>, table: &str) -> String { + format!( + "DROP FOREIGN TABLE IF EXISTS {}", + self.qualify_table_reference(schema, table) + ) + } + + fn rename_foreign_table( + &self, + _database: &str, + schema: Option<&str>, + old_name: &str, + new_name: &str, + ) -> String { + // `ALTER ... RENAME TO` 只能命名一个对象,旧名必须限定 schema。 + format!( + "ALTER FOREIGN TABLE {} RENAME TO {}", + self.qualify_table_reference(schema, old_name), + self.quote_identifier(new_name) + ) + } + + fn drop_materialized_view(&self, _database: &str, schema: Option<&str>, view: &str) -> String { + // 与外部表同理,限定 schema 才能不依赖会话 search_path。 + format!( + "DROP MATERIALIZED VIEW IF EXISTS {}", + self.qualify_table_reference(schema, view) + ) + } + fn build_backup_table_sql( &self, _database: &str, @@ -3648,10 +3913,26 @@ mod tests { .push(query.to_string()); let rows = if query.contains("table_comment") { + vec![ + vec![ + Some("users".to_string()), + Some("public".to_string()), + Some("r".to_string()), + Some("Application users".to_string()), + ], + vec![ + Some("remote_orders".to_string()), + Some("public".to_string()), + Some("f".to_string()), + Some("Orders on the remote server".to_string()), + ], + ] + } else if query.contains("matview_name") { vec![vec![ - Some("users".to_string()), + Some("mv_orders".to_string()), Some("public".to_string()), - Some("Application users".to_string()), + Some("SELECT order_id FROM orders".to_string()), + Some("Orders rollup".to_string()), ]] } else if query.contains("column_name") { vec![vec![ @@ -4033,17 +4314,202 @@ mod tests { .await .expect("list tables"); - assert_eq!(1, tables.len()); + assert_eq!(2, tables.len()); assert_eq!(crate::TableObjectType::Table, tables[0].object_type); assert_eq!(Some("Application users"), tables[0].comment.as_deref()); + // 外部表(relkind = 'f')列入「表」目录,但必须带独立的对象类型。 + assert_eq!("remote_orders", tables[1].name); + assert_eq!(crate::TableObjectType::ForeignTable, tables[1].object_type); + assert_eq!( + Some("Orders on the remote server"), + tables[1].comment.as_deref() + ); + let queries = connection.queries(); let table_query = queries .iter() .find(|query| query.contains("table_comment")) .expect("table metadata query"); assert!(table_query.contains("obj_description(c.oid, 'pg_class')")); - assert!(table_query.contains("c.relkind = 'r'")); - assert!(!table_query.contains("c.relkind IN")); + assert!(table_query.contains("c.relkind IN ('r', 'p', 'f')")); + } + + #[tokio::test] + async fn test_postgres_materialized_views_use_pg_class() { + let plugin = create_plugin(); + let connection = CommentMetadataConnection::new(); + + // 测试用伪连接只识别部分 SQL,这里同时验证能力位与查询文本。 + let views = plugin + .list_materialized_views(&connection, "app", Some("public".to_string())) + .await + .expect("list materialized views"); + assert_eq!(1, views.len()); + assert_eq!("mv_orders", views[0].name); + assert_eq!(Some("public"), views[0].schema.as_deref()); + assert_eq!( + Some("SELECT order_id FROM orders"), + views[0].definition.as_deref() + ); + assert_eq!(Some("Orders rollup"), views[0].comment.as_deref()); + assert!(plugin.capabilities().supports_materialized_views); + + let queries = connection.queries(); + let matview_query = queries + .iter() + .find(|query| query.contains("matview_name")) + .expect("materialized view query"); + assert!(matview_query.contains("c.relkind = 'm'")); + assert!(matview_query.contains("pg_get_viewdef")); + } + + #[test] + fn test_postgres_foreign_table_ddl_uses_foreign_table_statements() { + let plugin = create_plugin(); + + assert_eq!( + "DROP FOREIGN TABLE IF EXISTS \"public\".\"remote_orders\"", + plugin.drop_foreign_table("app", Some("public"), "remote_orders") + ); + assert_eq!( + "ALTER FOREIGN TABLE \"public\".\"remote_orders\" RENAME TO \"orders_2024\"", + plugin.rename_foreign_table("app", Some("public"), "remote_orders", "orders_2024") + ); + assert_eq!( + "DROP MATERIALIZED VIEW IF EXISTS \"public\".\"mv_orders\"", + plugin.drop_materialized_view("app", Some("public"), "mv_orders") + ); + // 没有 schema 时退化为仅限定对象名(与其它 DDL 生成方法一致)。 + assert_eq!( + "ALTER FOREIGN TABLE \"remote_orders\" RENAME TO \"orders_2024\"", + plugin.rename_foreign_table("app", None, "remote_orders", "orders_2024") + ); + } + + #[test] + fn test_postgres_manifest_scopes_table_actions_per_node_type() { + let manifest = create_plugin().ui_manifest(); + let supports = |node_type: DbNodeType, action_id: DatabaseActionId| { + manifest.actions.actions.iter().any(|action| { + action.id == action_id + && action + .targets + .iter() + .any(|target| target.node_type == node_type) + }) + }; + + // 外部表:只能查看数据 / 重命名 / 删除 / 数据转储 / 导入导出; + // 不能当作普通表设计、复制或做结构转储。 + assert!(supports( + DbNodeType::ForeignTable, + DatabaseActionId::OpenTableData + )); + assert!(supports( + DbNodeType::ForeignTable, + DatabaseActionId::RenameTable + )); + assert!(supports( + DbNodeType::ForeignTable, + DatabaseActionId::DeleteTable + )); + assert!(supports( + DbNodeType::ForeignTable, + DatabaseActionId::DumpSqlData + )); + assert!(supports( + DbNodeType::ForeignTable, + DatabaseActionId::ExportData + )); + assert!(!supports( + DbNodeType::ForeignTable, + DatabaseActionId::DesignTable + )); + assert!(!supports( + DbNodeType::ForeignTable, + DatabaseActionId::CopyTable + )); + assert!(!supports( + DbNodeType::ForeignTable, + DatabaseActionId::DumpSqlStructure + )); + assert!(!supports( + DbNodeType::ForeignTable, + DatabaseActionId::DumpSqlStructureAndData + )); + + // 物化视图:查看数据 / 删除 / 数据转储。 + assert!(supports( + DbNodeType::MaterializedView, + DatabaseActionId::OpenViewData + )); + assert!(supports( + DbNodeType::MaterializedView, + DatabaseActionId::DeleteView + )); + assert!(supports( + DbNodeType::MaterializedView, + DatabaseActionId::DumpSqlData + )); + assert!(!supports( + DbNodeType::MaterializedView, + DatabaseActionId::DesignTable + )); + } + + #[tokio::test] + async fn test_postgres_schema_tree_separates_foreign_tables_and_materialized_views() { + let plugin = create_plugin(); + let connection = CommentMetadataConnection::new(); + let schema_node = DbNode::new( + "conn:app:public", + "public", + DbNodeType::Schema, + "conn".to_string(), + DatabaseType::PostgreSQL, + ) + .with_metadata(HashMap::from([("database".to_string(), "app".to_string())])); + + let children = plugin + .build_database_or_schema_children( + &connection, + &schema_node, + Some("public".to_string()), + ) + .await + .expect("build schema children"); + + let tables = children + .iter() + .find(|node| node.node_type == DbNodeType::TablesFolder) + .expect("tables folder"); + let table_nodes: Vec<(&str, DbNodeType)> = tables + .children + .iter() + .map(|node| (node.name.as_str(), node.node_type)) + .collect(); + assert_eq!( + // `DbNode::Ord` 先按节点类型再按名称排序:普通表在前,外部表紧随其后。 + vec![ + ("users", DbNodeType::Table), + ("remote_orders", DbNodeType::ForeignTable), + ], + table_nodes + ); + + let matviews = children + .iter() + .find(|node| node.node_type == DbNodeType::MaterializedViewsFolder) + .expect("materialized views folder"); + assert_eq!("DbTree.MaterializedViews", matviews.name); + assert_eq!( + vec![("mv_orders", DbNodeType::MaterializedView)], + matviews + .children + .iter() + .map(|node| (node.name.as_str(), node.node_type)) + .collect::>() + ); } #[tokio::test] diff --git a/crates/db/src/types.rs b/crates/db/src/types.rs index 2c286bcb7..241a75725 100644 --- a/crates/db/src/types.rs +++ b/crates/db/src/types.rs @@ -36,6 +36,11 @@ pub enum DbNodeType { Schema, TablesFolder, Table, + /// PostgreSQL 外部表(`pg_class.relkind = 'f'`)。 + /// + /// 与 `Table` 并列显示在「表」目录中,但拥有独立的动作范围: + /// 删除/重命名必须使用 `DROP/ALTER FOREIGN TABLE`,设计表等不适用。 + ForeignTable, ColumnsFolder, Column, IndexesFolder, @@ -48,6 +53,8 @@ pub enum DbNodeType { Check, ViewsFolder, View, + MaterializedViewsFolder, + MaterializedView, FunctionsFolder, Function, ProceduresFolder, @@ -67,6 +74,7 @@ impl fmt::Display for DbNodeType { DbNodeType::Schema => write!(f, "Schema"), DbNodeType::TablesFolder => write!(f, "Tables"), DbNodeType::Table => write!(f, "Table"), + DbNodeType::ForeignTable => write!(f, "Foreign Table"), DbNodeType::ColumnsFolder => write!(f, "Columns"), DbNodeType::Column => write!(f, "Column"), DbNodeType::IndexesFolder => write!(f, "Indexes"), @@ -79,6 +87,8 @@ impl fmt::Display for DbNodeType { DbNodeType::Check => write!(f, "Check"), DbNodeType::ViewsFolder => write!(f, "Views"), DbNodeType::View => write!(f, "View"), + DbNodeType::MaterializedViewsFolder => write!(f, "Materialized Views"), + DbNodeType::MaterializedView => write!(f, "Materialized View"), DbNodeType::FunctionsFolder => write!(f, "Functions"), DbNodeType::Function => write!(f, "Function"), DbNodeType::ProceduresFolder => write!(f, "Procedures"), @@ -203,12 +213,17 @@ impl DbNode { } pub fn get_table_name(&self) -> Option { - if self.node_type == DbNodeType::Table { + if matches!(self.node_type, DbNodeType::Table | DbNodeType::ForeignTable) { Some(self.name.clone()) } else { self.metadata.get("table").cloned() } } + + /// 是否属于「表」目录下的对象(普通表 / 外部表 / 分区表)。 + pub fn is_table_like(&self) -> bool { + matches!(self.node_type, DbNodeType::Table | DbNodeType::ForeignTable) + } } /// Database information @@ -275,6 +290,15 @@ pub enum TableObjectType { #[default] Table, View, + /// 外部表属于「表」目录,但结构比较、结构转储不能按普通表生成 DDL。 + ForeignTable, +} + +impl TableObjectType { + /// 是否可作为普通表参与 DDL 生成(结构比较、结构转储、ER 图)。 + pub fn is_ddl_comparable(self) -> bool { + matches!(self, TableObjectType::Table) + } } /// Table-like object information with description/metadata. diff --git a/crates/db/tests/real_databases/postgres/foreign_matview.rs b/crates/db/tests/real_databases/postgres/foreign_matview.rs new file mode 100644 index 000000000..3adf3e472 --- /dev/null +++ b/crates/db/tests/real_databases/postgres/foreign_matview.rs @@ -0,0 +1,386 @@ +use db::connection::DbConnection; +use db::plugin::DatabasePlugin; +use db::postgresql::PostgresPlugin; +use db::types::{DbNode, DbNodeType, TableObjectType}; +use one_core::storage::DatabaseType; + +use crate::real_databases::common::env::{optional_database, postgres_config, skip_database}; +use crate::real_databases::postgres::core_flow::{ + drop_schema, execute, reset_schema, unique_schema, +}; + +/// 外部表依赖 FDW:没有外部服务器就建不出外部表。本地测试用 postgres_fdw 指回 +/// 同一个库,行为与真实外部表一致(可 SELECT / TRUNCATE,DDL 用 FOREIGN TABLE 语法)。 +const FIXTURE_SQL: &str = r#" +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + amount NUMERIC(10, 2) NOT NULL +); +INSERT INTO orders VALUES (1, 10.50), (2, 20.25); + +CREATE TABLE orders_part ( + id INTEGER NOT NULL, + amount NUMERIC(10, 2) NOT NULL +) PARTITION BY RANGE (id); +CREATE TABLE orders_part_low PARTITION OF orders_part FOR VALUES FROM (0) TO (100); +INSERT INTO orders_part VALUES (1, 1.00); + +CREATE MATERIALIZED VIEW mv_orders AS + SELECT id, amount * 2 AS doubled FROM orders; +COMMENT ON MATERIALIZED VIEW mv_orders IS 'doubled orders'; +"#; + +#[tokio::test] +async fn postgres_real_foreign_table_and_materialized_view_flow() { + let Some(config) = postgres_config() else { + skip_database( + "PostgreSQL", + "ONETCLI_TEST_POSTGRES_PASSWORD (empty string is valid)", + ); + return; + }; + let config = optional_database( + &config, + &std::env::var("ONETCLI_TEST_POSTGRES_DATABASE").unwrap_or_else(|_| "postgres".to_string()), + ); + let password = config.password.clone(); + // 用非 public schema:DDL 必须自带 schema 限定,不能依赖会话 search_path。 + let schema = unique_schema("foreign"); + let server = format!("{schema}_srv"); + let plugin = PostgresPlugin::new(); + let mut connection = plugin + .create_connection(config.clone()) + .await + .expect("PostgreSQL should connect"); + let conn: &(dyn DbConnection + Send + Sync) = connection.as_ref(); + // 这个库本来没装 postgres_fdw 的话,测完把扩展也清掉,不给本地库留残留。 + let extension_existed = postgres_fdw_installed(conn).await; + + reset_schema(&plugin, conn, &schema).await; + conn.switch_schema(&schema) + .await + .expect("switch to the test schema"); + create_foreign_table(&plugin, conn, &schema, &server, &password).await; + execute(&plugin, conn, FIXTURE_SQL).await; + + assert_listed_objects(&plugin, conn, &schema).await; + assert_object_view_types(&plugin, conn, &schema).await; + assert_tree_children(&plugin, conn, &schema).await; + assert_foreign_table_lifecycle(&plugin, conn, &schema, &server).await; + assert_materialized_view_lifecycle(&plugin, conn, &schema).await; + + drop_schema(&plugin, conn, &schema).await; + execute( + &plugin, + conn, + &format!("DROP SERVER IF EXISTS \"{server}\" CASCADE;"), + ) + .await; + if !extension_existed { + execute(&plugin, conn, "DROP EXTENSION IF EXISTS postgres_fdw;").await; + } + connection + .disconnect() + .await + .expect("PostgreSQL should disconnect"); +} + +async fn create_foreign_table( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, + server: &str, + password: &str, +) { + let sql = foreign_table_setup_sql(schema, server, password); + execute(plugin, connection, &sql).await; +} + +async fn postgres_fdw_installed(connection: &(dyn DbConnection + Send + Sync)) -> bool { + let result = connection + .query("SELECT extname FROM pg_extension WHERE extname = 'postgres_fdw'") + .await + .expect("extension lookup should run"); + matches!(result, db::executor::SqlResult::Query(query) if !query.rows.is_empty()) +} + +/// 建立外部表所需的 SQL:FDW 扩展、外部服务器、用户映射、外部表本体。 +fn foreign_table_setup_sql(schema: &str, server: &str, password: &str) -> String { + // 密码来自环境变量(ONETCLI_TEST_POSTGRES_PASSWORD),不硬编码。 + let escaped_password = password.replace('\'', "''"); + // 显式限定外部表与远端表的 schema,不靠会话 search_path。 + format!( + "CREATE EXTENSION IF NOT EXISTS postgres_fdw;\n\ + CREATE SERVER \"{server}\" FOREIGN DATA WRAPPER postgres_fdw \ + OPTIONS (host '127.0.0.1', dbname 'postgres');\n\ + CREATE USER MAPPING FOR CURRENT_USER SERVER \"{server}\" \ + OPTIONS (user 'postgres', password '{escaped_password}');\n\ + CREATE FOREIGN TABLE \"{schema}\".remote_orders (id INTEGER, amount NUMERIC(10, 2)) \ + SERVER \"{server}\" OPTIONS (schema_name '{schema}', table_name 'orders');" + ) +} + +async fn assert_listed_objects( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, +) { + let tables = plugin + .list_tables(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables should list"); + let table = |name: &str| { + tables + .iter() + .find(|table| table.name == name) + .unwrap_or_else(|| panic!("{name} should be listed, got {:?}", names(&tables))) + }; + + // 普通表 / 分区表 / 外部表都在「表」目录,只有外部表需要区分类型。 + assert_eq!(TableObjectType::Table, table("orders").object_type); + assert_eq!(TableObjectType::Table, table("orders_part").object_type); + assert_eq!( + TableObjectType::ForeignTable, + table("remote_orders").object_type + ); + assert!(!table("remote_orders").object_type.is_ddl_comparable()); + assert!(table("orders").object_type.is_ddl_comparable()); + + // 物化视图不在 information_schema.views 里,必须走 pg_class。 + let views = plugin + .list_views(connection, "postgres", Some(schema.to_string())) + .await + .expect("views should list"); + assert!( + !views.iter().any(|view| view.name == "mv_orders"), + "materialized views must not leak into the plain views list" + ); + let matviews = plugin + .list_materialized_views(connection, "postgres", Some(schema.to_string())) + .await + .expect("materialized views should list"); + let matview = matviews + .iter() + .find(|view| view.name == "mv_orders") + .expect("mv_orders should be listed as a materialized view"); + assert_eq!(Some(schema), matview.schema.as_deref()); + assert_eq!(Some("doubled orders"), matview.comment.as_deref()); + assert!( + matview + .definition + .as_deref() + .unwrap_or_default() + .contains("orders"), + "materialized view definition should come from pg_get_viewdef: {:?}", + matview.definition + ); +} + +async fn assert_object_view_types( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, +) { + let view = plugin + .list_tables_view(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables view should load"); + let type_of = |name: &str| { + let row = view + .rows + .iter() + .find(|row| row.first().map(String::as_str) == Some(name)) + .unwrap_or_else(|| panic!("{name} should appear in the object list")); + // 列顺序:name / owner / type / rows / size / indexes / tablespace / comment + row.get(2).cloned().unwrap_or_default() + }; + assert_eq!("Table", type_of("orders")); + assert_eq!("Partitioned Table", type_of("orders_part")); + assert_eq!("Foreign Table", type_of("remote_orders")); + + let matviews = plugin + .list_materialized_views_view(connection, "postgres", Some(schema.to_string())) + .await + .expect("materialized views view should load"); + assert_eq!(DbNodeType::MaterializedView, matviews.db_node_type); + assert!( + matviews + .rows + .iter() + .any(|row| row.first().map(String::as_str) == Some("mv_orders")), + "mv_orders should appear in the materialized views object list" + ); + + let tables_view = plugin + .list_tables_view(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables view should reload"); + assert!( + !tables_view + .rows + .iter() + .any(|row| row.first().map(String::as_str) == Some("mv_orders")), + "materialized views must not appear in the tables object list" + ); +} + +async fn assert_tree_children( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, +) { + let schema_node = DbNode::new( + format!("conn:{schema}"), + schema, + DbNodeType::Schema, + "conn".to_string(), + DatabaseType::PostgreSQL, + ) + .with_metadata(std::collections::HashMap::from([( + "database".to_string(), + "postgres".to_string(), + )])); + + let children = plugin + .build_database_or_schema_children(connection, &schema_node, Some(schema.to_string())) + .await + .expect("schema children should build"); + + let tables = children + .iter() + .find(|node| node.node_type == DbNodeType::TablesFolder) + .expect("tables folder should exist"); + let node_type_of = |name: &str| { + tables + .children + .iter() + .find(|node| node.name == name) + .unwrap_or_else(|| panic!("{name} should be a table node")) + .node_type + }; + assert_eq!(DbNodeType::Table, node_type_of("orders")); + assert_eq!(DbNodeType::Table, node_type_of("orders_part")); + assert_eq!(DbNodeType::ForeignTable, node_type_of("remote_orders")); + + let matviews = children + .iter() + .find(|node| node.node_type == DbNodeType::MaterializedViewsFolder) + .expect("materialized views folder should exist"); + assert_eq!( + vec![DbNodeType::MaterializedView], + matviews + .children + .iter() + .map(|node| node.node_type) + .collect::>() + ); + assert_eq!( + Some("mv_orders"), + matviews.children.first().map(|node| node.name.as_str()), + ); +} + +async fn assert_foreign_table_lifecycle( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, + server: &str, +) { + // 外部表可直接查询(数据面可用)。 + assert_eq!( + 2, + query_row_count(connection, "remote_orders", schema).await + ); + + // 生成的 ALTER 必须能真的改掉非 public schema 下的外部表。 + let rename = + plugin.rename_foreign_table("postgres", Some(schema), "remote_orders", "orders_fdw"); + execute(plugin, connection, &rename).await; + let renamed = plugin + .list_tables(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables should list after rename"); + assert!( + renamed.iter().any(|table| table.name == "orders_fdw" + && table.object_type == TableObjectType::ForeignTable), + "renamed foreign table should still be a foreign table, got {:?}", + names(&renamed) + ); + assert!(!renamed.iter().any(|table| table.name == "remote_orders")); + + // 生成的 DROP FOREIGN TABLE 必须命中外部表本身。 + let drop = plugin.drop_foreign_table("postgres", Some(schema), "orders_fdw"); + execute(plugin, connection, &drop).await; + let after_drop = plugin + .list_tables(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables should list after drop"); + assert!( + !after_drop.iter().any(|table| table.name == "orders_fdw"), + "dropped foreign table should disappear, got {:?}", + names(&after_drop) + ); + + // 重建一张外部表,确认删除普通表不会连带外部表(两条 DDL 互不干扰)。 + let rebuild = format!( + "CREATE FOREIGN TABLE \"{schema}\".remote_orders_2 (id INTEGER, amount NUMERIC(10, 2)) \ + SERVER \"{server}\" OPTIONS (schema_name '{schema}', table_name 'orders');" + ); + execute(plugin, connection, &rebuild).await; + execute( + plugin, + connection, + &plugin.drop_table("postgres", Some(schema), "orders_part_low"), + ) + .await; + let final_tables = plugin + .list_tables(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables should list at the end"); + assert!( + final_tables + .iter() + .any(|table| table.name == "remote_orders_2"), + "dropping a plain table must not touch foreign tables, got {:?}", + names(&final_tables) + ); +} + +async fn assert_materialized_view_lifecycle( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, +) { + // 物化视图存了数据,可以直接查。 + assert_eq!(2, query_row_count(connection, "mv_orders", schema).await); + + let drop = plugin.drop_materialized_view("postgres", Some(schema), "mv_orders"); + execute(plugin, connection, &drop).await; + let matviews = plugin + .list_materialized_views(connection, "postgres", Some(schema.to_string())) + .await + .expect("materialized views should list after drop"); + assert!( + !matviews.iter().any(|view| view.name == "mv_orders"), + "dropped materialized view should disappear" + ); +} + +async fn query_row_count( + connection: &(dyn DbConnection + Send + Sync), + table: &str, + schema: &str, +) -> usize { + let result = connection + .query(&format!("SELECT * FROM \"{schema}\".\"{table}\"")) + .await + .expect("select should run"); + match result { + db::executor::SqlResult::Query(query) => query.rows.len(), + other => panic!("expected rows from {table}, got {other:?}"), + } +} + +fn names(tables: &[db::types::TableInfo]) -> Vec { + tables.iter().map(|table| table.name.clone()).collect() +} diff --git a/crates/db/tests/real_databases/postgres/mod.rs b/crates/db/tests/real_databases/postgres/mod.rs index 57894241d..0e96a7064 100644 --- a/crates/db/tests/real_databases/postgres/mod.rs +++ b/crates/db/tests/real_databases/postgres/mod.rs @@ -1,4 +1,5 @@ mod core_flow; mod data; mod designer; +mod foreign_matview; mod import_export; diff --git a/crates/db_view/locales/db_view.yml b/crates/db_view/locales/db_view.yml index 5bc2f0b8a..97ce273ea 100644 --- a/crates/db_view/locales/db_view.yml +++ b/crates/db_view/locales/db_view.yml @@ -1941,6 +1941,10 @@ DbTree: en: Views zh-CN: 视图 zh-HK: 視圖 + MaterializedViews: + en: Materialized Views + zh-CN: 物化视图 + zh-HK: 實體化檢視 Functions: en: Functions zh-CN: 函数 @@ -2792,6 +2796,10 @@ SqlDump: en: "Found %{count} tables" zh-CN: "找到 %{count} 个表" zh-HK: "找到 %{count} 個表" + skipped_non_ddl_tables: + en: "Skipped %{count} object(s) without a plain table structure (foreign tables, etc.)" + zh-CN: "已跳过 %{count} 个没有普通表结构的对象(如外部表)" + zh-HK: "已略過 %{count} 個沒有普通資料表結構的物件(如外部表)" fetch_tables_failed: en: "Failed to fetch table list: %{error}" zh-CN: "获取表列表失败: %{error}" diff --git a/crates/db_view/src/database_objects_tab.rs b/crates/db_view/src/database_objects_tab.rs index 72a58b37d..e78095441 100644 --- a/crates/db_view/src/database_objects_tab.rs +++ b/crates/db_view/src/database_objects_tab.rs @@ -315,7 +315,13 @@ impl DatabaseObjects { } let node = nodes[0].clone(); - if matches!(node.node_type, DbNodeType::Table | DbNodeType::View) { + if matches!( + node.node_type, + DbNodeType::Table + | DbNodeType::ForeignTable + | DbNodeType::View + | DbNodeType::MaterializedView + ) { cx.emit(DatabaseObjectsEvent::CreateNewQuery { node }); } } @@ -428,8 +434,12 @@ impl DatabaseObjects { supports_action: impl Fn(DatabaseActionId) -> bool, ) -> Option { Some(match node.node_type { - DbNodeType::Table => DatabaseObjectsEvent::OpenTableData { node }, - DbNodeType::View => DatabaseObjectsEvent::OpenViewData { node }, + DbNodeType::Table | DbNodeType::ForeignTable => { + DatabaseObjectsEvent::OpenTableData { node } + } + DbNodeType::View | DbNodeType::MaterializedView => { + DatabaseObjectsEvent::OpenViewData { node } + } DbNodeType::Function if supports_action(DatabaseActionId::OpenFunction) => { DatabaseObjectsEvent::OpenFunction { node } } @@ -458,8 +468,11 @@ impl DatabaseObjects { | DbNodeType::Schema | DbNodeType::TablesFolder | DbNodeType::Table + | DbNodeType::ForeignTable | DbNodeType::ViewsFolder | DbNodeType::View + | DbNodeType::MaterializedViewsFolder + | DbNodeType::MaterializedView | DbNodeType::FunctionsFolder | DbNodeType::Function | DbNodeType::ProceduresFolder @@ -732,7 +745,7 @@ impl DatabaseObjects { ) } } - DbNodeType::TablesFolder | DbNodeType::Table => { + DbNodeType::TablesFolder | DbNodeType::Table | DbNodeType::ForeignTable => { let schema = current_node.get_schema_name(); metadata.insert("database".to_string(), database.clone()); if let Some(schema) = schema.as_ref().filter(|schema| !schema.trim().is_empty()) { @@ -754,7 +767,7 @@ impl DatabaseObjects { } else { format!("{}:table_folder:{}", current_node.id, name) }; - (node_id, DbNodeType::Table) + (node_id, Self::table_row_node_type(columns, row_data)) } DbNodeType::Schema => { if current_node.node_type == DbNodeType::Connection { @@ -783,23 +796,43 @@ impl DatabaseObjects { metadata.insert("table".to_string(), name.clone()); ( format!("{}:{}:{}:table_folder:{}", connection_id, db, schema, name), - DbNodeType::Table, + Self::table_row_node_type(columns, row_data), ) } } - DbNodeType::ViewsFolder | DbNodeType::View => { + DbNodeType::ViewsFolder + | DbNodeType::View + | DbNodeType::MaterializedViewsFolder + | DbNodeType::MaterializedView => { + let is_materialized = matches!( + db_node_type, + DbNodeType::MaterializedViewsFolder | DbNodeType::MaterializedView + ); let schema = current_node.get_schema_name(); metadata.insert("database".to_string(), database.clone()); if let Some(schema) = schema.as_ref().filter(|schema| !schema.trim().is_empty()) { metadata.insert("schema".to_string(), schema.clone()); } + let folder_key = if is_materialized { + "matviews_folder" + } else { + "views_folder" + }; metadata.insert("view".to_string(), name.clone()); - let node_id = if current_node.node_type == DbNodeType::ViewsFolder { + let node_id = if matches!( + current_node.node_type, + DbNodeType::ViewsFolder | DbNodeType::MaterializedViewsFolder + ) { format!("{}:{}", current_node.id, name) } else { - format!("{}:views_folder:{}", current_node.id, name) + format!("{}:{}:{}", current_node.id, folder_key, name) + }; + let node_type = if is_materialized { + DbNodeType::MaterializedView + } else { + DbNodeType::View }; - (node_id, DbNodeType::View) + (node_id, node_type) } DbNodeType::FunctionsFolder | DbNodeType::Function => { let schema = current_node.get_schema_name(); @@ -908,6 +941,17 @@ impl DatabaseObjects { row_data.get(index).cloned() } + /// 「表」目录对象行的真实节点类型。 + /// + /// 外部表与普通表共用同一个列表,必须靠类型列区分,否则右键菜单与 + /// 删除/重命名会按普通表生成 DDL。 + fn table_row_node_type(columns: &[Column], row_data: &[String]) -> DbNodeType { + match Self::row_value_for_column(columns, row_data, "type").as_deref() { + Some("Foreign Table") => DbNodeType::ForeignTable, + _ => DbNodeType::Table, + } + } + fn build_nodes_for_selected_rows(&self) -> Vec { let mut selected_rows: Vec = self.selected_indices.iter().copied().collect(); selected_rows.sort_unstable(); @@ -2242,6 +2286,93 @@ mod tests { .with_metadata(HashMap::from([("database".to_string(), String::new())])) } + fn postgres_tables_folder_node() -> DbNode { + DbNode::new( + "1:app:public:table_folder", + "DbTree.Tables", + DbNodeType::TablesFolder, + "1".to_string(), + DatabaseType::PostgreSQL, + ) + .with_metadata(HashMap::from([ + ("database".to_string(), "app".to_string()), + ("schema".to_string(), "public".to_string()), + ])) + } + + fn object_columns_with_type() -> Vec { + vec![Column::new("name", "Name"), Column::new("type", "Type")] + } + + #[test] + fn postgres_foreign_table_row_builds_foreign_table_node() { + let columns = object_columns_with_type(); + let row = vec!["remote_orders".to_string(), "Foreign Table".to_string()]; + + let node = DatabaseObjects::build_node_from_object_row( + DbNodeType::Table, + Some(&postgres_tables_folder_node()), + &columns, + &row, + ) + .expect("foreign table row should produce a node"); + + // 外部表与普通表同在「表」目录,但节点类型必须区分, + // 否则右键菜单与删除/重命名会按普通表生成 DDL。 + assert_eq!(DbNodeType::ForeignTable, node.node_type); + assert_eq!("1:app:public:table_folder:remote_orders", node.id); + assert_eq!( + Some("remote_orders"), + node.metadata.get("table").map(String::as_str) + ); + } + + #[test] + fn postgres_regular_table_row_stays_table_node() { + let columns = object_columns_with_type(); + let row = vec!["users".to_string(), "Table".to_string()]; + + let node = DatabaseObjects::build_node_from_object_row( + DbNodeType::Table, + Some(&postgres_tables_folder_node()), + &columns, + &row, + ) + .expect("table row should produce a node"); + + assert_eq!(DbNodeType::Table, node.node_type); + assert_eq!("1:app:public:table_folder:users", node.id); + } + + #[test] + fn postgres_matviews_folder_row_builds_materialized_view_node() { + let current_node = DbNode::new( + "1:app:public:matviews_folder", + "DbTree.MaterializedViews", + DbNodeType::MaterializedViewsFolder, + "1".to_string(), + DatabaseType::PostgreSQL, + ) + .with_metadata(HashMap::from([ + ("database".to_string(), "app".to_string()), + ("schema".to_string(), "public".to_string()), + ])); + let columns = vec![Column::new("name", "Name")]; + let row = vec!["mv_orders".to_string()]; + + let node = DatabaseObjects::build_node_from_object_row( + DbNodeType::MaterializedViewsFolder, + Some(¤t_node), + &columns, + &row, + ) + .expect("materialized view row should produce a node"); + + assert_eq!(DbNodeType::MaterializedView, node.node_type); + // 与树中 build_materialized_views_folder 生成的 id 保持一致。 + assert_eq!("1:app:public:matviews_folder:mv_orders", node.id); + } + #[test] fn oracle_tables_folder_row_builds_table_node_matching_tree_id() { let row = vec!["BIZ_MESSAGE".to_string()]; diff --git a/crates/db_view/src/database_view_plugin.rs b/crates/db_view/src/database_view_plugin.rs index 6f3e7312b..628f434de 100644 --- a/crates/db_view/src/database_view_plugin.rs +++ b/crates/db_view/src/database_view_plugin.rs @@ -556,6 +556,22 @@ fn context_menu_rank(node_type: DbNodeType, action_id: DatabaseActionId) -> usiz DatabaseActionId::DeleteView => 20, _ => 900, }, + DbNodeType::ForeignTable => match action_id { + DatabaseActionId::OpenTableData => 10, + DatabaseActionId::RenameTable => 30, + DatabaseActionId::TruncateTable => 50, + DatabaseActionId::DeleteTable => 60, + DatabaseActionId::DumpSqlData => 71, + DatabaseActionId::ImportData => 80, + DatabaseActionId::ExportData => 90, + _ => 900, + }, + DbNodeType::MaterializedView => match action_id { + DatabaseActionId::OpenViewData => 10, + DatabaseActionId::DeleteView => 20, + DatabaseActionId::DumpSqlData => 71, + _ => 900, + }, DbNodeType::Function => match action_id { DatabaseActionId::OpenFunction => 10, _ => 900, @@ -588,7 +604,13 @@ fn insert_query_table_context_menu_item( node_type: DbNodeType, node_id: &str, ) { - if !matches!(node_type, DbNodeType::Table | DbNodeType::View) { + if !matches!( + node_type, + DbNodeType::Table + | DbNodeType::ForeignTable + | DbNodeType::View + | DbNodeType::MaterializedView + ) { return; } @@ -651,6 +673,21 @@ fn context_menu_group(node_type: DbNodeType, action: &DatabaseActionDescriptor) DatabaseActionId::DeleteView => Some("view"), _ => None, }, + DbNodeType::ForeignTable => match action.id { + DatabaseActionId::OpenTableData => Some("open"), + DatabaseActionId::RenameTable + | DatabaseActionId::TruncateTable + | DatabaseActionId::DeleteTable => Some("table"), + DatabaseActionId::DumpSqlData => Some("dump"), + DatabaseActionId::ImportData | DatabaseActionId::ExportData => Some("io"), + _ => None, + }, + DbNodeType::MaterializedView => match action.id { + DatabaseActionId::OpenViewData => Some("open"), + DatabaseActionId::DeleteView => Some("view"), + DatabaseActionId::DumpSqlData => Some("dump"), + _ => None, + }, DbNodeType::Function => match action.id { DatabaseActionId::OpenFunction => Some("open"), _ => None, diff --git a/crates/db_view/src/db_tree_event.rs b/crates/db_view/src/db_tree_event.rs index 6021ec1d2..bb0a2eb40 100644 --- a/crates/db_view/src/db_tree_event.rs +++ b/crates/db_view/src/db_tree_event.rs @@ -12,7 +12,10 @@ use crate::{ sql_editor_view::SqlEditorTab, table_designer_tab::{TableDesigner, TableDesignerConfig}, }; -use db::{DbNode, DbNodeType, GlobalDbState, RoutineIdentity, SqlResult, schema_for_new_query}; +use db::{ + DbNode, DbNodeType, GlobalDbState, RoutineIdentity, SqlResult, TableObjectType, + schema_for_new_query, +}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, ParentElement, PathPromptOptions, Styled, Subscription, Window, div, px, @@ -116,8 +119,8 @@ impl DatabaseEventHandler { fn format_query_table_reference(node: &DbNode, global_state: &GlobalDbState) -> Option { let table = match node.node_type { - DbNodeType::Table => node.get_table_name()?, - DbNodeType::View => node.name.clone(), + DbNodeType::Table | DbNodeType::ForeignTable => node.get_table_name()?, + DbNodeType::View | DbNodeType::MaterializedView => node.name.clone(), _ => return None, }; let plugin = global_state @@ -140,9 +143,19 @@ impl DatabaseEventHandler { }) } + fn table_object_type_for_node(node: &DbNode) -> TableObjectType { + match node.node_type { + DbNodeType::ForeignTable => TableObjectType::ForeignTable, + _ => TableObjectType::Table, + } + } + fn query_title_for_node(node: &DbNode, database: Option<&str>) -> String { match node.node_type { - DbNodeType::Table | DbNodeType::View => format!("{} - Query", node.name), + DbNodeType::Table + | DbNodeType::ForeignTable + | DbNodeType::View + | DbNodeType::MaterializedView => format!("{} - Query", node.name), _ => format!("{} - Query", database.unwrap_or("New Query")), } } @@ -3092,12 +3105,13 @@ impl DatabaseEventHandler { }; let task = state - .drop_table( + .drop_table_like( &mut cx.clone(), node.connection_id.clone(), database, schema, table_name.clone(), + Self::table_object_type_for_node(&node), ) .await; @@ -3204,14 +3218,27 @@ impl DatabaseEventHandler { .get("database") .map(|s| s.to_string()) .unwrap_or_default(); - let result = state - .drop_view( - cx, - node.connection_id.clone(), - database, - node.name.clone(), - ) - .await; + let schema = node.get_schema_name(); + let result = if node.node_type == DbNodeType::MaterializedView { + state + .drop_materialized_view( + cx, + node.connection_id.clone(), + database, + schema, + node.name.clone(), + ) + .await + } else { + state + .drop_view( + cx, + node.connection_id.clone(), + database, + node.name.clone(), + ) + .await + }; match result { Ok(_) => removed.push(node.id.clone()), Err(error) => { @@ -3388,6 +3415,7 @@ impl DatabaseEventHandler { let database_name = node.get_database_name(); let schema_name = node.get_schema_name(); let table_name = node.get_table_name(); + let object_type = Self::table_object_type_for_node(&node); let window_id = cx.active_window(); window.open_dialog(cx, move |dialog, _window, _cx| { @@ -3443,12 +3471,13 @@ impl DatabaseEventHandler { }; let task = state - .drop_table( + .drop_table_like( &mut cx.clone(), conn_id.clone(), database, schema, tbl_name_value.clone(), + object_type, ) .await; @@ -3509,6 +3538,8 @@ impl DatabaseEventHandler { let connection_id = node.connection_id.clone(); let old_table_name = node.name.clone(); let metadata = node.metadata.clone(); + let object_type = Self::table_object_type_for_node(&node); + let schema = node.get_schema_name(); // 创建输入框状态 let input_state = cx.new(|cx| { @@ -3522,6 +3553,7 @@ impl DatabaseEventHandler { let conn_id = connection_id.clone(); let old_name = old_table_name.clone(); let meta = metadata.clone(); + let schema = schema.clone(); let state = global_state.clone(); let input = input_state.clone(); let tree = tree_view.clone(); @@ -3566,6 +3598,7 @@ impl DatabaseEventHandler { let conn_id = conn_id.clone(); let old_name = old_name.clone(); let meta = meta.clone(); + let schema = schema.clone(); let state = state.clone(); let tree = tree.clone(); @@ -3579,12 +3612,14 @@ impl DatabaseEventHandler { let db_node_id = format!("{}:{}", conn_id, database); let task = state - .rename_table( + .rename_table_like( cx, conn_id.clone(), database, + schema, old_name.clone(), new_name.clone(), + object_type, ) .await; match task { @@ -3919,12 +3954,15 @@ impl DatabaseEventHandler { let view_name = node.name.clone(); let view_node_id = node.id.clone(); let metadata = node.metadata.clone(); + let schema = node.get_schema_name(); + let is_materialized = node.node_type == DbNodeType::MaterializedView; window.open_dialog(cx, move |dialog, _window, _cx| { let conn_id = connection_id.clone(); let v_name = view_name.clone(); let v_node_id = view_node_id.clone(); let meta = metadata.clone(); + let schema = schema.clone(); let state = global_state.clone(); let v_name_display = view_name.clone(); let tree = tree_view.clone(); @@ -3951,6 +3989,7 @@ impl DatabaseEventHandler { let v_name = v_name.clone(); let v_node_id = v_node_id.clone(); let meta = meta.clone(); + let schema = schema.clone(); let state = state.clone(); let v_name_log = v_name.clone(); let tree = tree.clone(); @@ -3961,9 +4000,21 @@ impl DatabaseEventHandler { .get("database") .map(|s| s.to_string()) .unwrap_or_default(); - let result = state - .drop_view(cx, conn_id.clone(), database, v_name.clone()) - .await; + let result = if is_materialized { + state + .drop_materialized_view( + cx, + conn_id.clone(), + database, + schema, + v_name.clone(), + ) + .await + } else { + state + .drop_view(cx, conn_id.clone(), database, v_name.clone()) + .await + }; match result { Ok(_) => { diff --git a/crates/db_view/src/db_tree_view.rs b/crates/db_view/src/db_tree_view.rs index 4bb8c4129..4562a5ab5 100644 --- a/crates/db_view/src/db_tree_view.rs +++ b/crates/db_view/src/db_tree_view.rs @@ -507,7 +507,10 @@ pub fn get_icon_for_node_type(node_type: &DbNodeType, _theme: &gpui_component::T DbNodeType::Schema => object_icon(IconName::Schema), DbNodeType::Database => object_icon(IconName::Database), DbNodeType::Table => object_icon(IconName::Table), + // 外部表沿用普通表图标(Navicat 中同样归在「表」目录) + DbNodeType::ForeignTable => object_icon(IconName::Table), DbNodeType::View => object_icon(IconName::View), + DbNodeType::MaterializedView => object_icon(IconName::View), DbNodeType::Function => object_icon(IconName::Function), DbNodeType::Procedure => object_icon(IconName::Procedure), DbNodeType::Column => object_icon(IconName::Column), @@ -594,7 +597,13 @@ impl DbTreeView { let Some(node) = self.db_nodes.get(&node_id) else { return; }; - if matches!(node.node_type, DbNodeType::Table | DbNodeType::View) { + if matches!( + node.node_type, + DbNodeType::Table + | DbNodeType::ForeignTable + | DbNodeType::View + | DbNodeType::MaterializedView + ) { cx.emit(DbTreeViewEvent::CreateNewQuery { node_id }); } } @@ -1492,7 +1501,9 @@ impl DbTreeView { .db_nodes .get(&node_id) .and_then(|node| match node.node_type { - DbNodeType::View | DbNodeType::Function => node.parent_context.clone(), + DbNodeType::View | DbNodeType::MaterializedView | DbNodeType::Function => { + node.parent_context.clone() + } _ => None, }) .unwrap_or_else(|| node_id.clone()); @@ -1939,8 +1950,10 @@ impl DbTreeView { return matches!( node.node_type, DbNodeType::Table + | DbNodeType::ForeignTable | DbNodeType::TablesFolder | DbNodeType::ViewsFolder + | DbNodeType::MaterializedViewsFolder | DbNodeType::ColumnsFolder | DbNodeType::IndexesFolder | DbNodeType::FunctionsFolder @@ -2075,7 +2088,10 @@ impl DbTreeView { Some(DbNodeType::SequencesFolder) => object_icon(IconName::FolderSequences), Some(DbNodeType::Table) => object_icon(IconName::Table), + Some(DbNodeType::ForeignTable) => object_icon(IconName::Table), Some(DbNodeType::View) => object_icon(IconName::View), + Some(DbNodeType::MaterializedView) => object_icon(IconName::View), + Some(DbNodeType::MaterializedViewsFolder) => object_icon(IconName::FolderViews), Some(DbNodeType::Function) => object_icon(IconName::Function), Some(DbNodeType::Procedure) => object_icon(IconName::Procedure), Some(DbNodeType::Column) => { @@ -2113,7 +2129,7 @@ impl DbTreeView { if let Some(node) = self.db_nodes.get(node_id).cloned() { let database = node.get_database_name().unwrap_or_default(); match node.node_type { - DbNodeType::Table => { + DbNodeType::Table | DbNodeType::ForeignTable => { // 查找所属数据库 info!( @@ -2124,7 +2140,7 @@ impl DbTreeView { node_id: node.id.clone(), }); } - DbNodeType::View => { + DbNodeType::View | DbNodeType::MaterializedView => { info!( "DbTreeView: opening view data tab: {}.{}", database, node.name @@ -2183,7 +2199,8 @@ impl DbTreeView { | DbNodeType::QueriesFolder | DbNodeType::QueryFolder | DbNodeType::TablesFolder - | DbNodeType::ViewsFolder => { + | DbNodeType::ViewsFolder + | DbNodeType::MaterializedViewsFolder => { let is_expanded = self.expanded_nodes.contains(node_id); // 切换展开状态 @@ -2669,6 +2686,7 @@ impl DbTreeView { n.node_type, DbNodeType::TablesFolder | DbNodeType::ViewsFolder + | DbNodeType::MaterializedViewsFolder | DbNodeType::FunctionsFolder | DbNodeType::ProceduresFolder | DbNodeType::SequencesFolder @@ -2683,7 +2701,7 @@ impl DbTreeView { } else { n.name.clone() }; - let comment = if n.node_type == DbNodeType::Table { + let comment = if n.is_table_like() { n.metadata.get("comment").cloned() } else { None @@ -2705,6 +2723,7 @@ impl DbTreeView { node_type, Some(DbNodeType::TablesFolder) | Some(DbNodeType::ViewsFolder) + | Some(DbNodeType::MaterializedViewsFolder) | Some(DbNodeType::FunctionsFolder) | Some(DbNodeType::ProceduresFolder) | Some(DbNodeType::TriggersFolder) diff --git a/crates/db_view/src/import_export/sql_dump_target.rs b/crates/db_view/src/import_export/sql_dump_target.rs index f2c005451..dbf9a1e0c 100644 --- a/crates/db_view/src/import_export/sql_dump_target.rs +++ b/crates/db_view/src/import_export/sql_dump_target.rs @@ -22,7 +22,13 @@ pub(crate) fn resolve_sql_dump_target(node: &DbNode) -> Option { schema: non_empty(node.get_schema_name()), table: None, }), - DbNodeType::Table => Some(SqlDumpTarget { + DbNodeType::Table | DbNodeType::ForeignTable => Some(SqlDumpTarget { + database: non_empty(node.get_database_name())?, + schema: non_empty(node.get_schema_name()), + table: Some(node.name.clone()), + }), + // 物化视图在数据层面上就是一张可查询的表,只支持数据转储(结构转储未注册)。 + DbNodeType::MaterializedView => Some(SqlDumpTarget { database: non_empty(node.get_database_name())?, schema: non_empty(node.get_schema_name()), table: Some(node.name.clone()), @@ -128,6 +134,28 @@ mod tests { assert_eq!(None, target.table.as_deref()); } + #[test] + fn resolves_foreign_table_and_matview_data_dump_targets() { + let foreign = node_with_metadata( + DbNodeType::ForeignTable, + "remote_orders", + &[("database", "sales"), ("schema", "public")], + ); + let target = + resolve_sql_dump_target(&foreign).expect("foreign table target should resolve"); + assert_eq!("sales", target.database); + assert_eq!(Some("public"), target.schema.as_deref()); + assert_eq!(Some("remote_orders"), target.table.as_deref()); + + let matview = node_with_metadata( + DbNodeType::MaterializedView, + "mv_orders", + &[("database", "sales"), ("schema", "public")], + ); + let target = resolve_sql_dump_target(&matview).expect("matview target should resolve"); + assert_eq!(Some("mv_orders"), target.table.as_deref()); + } + #[test] fn sanitizes_dump_filename_components_for_path_safe_output() { assert_eq!( diff --git a/crates/db_view/src/import_export/sql_dump_view.rs b/crates/db_view/src/import_export/sql_dump_view.rs index a5a546750..156374251 100644 --- a/crates/db_view/src/import_export/sql_dump_view.rs +++ b/crates/db_view/src/import_export/sql_dump_view.rs @@ -200,7 +200,22 @@ impl SqlDumpView { .await; match tables_result { Ok(table_infos) => { - let tables: Vec<_> = table_infos.into_iter().map(|t| t.name).collect(); + // 外部表没有普通表结构:按普通表生成 CREATE TABLE 会产出无法 + // 执行的脚本,因此整库/整模式转储时跳过。 + let (tables_infos, skipped): (Vec<_>, Vec<_>) = table_infos + .into_iter() + .partition(|info| info.object_type.is_ddl_comparable()); + if !skipped.is_empty() { + Self::add_log( + &cx, + &logs, + &scroll_handle, + "".to_string(), + t!("SqlDump.skipped_non_ddl_tables", count = skipped.len()) + .to_string(), + ); + } + let tables: Vec<_> = tables_infos.into_iter().map(|t| t.name).collect(); Self::add_log( &cx, &logs, diff --git a/crates/db_view/src/sql_editor_view.rs b/crates/db_view/src/sql_editor_view.rs index 19436b955..32a0fc9cd 100644 --- a/crates/db_view/src/sql_editor_view.rs +++ b/crates/db_view/src/sql_editor_view.rs @@ -643,6 +643,8 @@ async fn fetch_foreign_schema_metadata( object_type: match table.object_type { TableObjectType::Table => SqlObjectType::Table, TableObjectType::View => SqlObjectType::View, + // 外部表参与 SQL 补全时按表处理,避免从候选中消失。 + TableObjectType::ForeignTable => SqlObjectType::Table, }, schema: table.schema.clone(), comment: table.comment.clone(), @@ -3010,6 +3012,8 @@ impl SqlEditorTab { object_type: match table.object_type { TableObjectType::Table => SqlObjectType::Table, TableObjectType::View => SqlObjectType::View, + // 外部表参与 SQL 补全时按表处理,避免从候选中消失。 + TableObjectType::ForeignTable => SqlObjectType::Table, }, schema: table.schema.clone(), comment: table.comment.clone(),