|
| 1 | +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 |
| 2 | +From: IamMaxim <g.maxim.stepanoff@gmail.com> |
| 3 | +Date: Tue, 30 Jun 2026 20:05:35 +0300 |
| 4 | +Subject: [PATCH] fix(db): exclude internal pg_* schemas from explorer tree |
| 5 | + |
| 6 | +The schema explorer leaked `pg_toast` (and other `pg_*` internal |
| 7 | +schemas) as non-expandable dead-end nodes because the schema filter |
| 8 | +only excluded `pg_catalog` and `information_schema`. Switch to a |
| 9 | +`not like 'pg\_%'` filter so all internal schemas are excluded, leaving |
| 10 | +only user schemas (public, app, ...) that actually expand. |
| 11 | + |
| 12 | +Add live introspection tests (schemas/relations/columns) and a live |
| 13 | +panel test that drives lazy expansion connection -> schema -> relation |
| 14 | +-> columns against a real Postgres, plus the test-support dev-deps and |
| 15 | +gpui_tokio/allow_parking setup the existing connect_and_select test was |
| 16 | +missing. |
| 17 | + |
| 18 | +Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
| 19 | +Claude-Session: https://claude.ai/code/session_01GbHBSdw2MBBgxKxLnVCbf6 |
| 20 | +--- |
| 21 | + Cargo.lock | 1 + |
| 22 | + crates/database_client/Cargo.toml | 4 + |
| 23 | + .../database_client/src/connection/client.rs | 2 + |
| 24 | + .../src/connection/introspect.rs | 69 ++++++++- |
| 25 | + crates/database_client/src/panel.rs | 138 ++++++++++++++++++ |
| 26 | + 5 files changed, 213 insertions(+), 1 deletion(-) |
| 27 | + |
| 28 | +diff --git a/Cargo.lock b/Cargo.lock |
| 29 | +index c0f136430c..e061529963 100644 |
| 30 | +--- a/Cargo.lock |
| 31 | ++++ b/Cargo.lock |
| 32 | +@@ -3755,6 +3755,7 @@ dependencies = [ |
| 33 | + "anyhow", |
| 34 | + "db", |
| 35 | + "editor", |
| 36 | ++ "fs", |
| 37 | + "futures 0.3.32", |
| 38 | + "gpui", |
| 39 | + "gpui_tokio", |
| 40 | +diff --git a/crates/database_client/Cargo.toml b/crates/database_client/Cargo.toml |
| 41 | +index 9303062449..b564819821 100644 |
| 42 | +--- a/crates/database_client/Cargo.toml |
| 43 | ++++ b/crates/database_client/Cargo.toml |
| 44 | +@@ -43,6 +43,10 @@ zed_actions.workspace = true |
| 45 | + [dev-dependencies] |
| 46 | + db = { workspace = true, features = ["test-support"] } |
| 47 | + gpui = { workspace = true, features = ["test-support"] } |
| 48 | ++project = { workspace = true, features = ["test-support"] } |
| 49 | ++workspace = { workspace = true, features = ["test-support"] } |
| 50 | ++settings = { workspace = true, features = ["test-support"] } |
| 51 | ++fs = { workspace = true, features = ["test-support"] } |
| 52 | + tempfile.workspace = true |
| 53 | + |
| 54 | + [package.metadata.cargo-machete] |
| 55 | +diff --git a/crates/database_client/src/connection/client.rs b/crates/database_client/src/connection/client.rs |
| 56 | +index a02ae0ceff..14e3febad2 100644 |
| 57 | +--- a/crates/database_client/src/connection/client.rs |
| 58 | ++++ b/crates/database_client/src/connection/client.rs |
| 59 | +@@ -155,6 +155,8 @@ mod tests { |
| 60 | + let Ok(url) = std::env::var("DATABASE_CLIENT_TEST_PG_URL") else { |
| 61 | + return; |
| 62 | + }; |
| 63 | ++ cx.executor().allow_parking(); |
| 64 | ++ cx.update(|cx| gpui_tokio::init(cx)); |
| 65 | + let profile = crate::connection::profile::ConnectionProfile::from_url(&url).unwrap(); |
| 66 | + let password = profile_password_from_url(&url); |
| 67 | + let conn = cx |
| 68 | +diff --git a/crates/database_client/src/connection/introspect.rs b/crates/database_client/src/connection/introspect.rs |
| 69 | +index b2c878a1bc..e53e190c7a 100644 |
| 70 | +--- a/crates/database_client/src/connection/introspect.rs |
| 71 | ++++ b/crates/database_client/src/connection/introspect.rs |
| 72 | +@@ -81,7 +81,7 @@ pub(crate) fn quote_ident(s: &str) -> String { |
| 73 | + impl Connection { |
| 74 | + pub fn list_schemas(&self, cx: &App) -> Task<Result<Vec<SchemaInfo>>> { |
| 75 | + let sql = "select schema_name from information_schema.schemata \ |
| 76 | +- where schema_name not in ('pg_catalog','information_schema') \ |
| 77 | ++ where schema_name not like 'pg\\_%' and schema_name <> 'information_schema' \ |
| 78 | + order by schema_name" |
| 79 | + .to_string(); |
| 80 | + let task = self.execute(sql, 10_000, cx); |
| 81 | +@@ -175,4 +175,71 @@ mod tests { |
| 82 | + assert_eq!(quote_ident("order"), "\"order\""); |
| 83 | + assert_eq!(quote_ident("we\"ird"), "\"we\"\"ird\""); |
| 84 | + } |
| 85 | ++ |
| 86 | ++ // Live introspection tests against a real Postgres. Run with: |
| 87 | ++ // DATABASE_CLIENT_TEST_PG_URL=postgres://postgres:secret@localhost:55432/testdb?sslmode=disable \ |
| 88 | ++ // cargo test -p database_client --lib -- --ignored live_ |
| 89 | ++ #[cfg(test)] |
| 90 | ++ async fn live_connection(cx: &mut gpui::TestAppContext) -> Option<Connection> { |
| 91 | ++ use std::str::FromStr as _; |
| 92 | ++ let url = std::env::var("DATABASE_CLIENT_TEST_PG_URL").ok()?; |
| 93 | ++ cx.executor().allow_parking(); |
| 94 | ++ cx.update(|cx| gpui_tokio::init(cx)); |
| 95 | ++ let profile = crate::connection::profile::ConnectionProfile::from_url(&url).unwrap(); |
| 96 | ++ let password = tokio_postgres::Config::from_str(&url) |
| 97 | ++ .ok() |
| 98 | ++ .and_then(|cfg| cfg.get_password().and_then(|p| String::from_utf8(p.to_vec()).ok())); |
| 99 | ++ Some( |
| 100 | ++ cx.update(|cx| Connection::connect(profile, password, cx)) |
| 101 | ++ .await |
| 102 | ++ .unwrap(), |
| 103 | ++ ) |
| 104 | ++ } |
| 105 | ++ |
| 106 | ++ #[gpui::test] |
| 107 | ++ #[ignore] |
| 108 | ++ async fn live_list_schemas(cx: &mut gpui::TestAppContext) { |
| 109 | ++ let Some(conn) = live_connection(cx).await else { |
| 110 | ++ return; |
| 111 | ++ }; |
| 112 | ++ let schemas = cx.update(|cx| conn.list_schemas(cx)).await.unwrap(); |
| 113 | ++ let names: Vec<_> = schemas.iter().map(|s| s.name.as_str()).collect(); |
| 114 | ++ assert!(names.contains(&"public"), "expected public schema, got {names:?}"); |
| 115 | ++ assert!(names.contains(&"app"), "expected app schema, got {names:?}"); |
| 116 | ++ assert!( |
| 117 | ++ !names.iter().any(|n| n.starts_with("pg_")), |
| 118 | ++ "internal pg_* schemas should be filtered out, got {names:?}" |
| 119 | ++ ); |
| 120 | ++ } |
| 121 | ++ |
| 122 | ++ #[gpui::test] |
| 123 | ++ #[ignore] |
| 124 | ++ async fn live_list_relations(cx: &mut gpui::TestAppContext) { |
| 125 | ++ let Some(conn) = live_connection(cx).await else { |
| 126 | ++ return; |
| 127 | ++ }; |
| 128 | ++ let relations = cx |
| 129 | ++ .update(|cx| conn.list_relations("public".to_string(), cx)) |
| 130 | ++ .await |
| 131 | ++ .unwrap(); |
| 132 | ++ let by_name: std::collections::HashMap<_, _> = |
| 133 | ++ relations.iter().map(|r| (r.name.as_str(), r.kind)).collect(); |
| 134 | ++ assert_eq!(by_name.get("users"), Some(&RelationKind::Table)); |
| 135 | ++ assert_eq!(by_name.get("orders"), Some(&RelationKind::Table)); |
| 136 | ++ assert_eq!(by_name.get("user_emails"), Some(&RelationKind::View)); |
| 137 | ++ } |
| 138 | ++ |
| 139 | ++ #[gpui::test] |
| 140 | ++ #[ignore] |
| 141 | ++ async fn live_list_columns(cx: &mut gpui::TestAppContext) { |
| 142 | ++ let Some(conn) = live_connection(cx).await else { |
| 143 | ++ return; |
| 144 | ++ }; |
| 145 | ++ let columns = cx |
| 146 | ++ .update(|cx| conn.list_columns("public".to_string(), "users".to_string(), cx)) |
| 147 | ++ .await |
| 148 | ++ .unwrap(); |
| 149 | ++ let names: Vec<_> = columns.iter().map(|c| c.name.as_str()).collect(); |
| 150 | ++ assert_eq!(names, vec!["id", "name", "email"]); |
| 151 | ++ } |
| 152 | + } |
| 153 | +diff --git a/crates/database_client/src/panel.rs b/crates/database_client/src/panel.rs |
| 154 | +index ad9c8951b3..cb6e8d094d 100644 |
| 155 | +--- a/crates/database_client/src/panel.rs |
| 156 | ++++ b/crates/database_client/src/panel.rs |
| 157 | +@@ -614,3 +614,141 @@ impl Render for DatabasePanel { |
| 158 | + })) |
| 159 | + } |
| 160 | + } |
| 161 | ++ |
| 162 | ++#[cfg(test)] |
| 163 | ++mod tests { |
| 164 | ++ use super::*; |
| 165 | ++ use crate::connection::profile::{ConnectionProfile, SslMode}; |
| 166 | ++ use gpui::TestAppContext; |
| 167 | ++ use std::time::Duration; |
| 168 | ++ |
| 169 | ++ fn init_test(cx: &mut TestAppContext) { |
| 170 | ++ cx.update(|cx| { |
| 171 | ++ let settings_store = settings::SettingsStore::test(cx); |
| 172 | ++ cx.set_global(settings_store); |
| 173 | ++ cx.set_global(db::AppDatabase::test_new()); |
| 174 | ++ theme_settings::init(theme::LoadThemes::JustBase, cx); |
| 175 | ++ gpui_tokio::init(cx); |
| 176 | ++ }); |
| 177 | ++ } |
| 178 | ++ |
| 179 | ++ /// Build a panel backed by a single injected profile, bypassing on-disk profile loading. |
| 180 | ++ async fn panel_with_profile<'a>( |
| 181 | ++ profile: ConnectionProfile, |
| 182 | ++ cx: &'a mut TestAppContext, |
| 183 | ++ ) -> (Entity<DatabasePanel>, &'a mut gpui::VisualTestContext) { |
| 184 | ++ let fs = fs::FakeFs::new(cx.executor()); |
| 185 | ++ let project = project::Project::test(fs, [], cx).await; |
| 186 | ++ let (workspace, cx) = |
| 187 | ++ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); |
| 188 | ++ let panel = workspace.update_in(cx, |workspace, window, cx| { |
| 189 | ++ DatabasePanel::new(workspace, window, cx) |
| 190 | ++ }); |
| 191 | ++ panel.update(cx, |panel, cx| { |
| 192 | ++ panel.profiles = vec![profile]; |
| 193 | ++ panel.tree = DatabasePanel::build_tree(&panel.profiles); |
| 194 | ++ panel.connections.clear(); |
| 195 | ++ cx.notify(); |
| 196 | ++ }); |
| 197 | ++ (panel, cx) |
| 198 | ++ } |
| 199 | ++ |
| 200 | ++ async fn wait_until( |
| 201 | ++ panel: &Entity<DatabasePanel>, |
| 202 | ++ cx: &mut gpui::VisualTestContext, |
| 203 | ++ description: &str, |
| 204 | ++ predicate: impl Fn(&DatabasePanel) -> bool, |
| 205 | ++ ) { |
| 206 | ++ for _ in 0..200 { |
| 207 | ++ cx.run_until_parked(); |
| 208 | ++ if panel.read_with(cx, |panel, _| predicate(panel)) { |
| 209 | ++ return; |
| 210 | ++ } |
| 211 | ++ cx.background_executor.timer(Duration::from_millis(25)).await; |
| 212 | ++ } |
| 213 | ++ let labels = panel.read_with(cx, |panel, _| { |
| 214 | ++ panel |
| 215 | ++ .tree |
| 216 | ++ .visible_nodes() |
| 217 | ++ .iter() |
| 218 | ++ .map(|n| n.label.clone()) |
| 219 | ++ .collect::<Vec<_>>() |
| 220 | ++ }); |
| 221 | ++ let errors = panel.read_with(cx, |panel, _| panel.errors.clone()); |
| 222 | ++ panic!("timed out waiting for {description}; visible={labels:?} errors={errors:?}"); |
| 223 | ++ } |
| 224 | ++ |
| 225 | ++ fn labels(panel: &Entity<DatabasePanel>, cx: &mut gpui::VisualTestContext) -> Vec<String> { |
| 226 | ++ panel.read_with(cx, |panel, _| { |
| 227 | ++ panel |
| 228 | ++ .tree |
| 229 | ++ .visible_nodes() |
| 230 | ++ .iter() |
| 231 | ++ .map(|n| n.label.clone()) |
| 232 | ++ .collect() |
| 233 | ++ }) |
| 234 | ++ } |
| 235 | ++ |
| 236 | ++ // Drives the real panel expand orchestration against a live Postgres. Run with: |
| 237 | ++ // DATABASE_CLIENT_TEST_PG_URL=postgres://postgres@localhost:55432/testdb?sslmode=disable \ |
| 238 | ++ // cargo test -p database_client --lib -- --ignored live_panel |
| 239 | ++ #[gpui::test] |
| 240 | ++ #[ignore] |
| 241 | ++ async fn live_panel_expands_connection_schema_relation_columns(cx: &mut TestAppContext) { |
| 242 | ++ let Ok(url) = std::env::var("DATABASE_CLIENT_TEST_PG_URL") else { |
| 243 | ++ return; |
| 244 | ++ }; |
| 245 | ++ cx.executor().allow_parking(); |
| 246 | ++ init_test(cx); |
| 247 | ++ |
| 248 | ++ let mut profile = ConnectionProfile::from_url(&url).expect("valid test url"); |
| 249 | ++ profile.id = "p1".into(); |
| 250 | ++ profile.ssl_mode = SslMode::Disable; |
| 251 | ++ |
| 252 | ++ let (panel, cx) = panel_with_profile(profile, cx).await; |
| 253 | ++ |
| 254 | ++ // Expand connection -> schemas load. |
| 255 | ++ panel.update(cx, |panel, cx| panel.toggle_node("p1".into(), cx)); |
| 256 | ++ wait_until(&panel, cx, "schemas to load", |panel| { |
| 257 | ++ panel.tree.schemas_loaded("p1") |
| 258 | ++ }) |
| 259 | ++ .await; |
| 260 | ++ let schema_labels = labels(&panel, cx); |
| 261 | ++ assert!( |
| 262 | ++ schema_labels.contains(&"public".to_string()), |
| 263 | ++ "expected public schema, got {schema_labels:?}" |
| 264 | ++ ); |
| 265 | ++ assert!( |
| 266 | ++ !schema_labels.iter().any(|l| l.starts_with("pg_")), |
| 267 | ++ "internal pg_* schemas should not appear, got {schema_labels:?}" |
| 268 | ++ ); |
| 269 | ++ |
| 270 | ++ // Expand the public schema -> relations load. |
| 271 | ++ panel.update(cx, |panel, cx| { |
| 272 | ++ panel.toggle_node("p1/public".into(), cx) |
| 273 | ++ }); |
| 274 | ++ wait_until(&panel, cx, "relations to load", |panel| { |
| 275 | ++ panel.tree.relations_loaded("p1/public") |
| 276 | ++ }) |
| 277 | ++ .await; |
| 278 | ++ let relation_labels = labels(&panel, cx); |
| 279 | ++ assert!( |
| 280 | ++ relation_labels.contains(&"users".to_string()), |
| 281 | ++ "expected users table under public, got {relation_labels:?}" |
| 282 | ++ ); |
| 283 | ++ |
| 284 | ++ // Expand the users relation -> columns load. |
| 285 | ++ panel.update(cx, |panel, cx| { |
| 286 | ++ panel.toggle_node("p1/public/users".into(), cx) |
| 287 | ++ }); |
| 288 | ++ wait_until(&panel, cx, "columns to load", |panel| { |
| 289 | ++ panel.tree.columns_loaded("p1/public/users") |
| 290 | ++ }) |
| 291 | ++ .await; |
| 292 | ++ let column_labels = labels(&panel, cx); |
| 293 | ++ assert!( |
| 294 | ++ column_labels.iter().any(|l| l.starts_with("id:")), |
| 295 | ++ "expected id column under users, got {column_labels:?}" |
| 296 | ++ ); |
| 297 | ++ } |
| 298 | ++} |
0 commit comments