From a909c07db38867a5906066a7a63976a73d802368 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Tue, 30 Jun 2026 19:05:51 +0200 Subject: [PATCH 01/12] feat(postgres): support multi-database selection on a single connection Lets a PostgreSQL connection hold and browse several databases at once, the same way MySQL/MariaDB connections already can. Because Postgres binds a connection to one database and cannot query across databases, each selected database gets its own pool and the sidebar shows a database to schema to table tree. Backend reads (get_tables, get_views, get_schemas, get_columns, etc.) and execute_query now take an optional database argument that routes the work to the right pool; the convention matches the existing record mutation commands. Empty selections fall back to the postgres maintenance database since Postgres cannot connect server-wide. --- src-tauri/src/commands.rs | 82 +++++++-- src-tauri/src/drivers/postgres/mod.rs | 6 +- src-tauri/src/pool_manager.rs | 15 +- src-tauri/src/pool_manager_tests.rs | 31 +++- src/components/layout/ExplorerSidebar.tsx | 73 ++++++++ .../layout/sidebar/SidebarDatabaseItem.tsx | 68 ++++++- .../layout/sidebar/SidebarSchemaItem.tsx | 6 + .../layout/sidebar/SidebarTableItem.tsx | 9 +- src/contexts/DatabaseContext.ts | 8 + src/contexts/DatabaseProvider.tsx | 171 ++++++++++++++++-- src/pages/Editor.tsx | 35 +++- src/types/editor.ts | 2 + src/utils/database.ts | 26 ++- tests/utils/database.test.ts | 37 +++- 14 files changed, 516 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c032bcfe..259c17cb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -468,13 +468,19 @@ pub async fn get_connection_by_id( pub async fn get_schemas( app: AppHandle, connection_id: String, + database: Option, ) -> Result, String> { log::info!("Fetching schemas for connection: {}", connection_id); let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + // On multi-database connections (e.g. PostgreSQL) route schema discovery to the + // selected database's pool instead of the connection's primary database. + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; drv.get_schemas(¶ms).await @@ -504,13 +510,17 @@ pub async fn get_routines( app: AppHandle, connection_id: String, schema: Option, + database: Option, ) -> Result, String> { log::info!("Fetching routines for connection: {}", connection_id); let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; drv.get_routines(¶ms, schema.as_deref()).await @@ -522,6 +532,7 @@ pub async fn get_routine_parameters( connection_id: String, routine_name: String, schema: Option, + database: Option, ) -> Result, String> { log::info!( "Fetching routine parameters for: {} on connection: {}", @@ -532,7 +543,10 @@ pub async fn get_routine_parameters( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; drv.get_routine_parameters(¶ms, &routine_name, schema.as_deref()) @@ -546,6 +560,7 @@ pub async fn get_routine_definition( routine_name: String, routine_type: String, // "PROCEDURE" or "FUNCTION" - mainly for MySQL SHOW CREATE schema: Option, + database: Option, ) -> Result { log::info!( "Fetching routine definition for: {} ({}) on connection: {}", @@ -557,7 +572,10 @@ pub async fn get_routine_definition( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; drv.get_routine_definition(¶ms, &routine_name, &routine_type, schema.as_deref()) @@ -569,11 +587,15 @@ pub async fn get_schema_snapshot( app: AppHandle, connection_id: String, schema: Option, + database: Option, ) -> Result, String> { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; drv.get_schema_snapshot(¶ms, schema.as_deref()).await } @@ -2628,13 +2650,17 @@ pub async fn get_tables( app: AppHandle, connection_id: String, schema: Option, + database: Option, ) -> Result, String> { log::info!("Fetching tables for connection: {}", connection_id); let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } log::debug!( "Getting tables from {} database: {}", @@ -2659,11 +2685,15 @@ pub async fn get_columns( connection_id: String, table_name: String, schema: Option, + database: Option, ) -> Result, String> { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; drv.get_columns(¶ms, &table_name, schema.as_deref()) .await @@ -2675,11 +2705,15 @@ pub async fn get_foreign_keys( connection_id: String, table_name: String, schema: Option, + database: Option, ) -> Result, String> { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; drv.get_foreign_keys(¶ms, &table_name, schema.as_deref()) .await @@ -2691,11 +2725,15 @@ pub async fn get_indexes( connection_id: String, table_name: String, schema: Option, + database: Option, ) -> Result, String> { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; drv.get_indexes(¶ms, &table_name, schema.as_deref()) .await @@ -3047,6 +3085,7 @@ pub async fn execute_query( limit: Option, page: Option, schema: Option, + database: Option, ) -> Result { log::info!( "Executing query on connection: {} | Query: {}", @@ -3059,7 +3098,12 @@ pub async fn execute_query( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + // On multi-database connections that cannot cross-database qualify in SQL + // (e.g. PostgreSQL), route the query to the selected database's pool. + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; let task = tokio::spawn(async move { @@ -3550,13 +3594,17 @@ pub async fn get_views( app: AppHandle, connection_id: String, schema: Option, + database: Option, ) -> Result, String> { log::info!("Fetching views for connection: {}", connection_id); let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } log::debug!( "Getting views from {} database: {}", @@ -3735,13 +3783,17 @@ pub async fn get_triggers( app: AppHandle, connection_id: String, schema: Option, + database: Option, ) -> Result, String> { log::info!("Fetching triggers for connection: {}", connection_id); let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; let result = drv.get_triggers(¶ms, schema.as_deref()).await; @@ -3761,6 +3813,7 @@ pub async fn get_trigger_definition( trigger_name: String, table_name: String, schema: Option, + database: Option, ) -> Result { log::info!( "Fetching trigger definition for: {} on connection: {}", @@ -3771,7 +3824,10 @@ pub async fn get_trigger_definition( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let drv = driver_for(&saved_conn.params.driver).await?; drv.get_trigger_definition(¶ms, &trigger_name, &table_name, schema.as_deref()) diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 009c41a4..51b112f6 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -1482,13 +1482,17 @@ impl DatabaseDriver for PostgresDriver { use urlencoding::encode; let user = encode(params.username.as_deref().unwrap_or_default()); let pass = encode(params.password.as_deref().unwrap_or_default()); + // Fall back to the `postgres` maintenance DB when no database is selected + // (PostgreSQL cannot connect server-wide); mirrors postgres_dbname(). + let dbname_owned = crate::pool_manager::postgres_dbname(params); + let dbname = encode(&dbname_owned); Ok(format!( "postgres://{}:{}@{}:{}/{}", user, pass, params.host.as_deref().unwrap_or("localhost"), params.port.unwrap_or(5432), - params.database + dbname )) } diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs index 2b88c1e0..3b91677b 100644 --- a/src-tauri/src/pool_manager.rs +++ b/src-tauri/src/pool_manager.rs @@ -263,13 +263,26 @@ pub(crate) fn is_pipes_as_concat_unsupported(err: &str) -> bool { err.contains("pipes_as_concat") || err.contains("no_engine_substitution") } +/// PostgreSQL requires a target database in every connection — unlike MySQL it +/// cannot connect "server-wide". When no database is selected (e.g. while +/// listing databases for a multi-database connection), fall back to the standard +/// `postgres` maintenance database so the connection still succeeds. +pub(crate) fn postgres_dbname(params: &ConnectionParams) -> String { + let primary = params.database.primary(); + if primary.is_empty() { + "postgres".to_string() + } else { + primary.to_string() + } +} + pub(crate) fn build_postgres_configurations(params: &ConnectionParams) -> PgConfig { let mut cfg = PgConfig::new(); cfg.user(params.username.as_deref().unwrap_or_default()) .password(params.password.as_deref().unwrap_or_default()) .port(params.port.unwrap_or(5432)) .host(params.host.as_deref().unwrap_or_default()) - .dbname(&format!("{}", params.database)); + .dbname(&postgres_dbname(params)); if let Some(ssl_mode) = params.ssl_mode.as_deref() { match ssl_mode { diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs index 971af020..5c0f945c 100644 --- a/src-tauri/src/pool_manager_tests.rs +++ b/src-tauri/src/pool_manager_tests.rs @@ -3,7 +3,7 @@ mod tests { use crate::models::{ConnectionParams, DatabaseSelection}; use crate::pool_manager::{ build_connection_key, build_mysql_options, format_error_chain, - is_pipes_as_concat_unsupported, + is_pipes_as_concat_unsupported, postgres_dbname, }; use sqlx::mysql::MySqlSslMode; @@ -227,6 +227,35 @@ mod tests { "Access denied for user 'root'@'localhost'" )); } + + #[test] + fn postgres_dbname_uses_selected_single_database() { + let mut params = connection_params("postgres", None); + params.database = DatabaseSelection::Single("analytics".to_string()); + assert_eq!(postgres_dbname(¶ms), "analytics"); + } + + #[test] + fn postgres_dbname_falls_back_to_maintenance_db_when_empty() { + let mut params = connection_params("postgres", None); + params.database = DatabaseSelection::Single(String::new()); + assert_eq!(postgres_dbname(¶ms), "postgres"); + } + + #[test] + fn postgres_dbname_falls_back_when_multiple_selection_is_empty() { + let mut params = connection_params("postgres", None); + params.database = DatabaseSelection::Multiple(vec![]); + assert_eq!(postgres_dbname(¶ms), "postgres"); + } + + #[test] + fn postgres_dbname_uses_first_of_multiple_selection() { + let mut params = connection_params("postgres", None); + params.database = + DatabaseSelection::Multiple(vec!["app".to_string(), "reporting".to_string()]); + assert_eq!(postgres_dbname(¶ms), "app"); + } } #[cfg(test)] diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index b6f9c842..ea2f202f 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -134,6 +134,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar setSelectedDatabases, databaseDataMap, loadDatabaseData, + loadDatabaseSchemaData, refreshDatabaseData, connectionDataMap, connections, @@ -394,6 +395,38 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar }); }; + // Schema-based multi-database (PostgreSQL): a table/view lives at + // database → schema → object. The query is schema-qualified ("schema"."table") + // and routed to the database's own connection pool via the `database` state. + const handleOpenSchemaTable = (database: string, schema: string, tableName: string) => { + setActiveTable(tableName, schema); + const quotedTable = quoteTableRef(tableName, activeDriver, schema); + navigate("/editor", { + state: { + initialQuery: `SELECT * FROM ${quotedTable}`, + tableName, + schema, + database, + title: `${tableName} (${database})`, + targetConnectionId: activeConnectionId, + }, + }); + }; + + const handleOpenSchemaView = (database: string, schema: string, viewName: string) => { + const quotedView = quoteTableRef(viewName, activeDriver, schema); + navigate("/editor", { + state: { + initialQuery: `SELECT * FROM ${quotedView}`, + tableName: viewName, + schema, + database, + title: `${viewName} (${database})`, + targetConnectionId: activeConnectionId, + }, + }); + }; + const handleRoutineDoubleClick = async (routine: RoutineInfo, schema?: string) => { try { const definition = await invoke("get_routine_definition", { @@ -430,6 +463,40 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar } }; + // Schema-based multi-database (PostgreSQL): fetch the routine/trigger DDL from + // the selected database's pool, passing both the database and its schema. + const handleSchemaRoutineDoubleClick = async (database: string, schema: string, routine: RoutineInfo) => { + try { + const definition = await invoke("get_routine_definition", { + connectionId: activeConnectionId, + routineName: routine.name, + routineType: routine.routine_type, + schema, + database, + }); + runQuery(definition, `${routine.name} Definition`, undefined, true); + } catch (e) { + console.error(e); + showAlert(t("sidebar.failGetRoutineDefinition") + String(e), { kind: "error" }); + } + }; + + const handleSchemaTriggerDoubleClick = async (database: string, schema: string, trigger: TriggerInfo) => { + try { + const definition = await invoke("get_trigger_definition", { + connectionId: activeConnectionId, + triggerName: trigger.name, + tableName: trigger.table_name, + schema, + database, + }); + runQuery(definition, `${trigger.name} Definition`, undefined, true, schema, true); + } catch (e) { + console.error(e); + showAlert(t("sidebar.failGetTriggerDefinition") + String(e), { kind: "error" }); + } + }; + const handleContextMenu = ( e: React.MouseEvent, type: string, @@ -1289,6 +1356,12 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar onViewDoubleClick={(name, db) => handleOpenDatabaseView(name, db)} onRoutineDoubleClick={(routine, db) => handleRoutineDoubleClick(routine, db)} onTriggerDoubleClick={(trigger, db) => handleTriggerDoubleClick(trigger, db)} + onLoadDatabaseSchema={loadDatabaseSchemaData} + onSchemaTableClick={(db, schema, name) => { void db; setActiveTable(name, schema); }} + onSchemaTableDoubleClick={handleOpenSchemaTable} + onSchemaViewDoubleClick={handleOpenSchemaView} + onSchemaRoutineDoubleClick={handleSchemaRoutineDoubleClick} + onSchemaTriggerDoubleClick={handleSchemaTriggerDoubleClick} onContextMenu={handleContextMenu} onAddColumn={(t_name) => setModifyColumnModal({ isOpen: true, tableName: t_name, column: null }) diff --git a/src/components/layout/sidebar/SidebarDatabaseItem.tsx b/src/components/layout/sidebar/SidebarDatabaseItem.tsx index a19b3cf2..ec3cc2bf 100644 --- a/src/components/layout/sidebar/SidebarDatabaseItem.tsx +++ b/src/components/layout/sidebar/SidebarDatabaseItem.tsx @@ -15,6 +15,7 @@ import { X, } from "lucide-react"; import { Accordion } from "./Accordion"; +import { SidebarSchemaItem } from "./SidebarSchemaItem"; import { SidebarTableItem } from "./SidebarTableItem"; import { SidebarViewItem } from "./SidebarViewItem"; import { SidebarRoutineItem } from "./SidebarRoutineItem"; @@ -62,6 +63,16 @@ interface SidebarDatabaseItemProps { onImport?: (database: string) => void; onViewDiagram?: (database: string) => void; capabilities?: DriverCapabilities | null; + // Schema-based multi-database (PostgreSQL) only. When the database holds + // schemas (databaseData.schemas is defined), the node renders one + // SidebarSchemaItem per schema and these callbacks carry both the database and + // the schema so queries route to the correct pool and qualify correctly. + onLoadDatabaseSchema?: (database: string, schema: string) => void; + onSchemaTableClick?: (database: string, schema: string, name: string) => void; + onSchemaTableDoubleClick?: (database: string, schema: string, name: string) => void; + onSchemaViewDoubleClick?: (database: string, schema: string, name: string) => void; + onSchemaRoutineDoubleClick?: (database: string, schema: string, routine: RoutineInfo) => void; + onSchemaTriggerDoubleClick?: (database: string, schema: string, trigger: TriggerInfo) => void; } export const SidebarDatabaseItem = ({ @@ -94,6 +105,12 @@ export const SidebarDatabaseItem = ({ onImport, onViewDiagram, capabilities, + onLoadDatabaseSchema, + onSchemaTableClick, + onSchemaTableDoubleClick, + onSchemaViewDoubleClick, + onSchemaRoutineDoubleClick, + onSchemaTriggerDoubleClick, }: SidebarDatabaseItemProps) => { const { t } = useTranslation(); @@ -121,6 +138,12 @@ export const SidebarDatabaseItem = ({ const isLoading = databaseData?.isLoading ?? false; const isLoaded = databaseData?.isLoaded ?? false; + // Schema-based multi-database (PostgreSQL): when the database carries a schema + // list, this node renders schemas instead of flat tables/views/routines. + const schemaList = databaseData?.schemas; + const isSchemaBased = schemaList !== undefined; + const schemaDataMap = databaseData?.schemaDataMap ?? {}; + // Auto-expand this database when it becomes the active one, e.g. after // picking a table from the Quick Navigator. Mirrors SidebarSchemaItem; done // during render (same-component setState) so the table item is mounted in @@ -152,7 +175,9 @@ export const SidebarDatabaseItem = ({ }; const itemCount = isLoaded - ? formatObjectCount(tables.length, views.length, routines.length, triggers.length) + ? isSchemaBased + ? `${schemaList?.length ?? 0}` + : formatObjectCount(tables.length, views.length, routines.length, triggers.length) : ""; return ( @@ -235,6 +260,47 @@ export const SidebarDatabaseItem = ({ {t("sidebar.loadingSchema")} + ) : isSchemaBased ? ( + (schemaList?.length ?? 0) === 0 ? ( +
+ {t("sidebar.noSchemas")} +
+ ) : ( +
+ {schemaList?.map((schema) => ( + onLoadDatabaseSchema?.(databaseName, s)} + onRefreshSchema={() => onRefreshDatabase(databaseName)} + onTableClick={(name, s) => onSchemaTableClick?.(databaseName, s, name)} + onTableDoubleClick={(name, s) => onSchemaTableDoubleClick?.(databaseName, s, name)} + onViewClick={onViewClick} + onViewDoubleClick={(name, s) => onSchemaViewDoubleClick?.(databaseName, s, name)} + onRoutineDoubleClick={(routine, s) => onSchemaRoutineDoubleClick?.(databaseName, s, routine)} + onTriggerDoubleClick={(trigger, s) => onSchemaTriggerDoubleClick?.(databaseName, s, trigger)} + onContextMenu={onContextMenu} + onAddColumn={onAddColumn} + onEditColumn={onEditColumn} + onAddIndex={onAddIndex} + onDropIndex={onDropIndex} + onAddForeignKey={onAddForeignKey} + onDropForeignKey={onDropForeignKey} + onCreateTable={onCreateTable} + onCreateView={onCreateView} + onCreateTrigger={onCreateTrigger} + showTriggers={capabilities?.triggers === true} + /> + ))} +
+ ) ) : ( <> {/* Tables */} diff --git a/src/components/layout/sidebar/SidebarSchemaItem.tsx b/src/components/layout/sidebar/SidebarSchemaItem.tsx index 7c630c02..9c3e8206 100644 --- a/src/components/layout/sidebar/SidebarSchemaItem.tsx +++ b/src/components/layout/sidebar/SidebarSchemaItem.tsx @@ -54,6 +54,10 @@ interface SidebarSchemaItemProps { onCreateView: () => void; onCreateTrigger: (schema: string) => void; showTriggers?: boolean; + /** Schema-based multi-database (PostgreSQL): the database this schema belongs + * to, so nested table metadata fetches route to the right connection pool. + * Absent for single-database connections. */ + database?: string; } export const SidebarSchemaItem = ({ @@ -83,6 +87,7 @@ export const SidebarSchemaItem = ({ onCreateView, onCreateTrigger, showTriggers = false, + database, }: SidebarSchemaItemProps) => { const { t } = useTranslation(); @@ -256,6 +261,7 @@ export const SidebarSchemaItem = ({ onDropForeignKey={onDropForeignKey} schemaVersion={schemaVersion} schema={schemaName} + database={database} /> ))} diff --git a/src/components/layout/sidebar/SidebarTableItem.tsx b/src/components/layout/sidebar/SidebarTableItem.tsx index 4a8753f4..84aba4d3 100644 --- a/src/components/layout/sidebar/SidebarTableItem.tsx +++ b/src/components/layout/sidebar/SidebarTableItem.tsx @@ -40,6 +40,9 @@ interface SidebarTableItemProps { onDropForeignKey: (tableName: string, fkName: string) => void; schemaVersion: number; schema?: string; + /** Schema-based multi-database (PostgreSQL): routes metadata fetches to this + * database's connection pool. Absent for single-database connections. */ + database?: string; canManage?: boolean; } @@ -60,6 +63,7 @@ const SidebarTableItemImpl = ({ onDropForeignKey, schemaVersion, schema, + database, }: SidebarTableItemProps) => { const { t } = useTranslation(); // Prevent unused variable warning @@ -85,16 +89,19 @@ const SidebarTableItemImpl = ({ connectionId, tableName: table.name, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }), invoke("get_foreign_keys", { connectionId, tableName: table.name, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }), invoke("get_indexes", { connectionId, tableName: table.name, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }), ]); @@ -106,7 +113,7 @@ const SidebarTableItemImpl = ({ } finally { setIsLoading(false); } - }, [connectionId, table.name, schema]); + }, [connectionId, table.name, schema, database]); useEffect(() => { if (isExpanded) { diff --git a/src/contexts/DatabaseContext.ts b/src/contexts/DatabaseContext.ts index 66382344..f1f8d2bb 100644 --- a/src/contexts/DatabaseContext.ts +++ b/src/contexts/DatabaseContext.ts @@ -77,6 +77,13 @@ export interface SchemaData { triggers: TriggerInfo[]; isLoading: boolean; isLoaded: boolean; + // Schema-based multi-database (PostgreSQL) only: when a `databaseDataMap` + // entry represents a whole database that contains schemas, `schemas` holds the + // schema names and `schemaDataMap` the per-schema objects. Both are absent for + // flat multi-database drivers (MySQL/MariaDB), where the database's tables live + // directly on the fields above. + schemas?: string[]; + schemaDataMap?: Record; } export interface ConnectionData { @@ -146,6 +153,7 @@ export interface DatabaseContextType { refreshSchemaData: (schema: string, connectionId?: string) => Promise; setSelectedSchemas: (schemas: string[], connectionId?: string) => Promise; loadDatabaseData: (database: string, connectionId?: string) => Promise; + loadDatabaseSchemaData: (database: string, schema: string, connectionId?: string) => Promise; refreshDatabaseData: (database: string, connectionId?: string) => Promise; setSelectedDatabases: (databases: string[], connectionId?: string) => void; getConnectionData: (connectionId: string) => ConnectionData | undefined; diff --git a/src/contexts/DatabaseProvider.tsx b/src/contexts/DatabaseProvider.tsx index af7f94fc..74dd8244 100644 --- a/src/contexts/DatabaseProvider.tsx +++ b/src/contexts/DatabaseProvider.tsx @@ -11,6 +11,7 @@ import { type ConnectionData, type ConnectionGroup, type ConnectionsFile, + type SchemaData, } from './DatabaseContext'; import type { ReactNode } from 'react'; import type { PluginManifest } from '../types/plugins'; @@ -18,7 +19,7 @@ import { clearAutocompleteCache } from '../utils/autocomplete'; import { toErrorMessage } from '../utils/errors'; import { useSettings } from '../hooks/useSettings'; import { findConnectionsForDrivers } from '../utils/connectionManager'; -import { isMultiDatabaseCapable, getEffectiveDatabase, getDatabaseList } from '../utils/database'; +import { isMultiDatabaseCapable, isSchemaBasedMultiDb, getEffectiveDatabase, getDatabaseList } from '../utils/database'; const createEmptyConnectionData = (driver: string = '', name: string = '', dbName: string = ''): ConnectionData => ({ driver, @@ -295,6 +296,42 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { const existing = currentData.databaseDataMap[database]; if (existing?.isLoaded || existing?.isLoading) return; + // Schema-based multi-database drivers (PostgreSQL): a database node holds + // schemas, not tables. Load the schema list here; per-schema objects are + // loaded lazily via loadDatabaseSchemaData when a schema is expanded. + if (isSchemaBasedMultiDb(currentData.capabilities)) { + updateConnectionData(connId, { + databaseDataMap: { + ...currentData.databaseDataMap, + [database]: { tables: [], views: [], routines: [], triggers: [], isLoading: true, isLoaded: false, schemas: [], schemaDataMap: {} }, + }, + }); + try { + const schemasResult = await invoke('get_schemas', { connectionId: connId, database }); + const freshData = connectionDataMap[connId]; + if (freshData) { + updateConnectionData(connId, { + databaseDataMap: { + ...freshData.databaseDataMap, + [database]: { tables: [], views: [], routines: [], triggers: [], isLoading: false, isLoaded: true, schemas: schemasResult, schemaDataMap: freshData.databaseDataMap[database]?.schemaDataMap ?? {} }, + }, + }); + } + } catch (e) { + console.error(`Failed to load schemas for database ${database}:`, e); + const freshData = connectionDataMap[connId]; + if (freshData) { + updateConnectionData(connId, { + databaseDataMap: { + ...freshData.databaseDataMap, + [database]: { tables: [], views: [], routines: [], triggers: [], isLoading: false, isLoaded: false, schemas: [], schemaDataMap: {} }, + }, + }); + } + } + return; + } + updateConnectionData(connId, { databaseDataMap: { ...currentData.databaseDataMap, @@ -340,6 +377,51 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { } }, [activeConnectionId, connectionDataMap, updateConnectionData]); + // Schema-based multi-database (PostgreSQL): load the tables/views/routines of a + // single schema inside a selected database, routing each query to that + // database's connection pool via the `database` argument. + const loadDatabaseSchemaData = useCallback(async (database: string, schema: string, targetConnectionId?: string) => { + const connId = targetConnectionId ?? activeConnectionId; + if (!connId) return; + + const currentData = connectionDataMap[connId]; + if (!currentData) return; + + const dbEntry = currentData.databaseDataMap[database]; + const existing = dbEntry?.schemaDataMap?.[schema]; + if (existing?.isLoaded || existing?.isLoading) return; + + const setSchemaEntry = (entry: SchemaData) => { + const fresh = connectionDataMap[connId]; + const freshDb = fresh?.databaseDataMap[database]; + if (!fresh || !freshDb) return; + updateConnectionData(connId, { + databaseDataMap: { + ...fresh.databaseDataMap, + [database]: { + ...freshDb, + schemaDataMap: { ...(freshDb.schemaDataMap ?? {}), [schema]: entry }, + }, + }, + }); + }; + + setSchemaEntry({ tables: [], views: [], routines: [], triggers: [], isLoading: true, isLoaded: false }); + + try { + const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + invoke('get_tables', { connectionId: connId, schema, database }), + invoke('get_views', { connectionId: connId, schema, database }), + invoke('get_routines', { connectionId: connId, schema, database }), + invoke('get_triggers', { connectionId: connId, schema, database }).catch(() => [] as TriggerInfo[]), + ]); + setSchemaEntry({ tables: tablesResult, views: viewsResult, routines: routinesResult, triggers: triggersResult, isLoading: false, isLoaded: true }); + } catch (e) { + console.error(`Failed to load schema ${schema} of database ${database}:`, e); + setSchemaEntry({ tables: [], views: [], routines: [], triggers: [], isLoading: false, isLoaded: false }); + } + }, [activeConnectionId, connectionDataMap, updateConnectionData]); + const refreshDatabaseData = useCallback(async (database: string, targetConnectionId?: string) => { const connId = targetConnectionId ?? activeConnectionId; if (!connId) return; @@ -347,6 +429,47 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { const currentData = connectionDataMap[connId]; if (!currentData) return; + // Schema-based multi-database (PostgreSQL): refresh re-reads the schema list + // and drops cached per-schema objects so they reload lazily on next expand. + if (isSchemaBasedMultiDb(currentData.capabilities)) { + updateConnectionData(connId, { + databaseDataMap: { + ...currentData.databaseDataMap, + [database]: { + ...(currentData.databaseDataMap[database] || { tables: [], views: [], routines: [], triggers: [], isLoaded: false, schemas: [], schemaDataMap: {} }), + isLoading: true, + }, + }, + }); + try { + const schemasResult = await invoke('get_schemas', { connectionId: connId, database }); + const freshData = connectionDataMap[connId]; + if (freshData) { + updateConnectionData(connId, { + databaseDataMap: { + ...freshData.databaseDataMap, + [database]: { tables: [], views: [], routines: [], triggers: [], isLoading: false, isLoaded: true, schemas: schemasResult, schemaDataMap: {} }, + }, + }); + } + } catch (e) { + console.error(`Failed to refresh schemas for database ${database}:`, e); + const freshData = connectionDataMap[connId]; + if (freshData) { + updateConnectionData(connId, { + databaseDataMap: { + ...freshData.databaseDataMap, + [database]: { + ...(freshData.databaseDataMap[database] || { tables: [], views: [], routines: [], triggers: [], isLoaded: false, schemas: [], schemaDataMap: {} }), + isLoading: false, + }, + }, + }); + } + } + return; + } + updateConnectionData(connId, { databaseDataMap: { ...currentData.databaseDataMap, @@ -537,22 +660,35 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { let initialDbMap: Record = {}; if (firstDb) { try { - const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ - invoke('get_tables', { connectionId, schema: firstDb }), - invoke('get_views', { connectionId, schema: firstDb }), - invoke('get_routines', { connectionId, schema: firstDb }), - invoke('get_triggers', { connectionId, schema: firstDb }).catch(() => [] as TriggerInfo[]), - ]); - initialDbMap = { - [firstDb]: { - tables: tablesResult, - views: viewsResult, - routines: routinesResult, - triggers: triggersResult, - isLoading: false, - isLoaded: true, - }, - }; + if (isSchemaBasedMultiDb(capabilities)) { + // Schema-based (PostgreSQL): pre-load the database's schema list. + // Per-schema objects load lazily when a schema is expanded. + const schemasResult = await invoke('get_schemas', { connectionId, database: firstDb }); + initialDbMap = { + [firstDb]: { + tables: [], views: [], routines: [], triggers: [], + isLoading: false, isLoaded: true, + schemas: schemasResult, schemaDataMap: {}, + }, + }; + } else { + const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + invoke('get_tables', { connectionId, schema: firstDb }), + invoke('get_views', { connectionId, schema: firstDb }), + invoke('get_routines', { connectionId, schema: firstDb }), + invoke('get_triggers', { connectionId, schema: firstDb }).catch(() => [] as TriggerInfo[]), + ]); + initialDbMap = { + [firstDb]: { + tables: tablesResult, + views: viewsResult, + routines: routinesResult, + triggers: triggersResult, + isLoading: false, + isLoaded: true, + }, + }; + } } catch (e) { console.error(`Failed to pre-load database ${firstDb}:`, e); } @@ -929,6 +1065,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { refreshSchemaData, setSelectedSchemas, loadDatabaseData, + loadDatabaseSchemaData, refreshDatabaseData, setSelectedDatabases, getConnectionData, diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 26cfc6e2..9e2e9f9c 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -138,6 +138,7 @@ interface EditorState { preventAutoRun?: boolean; readOnly?: boolean; schema?: string; + database?: string; targetConnectionId?: string; title?: string; } @@ -736,9 +737,13 @@ export const Editor = () => { targetTab?.type === "console" || targetTab?.type === "query_builder"; const schema = targetTab?.schema ?? activeSchema; + // Schema-based multi-database (PostgreSQL): route the query to the tab's + // database pool. Undefined for single-database / flat multi-db connections. + const database = targetTab?.database; // For history: fall back to activeDatabaseName for multi-db connections // where schema may not be set on the tab - const historyDb = schema + const historyDb = database + || schema || (isMultiDb ? activeDatabaseName : undefined) || undefined; @@ -756,6 +761,7 @@ export const Editor = () => { limit: pageSize, page: pageNum, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }); const end = performance.now(); @@ -883,7 +889,9 @@ export const Editor = () => { ? settings.resultPageSize : 100; const schema = targetTab?.schema ?? activeSchema; - const historyDb = schema + const database = targetTab?.database; + const historyDb = database + || schema || (isMultiDb ? activeDatabaseName : undefined) || undefined; @@ -979,6 +987,7 @@ export const Editor = () => { page: 1, batchId, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }, ); } catch (err) { @@ -1038,6 +1047,7 @@ export const Editor = () => { ? settings.resultPageSize : 100; const schema = currentTab?.schema ?? activeSchema; + const database = currentTab?.database; // Mark this entry as loading if (currentTab?.results) { @@ -1056,6 +1066,7 @@ export const Editor = () => { limit: pageSize, page: pageNum, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }); const end = performance.now(); @@ -2157,8 +2168,14 @@ export const Editor = () => { try { const promises = []; - const databaseParam = - isMultiDatabaseCapable(activeCapabilities) && activeTab?.schema + // Schema-based multi-database (PostgreSQL): the tab carries its database + // separately from its (PostgreSQL) schema, so route writes to that + // database's pool and qualify with the tab schema. Flat multi-database + // (MySQL) keeps overloading schema as the database name, unchanged. + const editSchema = activeTab?.database ? (activeTab?.schema ?? activeSchema) : activeSchema; + const databaseParam = activeTab?.database + ? { database: activeTab.database } + : isMultiDatabaseCapable(activeCapabilities) && activeTab?.schema ? { database: activeTab.schema } : {}; @@ -2170,7 +2187,7 @@ export const Editor = () => { connectionId: activeConnectionId, table: activeTable, pkMap, - ...(activeSchema ? { schema: activeSchema } : {}), + ...(editSchema ? { schema: editSchema } : {}), ...databaseParam, }), ), @@ -2187,7 +2204,7 @@ export const Editor = () => { pkMap: u.pkVal, colName: u.colName, newVal: u.newVal, - ...(activeSchema ? { schema: activeSchema } : {}), + ...(editSchema ? { schema: editSchema } : {}), ...databaseParam, }), ), @@ -2202,7 +2219,7 @@ export const Editor = () => { connectionId: activeConnectionId, table: activeTable, data: insertion.data, - ...(activeSchema ? { schema: activeSchema } : {}), + ...(editSchema ? { schema: editSchema } : {}), ...databaseParam, }), ), @@ -2488,7 +2505,7 @@ export const Editor = () => { ) return; - const queryKey = `${state.initialQuery}-${state.tableName}-${state.queryName}-${state.schema}-${state.title}`; + const queryKey = `${state.initialQuery}-${state.tableName}-${state.queryName}-${state.schema}-${state.database}-${state.title}`; if (processingRef.current === queryKey) { // If re-navigating to the same definition with readOnly, patch any @@ -2511,6 +2528,7 @@ export const Editor = () => { preventAutoRun, readOnly: navReadOnly, schema: navSchema, + database: navDatabase, title: navTitle, } = state; const tabId = addTab({ @@ -2519,6 +2537,7 @@ export const Editor = () => { query: sql, activeTable: table, schema: navSchema, + database: navDatabase, readOnly: navReadOnly, }); diff --git a/src/types/editor.ts b/src/types/editor.ts index 79fb2190..2d396a0b 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -110,6 +110,8 @@ export interface Tab { limitClause?: number; // SQL LIMIT value queryParams?: Record; // Saved values for query parameters schema?: string; // Schema name (PostgreSQL) for query reconstruction + database?: string; // Schema-based multi-database (PostgreSQL): routes queries to this database's pool + readOnly?: boolean; // Hides the Run button (e.g. for definition views) results?: QueryResultEntry[]; activeResultId?: string; diff --git a/src/utils/database.ts b/src/utils/database.ts index ab3ad474..5d128e69 100644 --- a/src/utils/database.ts +++ b/src/utils/database.ts @@ -1,17 +1,29 @@ import type { DriverCapabilities } from '../types/plugins'; /** - * Returns true when a driver supports cross-database access from a single connection - * (e.g. MySQL). Postgres uses schemas; SQLite/DuckDB are file-based or folder-based. + * Returns true when a connection can hold and browse more than one database + * (server-based drivers: MySQL/MariaDB, PostgreSQL). File-based (SQLite) and + * folder-based (DuckDB) drivers, and drivers that need no connection, are excluded. + * + * Note: this no longer requires `schemas === false`. Schema-based drivers + * (PostgreSQL) are multi-database capable too — they just present an extra + * `database → schema → table` level. Use {@link isSchemaBasedMultiDb} to tell + * the two layouts apart. */ export function isMultiDatabaseCapable(capabilities: DriverCapabilities | null | undefined): boolean { if (!capabilities) return false; if (capabilities.no_connection_required) return false; - return ( - capabilities.file_based === false && - !capabilities.folder_based && - capabilities.schemas === false - ); + return capabilities.file_based === false && !capabilities.folder_based; +} + +/** + * Returns true for multi-database drivers whose databases contain schemas + * (PostgreSQL). These need a hierarchical `database → schema → table` sidebar + * and per-database connection pools, unlike the flat `database → table` layout + * of MySQL/MariaDB. + */ +export function isSchemaBasedMultiDb(capabilities: DriverCapabilities | null | undefined): boolean { + return isMultiDatabaseCapable(capabilities) && capabilities?.schemas === true; } /** diff --git a/tests/utils/database.test.ts b/tests/utils/database.test.ts index 26eca104..be2348ae 100644 --- a/tests/utils/database.test.ts +++ b/tests/utils/database.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { isMultiDatabaseCapable, + isSchemaBasedMultiDb, isMultiDatabaseSelection, getDatabaseList, getEffectiveDatabase, @@ -22,8 +23,8 @@ describe('isMultiDatabaseCapable', () => { expect(isMultiDatabaseCapable(baseCapabilities)).toBe(true); }); - it('returns false when schemas is true (Postgres)', () => { - expect(isMultiDatabaseCapable({ ...baseCapabilities, schemas: true })).toBe(false); + it('returns true when schemas is true (Postgres is multi-database capable)', () => { + expect(isMultiDatabaseCapable({ ...baseCapabilities, schemas: true })).toBe(true); }); it('returns false when file_based is true (SQLite)', () => { @@ -34,10 +35,14 @@ describe('isMultiDatabaseCapable', () => { expect(isMultiDatabaseCapable({ ...baseCapabilities, folder_based: true })).toBe(false); }); - it('returns false when both schemas and file_based are true', () => { + it('returns false when file_based is true even if schemas is true', () => { expect(isMultiDatabaseCapable({ ...baseCapabilities, schemas: true, file_based: true })).toBe(false); }); + it('returns false when no_connection_required is true', () => { + expect(isMultiDatabaseCapable({ ...baseCapabilities, no_connection_required: true })).toBe(false); + }); + it('returns false for null capabilities', () => { expect(isMultiDatabaseCapable(null)).toBe(false); }); @@ -47,6 +52,32 @@ describe('isMultiDatabaseCapable', () => { }); }); +describe('isSchemaBasedMultiDb', () => { + it('returns true for a schema-based server driver (Postgres)', () => { + expect(isSchemaBasedMultiDb({ ...baseCapabilities, schemas: true })).toBe(true); + }); + + it('returns false for a flat server driver (MySQL)', () => { + expect(isSchemaBasedMultiDb(baseCapabilities)).toBe(false); + }); + + it('returns false for a file-based driver even with schemas', () => { + expect(isSchemaBasedMultiDb({ ...baseCapabilities, schemas: true, file_based: true })).toBe(false); + }); + + it('returns false when no_connection_required is true', () => { + expect(isSchemaBasedMultiDb({ ...baseCapabilities, schemas: true, no_connection_required: true })).toBe(false); + }); + + it('returns false for null capabilities', () => { + expect(isSchemaBasedMultiDb(null)).toBe(false); + }); + + it('returns false for undefined capabilities', () => { + expect(isSchemaBasedMultiDb(undefined)).toBe(false); + }); +}); + describe('isMultiDatabaseSelection', () => { it('returns true for an array', () => { expect(isMultiDatabaseSelection(['db1', 'db2'])).toBe(true); From 9d6d5de1a9ee8577ca49f26f42716c27a2d1b1e0 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Tue, 30 Jun 2026 20:57:18 +0200 Subject: [PATCH 02/12] fix(postgres): route schema+database for metadata, related records, and tab persistence on multi-database connections On schema-based multi-database (PostgreSQL) connections the backend keeps a separate pool per database, so every table-scoped call must carry both the schema and the database. Several paths dropped the database, which made them hit the connection's primary database and fail with relation-not-found: - PK/column metadata (fetchPkColumn, new-row, insert validation) omitted the database, leaving pkColumns null and silently turning the grid read-only. Added buildTableRoutingParams() to centralize schema+database routing. - Related-records / FK navigation ran execute_query without the database and dropped the referenced schema. Added ref_schema to ForeignKey (Rust + TS, mapped from foreign_schema_name) so cross-schema FKs qualify correctly, and routed the related-records query to the tab's database. - Editor tabs lost their database on save: cleanTabForStorage didn't persist the field, so a restored tab queried the primary database. Also made findExistingTableTab database-aware so same-schema tables in different databases no longer reuse the wrong tab. Adds a multi-schema demo database (erp_demo: hr/inventory/sales + cross-schema FKs) and tests for the routing/persistence regressions. --- demo/README.md | 20 ++- demo/connections.json | 25 ++- demo/init/postgres/07-schemas-demo.sql | 176 ++++++++++++++++++++++ src-tauri/src/drivers/mysql/mod.rs | 4 + src-tauri/src/drivers/postgres/mod.rs | 2 + src-tauri/src/drivers/sqlite/mod.rs | 2 + src-tauri/src/models.rs | 6 + src/components/ui/RelatedRecordsPanel.tsx | 3 + src/contexts/EditorProvider.tsx | 1 + src/hooks/useReferencedRecord.ts | 21 ++- src/pages/Editor.tsx | 55 +++++-- src/types/schema.ts | 6 + src/utils/database.ts | 38 +++++ src/utils/editor.ts | 10 +- src/utils/tabCleaner.ts | 8 + tests/hooks/useReferencedRecord.test.ts | 83 ++++++++++ tests/utils/database.test.ts | 58 +++++++ tests/utils/editor.test.ts | 49 ++++++ tests/utils/tabCleaner.test.ts | 49 ++++++ 19 files changed, 595 insertions(+), 21 deletions(-) create mode 100644 demo/init/postgres/07-schemas-demo.sql diff --git a/demo/README.md b/demo/README.md index 333405d2..339b4ef1 100644 --- a/demo/README.md +++ b/demo/README.md @@ -9,7 +9,7 @@ Tabularis features end to end. | Engine | Port | Databases | Theme | | ------------- | ----- | -------------------------------- | ---------------------------- | | MySQL 8.4 | 3306 | `tabularis_demo`, `blog_demo`, `perf_demo` | HR/e-commerce + blog CMS + wide-table perf | -| PostgreSQL 16 | 5432 | `tabularis_demo`, `analytics_demo`, `perf_demo` | HR/e-commerce + web analytics (JSONB) + wide-table perf | +| PostgreSQL 16 | 5432 | `tabularis_demo`, `analytics_demo`, `perf_demo`, `erp_demo` | HR/e-commerce + web analytics (JSONB) + wide-table perf + multi-schema ERP | | SQL Server 2022 | 1433 | `tabularis_demo`, `finance_demo` | HR/e-commerce + accounting | `tabularis_demo` is the **same logical schema** on all three engines (departments, @@ -29,6 +29,16 @@ takes a little longer. The SQL is generated by [`generate-perf-sql.py`](./generate-perf-sql.py) (edit there and re-run to change the column/row counts). +### `erp_demo` — multi-schema showcase (PostgreSQL only) + +A single database split across four schemas — `public` (app metadata + audit +log), `hr` (departments, employees), `inventory` (warehouses, products, +stock levels) and `sales` (customers, orders, order items) — wired together +with **cross-schema foreign keys** (e.g. `sales.orders.sales_rep_id → +hr.employees`, `sales.order_items.product_id → inventory.products`). Use it to +exercise the PostgreSQL **schema picker** in the sidebar: pick one or more +schemas, browse their tables, and confirm cross-schema relationships resolve. + ## Prerequisites - Docker Desktop or Docker Engine 24+ with the Compose plugin @@ -85,9 +95,12 @@ Open Tabularis → **Connections** → **Import** and pick `connections.json`. This adds a **Tabularis Demo (Docker)** group with pre-configured connections: - **Demo · MySQL** — exposes `tabularis_demo`, `blog_demo` and `perf_demo` -- **Demo · PostgreSQL** — exposes `tabularis_demo` +- **Demo · PostgreSQL (multi-database)** — a single connection exposing + `tabularis_demo`, `analytics_demo`, `perf_demo` and `erp_demo` together - **Demo · PostgreSQL (analytics_demo)** — the JSONB analytics database - **Demo · PostgreSQL (perf_demo)** — the wide-table scroll stress test +- **Demo · PostgreSQL (erp_demo, multi-schema)** — single database, four + schemas, for testing the schema picker > **SQL Server is not in `connections.json`.** Tabularis core currently ships > drivers for MySQL, PostgreSQL, and SQLite only; the official plugin registry @@ -119,7 +132,8 @@ demo/ │ │ ├── 01-tabularis-demo.sql │ │ ├── 02-analytics-demo.sql │ │ ├── ... -│ │ └── 06-perf-wide.sql # 50 cols x 50k rows (perf_demo) +│ │ ├── 06-perf-wide.sql # 50 cols x 50k rows (perf_demo) +│ │ └── 07-schemas-demo.sql # 4 schemas + cross-schema FKs (erp_demo) │ └── mssql/ │ ├── run-init.sh # Sidecar entrypoint │ ├── 01-tabularis-demo.sql # Idempotent diff --git a/demo/connections.json b/demo/connections.json index 739d5b22..e2577664 100644 --- a/demo/connections.json +++ b/demo/connections.json @@ -32,7 +32,7 @@ }, { "id": "tabularis-demo-postgres", - "name": "Demo · PostgreSQL (tabularis_demo)", + "name": "Demo · PostgreSQL (multi-database)", "group_id": "tabularis-demo-group", "sort_order": 1, "params": { @@ -41,7 +41,7 @@ "port": 5432, "username": "postgres", "password": "Tabularis_Demo_2026!", - "database": "tabularis_demo", + "database": ["tabularis_demo", "analytics_demo", "perf_demo", "erp_demo"], "ssl_mode": null, "ssl_ca": null, "ssl_cert": null, @@ -92,6 +92,27 @@ "ssh_connection_id": null, "save_in_keychain": true } + }, + { + "id": "tabularis-demo-postgres-erp", + "name": "Demo · PostgreSQL (erp_demo, multi-schema)", + "group_id": "tabularis-demo-group", + "sort_order": 4, + "params": { + "driver": "postgres", + "host": "127.0.0.1", + "port": 5432, + "username": "postgres", + "password": "Tabularis_Demo_2026!", + "database": "erp_demo", + "ssl_mode": null, + "ssl_ca": null, + "ssl_cert": null, + "ssl_key": null, + "ssh_enabled": false, + "ssh_connection_id": null, + "save_in_keychain": true + } } ], "ssh_connections": [] diff --git a/demo/init/postgres/07-schemas-demo.sql b/demo/init/postgres/07-schemas-demo.sql new file mode 100644 index 00000000..b9415dab --- /dev/null +++ b/demo/init/postgres/07-schemas-demo.sql @@ -0,0 +1,176 @@ +-- ============================================================= +-- Tabularis Demo — Multi-schema showcase (PostgreSQL 16) +-- Database: erp_demo +-- Purpose: exercise the PostgreSQL schema picker / multi-schema +-- sidebar. A single database holds four schemas, each owning a +-- slice of a tiny ERP, wired together with CROSS-SCHEMA foreign +-- keys so FK resolution (pg_namespace) is exercised too: +-- * public -> app metadata + audit log +-- * hr -> departments, employees +-- * inventory -> warehouses, products, stock_levels +-- * sales -> customers, orders, order_items +-- ============================================================= + +CREATE DATABASE erp_demo; + +\connect erp_demo + +CREATE SCHEMA IF NOT EXISTS hr; +CREATE SCHEMA IF NOT EXISTS inventory; +CREATE SCHEMA IF NOT EXISTS sales; + +-- ------------------------------------------------------------- +-- public — application metadata + audit trail +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.app_meta ( + key VARCHAR(50) PRIMARY KEY, + value VARCHAR(200) NOT NULL +); + +CREATE TABLE IF NOT EXISTS public.audit_log ( + id SERIAL PRIMARY KEY, + table_ref VARCHAR(100) NOT NULL, + action VARCHAR(20) NOT NULL CHECK (action IN ('insert', 'update', 'delete')), + actor VARCHAR(100) NOT NULL, + logged_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO public.app_meta (key, value) VALUES +('schema_version', '1.0.0'), +('seeded_by', 'tabularis-demo'), +('domain', 'multi-schema ERP showcase'); + +-- ------------------------------------------------------------- +-- hr — departments & employees +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS hr.departments ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + location VARCHAR(100) NOT NULL +); + +CREATE TABLE IF NOT EXISTS hr.employees ( + id SERIAL PRIMARY KEY, + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL, + email VARCHAR(150) NOT NULL UNIQUE, + department_id INT NOT NULL REFERENCES hr.departments(id), + hire_date DATE NOT NULL, + salary NUMERIC(10,2) NOT NULL +); + +INSERT INTO hr.departments (name, location) VALUES +('Sales', 'New York'), +('Warehouse', 'Newark'), +('Purchasing', 'Chicago'), +('Management', 'New York'); + +INSERT INTO hr.employees (first_name, last_name, email, department_id, hire_date, salary) VALUES +('Alice', 'Johnson', 'alice.johnson@erp.demo', 1, '2022-03-15', 78000.00), +('Bob', 'Smith', 'bob.smith@erp.demo', 1, '2021-07-01', 82000.00), +('Carol', 'Williams','carol.williams@erp.demo',2, '2023-01-10', 56000.00), +('David', 'Brown', 'david.brown@erp.demo', 2, '2022-06-20', 54000.00), +('Elena', 'Davis', 'elena.davis@erp.demo', 3, '2023-09-01', 64000.00), +('Frank', 'Miller', 'frank.miller@erp.demo', 4, '2020-05-01', 110000.00); + +-- ------------------------------------------------------------- +-- inventory — warehouses, products, per-warehouse stock +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS inventory.warehouses ( + id SERIAL PRIMARY KEY, + code VARCHAR(10) NOT NULL UNIQUE, + city VARCHAR(100) NOT NULL, + -- cross-schema FK: warehouse is run by an HR employee + manager_id INT REFERENCES hr.employees(id) +); + +CREATE TABLE IF NOT EXISTS inventory.products ( + id SERIAL PRIMARY KEY, + sku VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(150) NOT NULL, + category VARCHAR(50) NOT NULL, + price NUMERIC(10,2) NOT NULL +); + +CREATE TABLE IF NOT EXISTS inventory.stock_levels ( + id SERIAL PRIMARY KEY, + product_id INT NOT NULL REFERENCES inventory.products(id), + warehouse_id INT NOT NULL REFERENCES inventory.warehouses(id), + quantity INT NOT NULL DEFAULT 0, + UNIQUE (product_id, warehouse_id) +); + +INSERT INTO inventory.warehouses (code, city, manager_id) VALUES +('NWK', 'Newark', 3), +('CHI', 'Chicago', 4); + +INSERT INTO inventory.products (sku, name, category, price) VALUES +('SKU-1001', 'Laptop Pro 16', 'Electronics', 1499.99), +('SKU-1002', 'Wireless Mouse MX', 'Electronics', 34.99), +('SKU-1003', 'Standing Desk Oak', 'Furniture', 599.00), +('SKU-1004', 'Ergonomic Chair V2', 'Furniture', 449.00), +('SKU-1005', 'USB-C Hub 7-in-1', 'Electronics', 64.99), +('SKU-1006', 'Monitor 27" 4K', 'Electronics', 389.99); + +INSERT INTO inventory.stock_levels (product_id, warehouse_id, quantity) VALUES +(1, 1, 40), (1, 2, 20), +(2, 1, 300), +(3, 2, 15), +(4, 1, 25), (4, 2, 10), +(5, 1, 180), +(6, 2, 35); + +-- ------------------------------------------------------------- +-- sales — customers, orders, order items +-- orders.sales_rep_id -> hr.employees (cross-schema) +-- order_items.product_id -> inventory.products (cross-schema) +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS sales.customers ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(150) NOT NULL UNIQUE, + country VARCHAR(50) NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales.orders ( + id SERIAL PRIMARY KEY, + customer_id INT NOT NULL REFERENCES sales.customers(id), + sales_rep_id INT REFERENCES hr.employees(id), + order_date DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')), + total NUMERIC(10,2) NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS sales.order_items ( + id SERIAL PRIMARY KEY, + order_id INT NOT NULL REFERENCES sales.orders(id), + product_id INT NOT NULL REFERENCES inventory.products(id), + quantity INT NOT NULL, + unit_price NUMERIC(10,2) NOT NULL +); + +INSERT INTO sales.customers (name, email, country) VALUES +('TechCorp Inc.', 'orders@techcorp.com', 'USA'), +('Digital Solutions', 'buy@digitalsol.co.uk', 'UK'), +('Rome Design Studio','info@romedesign.it', 'Italy'), +('Berlin Startup Hub','office@berlinstartup.de', 'Germany'); + +INSERT INTO sales.orders (customer_id, sales_rep_id, order_date, status, total) VALUES +(1, 1, '2024-06-15', 'delivered', 1569.97), +(1, 2, '2024-08-20', 'delivered', 389.99), +(2, 1, '2024-06-22', 'shipped', 449.00), +(3, 2, '2024-07-01', 'confirmed', 664.98), +(4, 1, '2024-08-05', 'pending', 1499.99); + +INSERT INTO sales.order_items (order_id, product_id, quantity, unit_price) VALUES +(1, 1, 1, 1499.99), (1, 2, 2, 34.99), +(2, 6, 1, 389.99), +(3, 4, 1, 449.00), +(4, 3, 1, 599.00), (4, 5, 1, 64.99), +(5, 1, 1, 1499.99); + +INSERT INTO public.audit_log (table_ref, action, actor) VALUES +('sales.orders', 'insert', 'seed-script'), +('inventory.stock_levels','update', 'seed-script'), +('hr.employees', 'insert', 'seed-script'); diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 1bf9d22c..81a47114 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -155,6 +155,9 @@ pub async fn get_foreign_keys( column_name: mysql_row_str(r, 1), ref_table: mysql_row_str(r, 2), ref_column: mysql_row_str(r, 3), + // MySQL schema == database; cross-schema refs aren't modeled here, + // so the consumer falls back to the current schema. + ref_schema: None, on_update: mysql_row_str_opt(r, 4), on_delete: mysql_row_str_opt(r, 5), }) @@ -268,6 +271,7 @@ pub async fn get_all_foreign_keys_batch( column_name: mysql_row_str(row, 2), ref_table: mysql_row_str(row, 3), ref_column: mysql_row_str(row, 4), + ref_schema: None, on_update: mysql_row_str_opt(row, 5), on_delete: mysql_row_str_opt(row, 6), }; diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 51b112f6..cf9f1c60 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -211,6 +211,7 @@ pub async fn get_foreign_keys( column_name: r.try_get("column_name").unwrap_or_default(), ref_table: r.try_get("foreign_table_name").unwrap_or_default(), ref_column: r.try_get("foreign_column_name").unwrap_or_default(), + ref_schema: r.try_get("foreign_schema_name").ok(), on_update: r.try_get("update_rule").ok(), on_delete: r.try_get("delete_rule").ok(), }) @@ -357,6 +358,7 @@ pub async fn get_all_foreign_keys_batch( column_name: row.try_get("column_name").unwrap_or_default(), ref_table: row.try_get("foreign_table_name").unwrap_or_default(), ref_column: row.try_get("foreign_column_name").unwrap_or_default(), + ref_schema: row.try_get("foreign_schema_name").ok(), on_update: row.try_get("update_rule").ok(), on_delete: row.try_get("delete_rule").ok(), }; diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index c97c3703..e99360b5 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -140,6 +140,7 @@ pub async fn get_foreign_keys( column_name: r.try_get("from").unwrap_or_default(), ref_table: r.try_get("table").unwrap_or_default(), ref_column: r.try_get("to").unwrap_or_default(), + ref_schema: None, on_update: r.try_get("on_update").ok(), on_delete: r.try_get("on_delete").ok(), } @@ -216,6 +217,7 @@ pub async fn get_all_foreign_keys_batch( column_name: r.try_get("from").unwrap_or_default(), ref_table: r.try_get("table").unwrap_or_default(), ref_column: r.try_get("to").unwrap_or_default(), + ref_schema: None, on_update: r.try_get("on_update").ok(), on_delete: r.try_get("on_delete").ok(), } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index eca9d7aa..c073e580 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -306,6 +306,12 @@ pub struct ForeignKey { pub column_name: String, pub ref_table: String, pub ref_column: String, + /// Schema of the referenced table. Needed to qualify cross-schema + /// references (e.g. PostgreSQL `sales.orders -> inventory.products`); + /// `None` for drivers without schemas (the consumer falls back to the + /// current schema). + #[serde(default)] + pub ref_schema: Option, pub on_delete: Option, pub on_update: Option, } diff --git a/src/components/ui/RelatedRecordsPanel.tsx b/src/components/ui/RelatedRecordsPanel.tsx index fffabef7..a3561726 100644 --- a/src/components/ui/RelatedRecordsPanel.tsx +++ b/src/components/ui/RelatedRecordsPanel.tsx @@ -19,6 +19,7 @@ interface RelatedRecordsPanelProps { connectionId: string; driver?: string | null; schema?: string | null; + database?: string | null; onClose: () => void; onNavigateToTab: (fk: ForeignKey, value: unknown) => void; } @@ -28,6 +29,7 @@ export function RelatedRecordsPanel({ connectionId, driver, schema, + database, onClose, onNavigateToTab, }: RelatedRecordsPanelProps) { @@ -39,6 +41,7 @@ export function RelatedRecordsPanel({ value, driver, schema, + database, sourceColumnType, }); diff --git a/src/contexts/EditorProvider.tsx b/src/contexts/EditorProvider.tsx index 7db727cf..3069ab64 100644 --- a/src/contexts/EditorProvider.tsx +++ b/src/contexts/EditorProvider.tsx @@ -171,6 +171,7 @@ export const EditorProvider = ({ children }: { children: ReactNode }) => { activeConnectionId, partial?.activeTable || undefined, partial?.schema, + partial?.database, ); if (existing) { setActiveTabId(existing.id); diff --git a/src/hooks/useReferencedRecord.ts b/src/hooks/useReferencedRecord.ts index 4a98a151..b6b48c53 100644 --- a/src/hooks/useReferencedRecord.ts +++ b/src/hooks/useReferencedRecord.ts @@ -13,6 +13,7 @@ export interface FetchReferencedRecordParams { value: unknown; driver?: string | null; schema?: string | null; + database?: string | null; sourceColumnType?: string; } @@ -25,12 +26,18 @@ export async function fetchReferencedRecord({ value, driver, schema, + database, sourceColumnType, }: FetchReferencedRecordParams): Promise { if (!isForeignKeyValueNavigable(value)) { return { columns: [], rows: [], affected_rows: 0 }; } - const quotedTable = quoteTableRef(fk.ref_table, driver, schema); + // Qualify with the REFERENCED table's schema, not the source table's: a + // foreign key may point into a different schema (e.g. sales.orders -> + // inventory.products). Fall back to the current schema for drivers that + // don't report a referenced schema. + const targetSchema = fk.ref_schema ?? schema; + const quotedTable = quoteTableRef(fk.ref_table, driver, targetSchema); const filterClause = buildForeignKeyFilterClause( fk, value, @@ -45,7 +52,12 @@ export async function fetchReferencedRecord({ query, limit: 100, page: 1, - ...(schema ? { schema } : {}), + ...(targetSchema ? { schema: targetSchema } : {}), + // On schema-based multi-database connections (PostgreSQL) the pool is + // keyed by database, so the related-records query must run against the + // tab's database — otherwise it hits the connection's primary database + // and the referenced relation appears not to exist. + ...(database ? { database } : {}), }); } @@ -55,6 +67,7 @@ export interface UseReferencedRecordParams { value: unknown; driver?: string | null; schema?: string | null; + database?: string | null; sourceColumnType?: string; } @@ -64,6 +77,7 @@ export function useReferencedRecord({ value, driver, schema, + database, sourceColumnType, }: UseReferencedRecordParams) { const [result, setResult] = useState(null); @@ -87,6 +101,7 @@ export function useReferencedRecord({ value, driver, schema, + database, sourceColumnType, }); setResult(res); @@ -97,7 +112,7 @@ export function useReferencedRecord({ } finally { setIsLoading(false); } - }, [connectionId, fk, value, driver, schema, sourceColumnType]); + }, [connectionId, fk, value, driver, schema, database, sourceColumnType]); useEffect(() => { loadRecord(); diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 9e2e9f9c..2bf9fbdb 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -3,7 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { reconstructTableQuery } from "../utils/editor"; import { serializePkKey, buildPkMap } from "../utils/dataGrid"; -import { isMultiDatabaseCapable } from "../utils/database"; +import { isMultiDatabaseCapable, buildTableRoutingParams } from "../utils/database"; import { isReadonly } from "../utils/driverCapabilities"; import { generateTempId, @@ -560,20 +560,30 @@ export const Editor = () => { }, [tabs, updateScrollArrows]); const fetchPkColumn = useCallback( - async (table: string, tabId?: string, tabSchema?: string) => { + async ( + table: string, + tabId?: string, + tabSchema?: string, + tabDatabase?: string, + ) => { if (!activeConnectionId) return; - const effectiveSchema = tabSchema ?? activeSchema; + // On schema-based multi-database connections (PostgreSQL) the metadata + // pool is keyed by database, so column/PK detection MUST target the + // tab's database — otherwise get_columns hits the connection's primary + // database, finds no matching table, returns no PK, and the grid silently + // becomes read-only. Mirror the database routing the data query uses. + const routing = buildTableRoutingParams(tabSchema, tabDatabase, activeSchema); try { const [cols, fks] = await Promise.all([ invoke("get_columns", { connectionId: activeConnectionId, tableName: table, - ...(effectiveSchema ? { schema: effectiveSchema } : {}), + ...routing, }), invoke("get_foreign_keys", { connectionId: activeConnectionId, tableName: table, - ...(effectiveSchema ? { schema: effectiveSchema } : {}), + ...routing, }).catch((e) => { console.warn("Failed to fetch foreign keys:", e); return [] as ForeignKey[]; @@ -800,7 +810,12 @@ export const Editor = () => { if (tableName) { // Fetch column metadata in the background; tab updates when ready - fetchPkColumn(tableName, targetTabId, targetTab?.schema ?? undefined); + fetchPkColumn( + tableName, + targetTabId, + targetTab?.schema ?? undefined, + targetTab?.database ?? undefined, + ); } else { updateTab(targetTabId, { pkColumns: null }); } @@ -1602,14 +1617,19 @@ export const Editor = () => { sourceType, ); + // Qualify with the referenced table's own schema when the driver + // reports one (cross-schema FKs); fall back to the source tab's schema. const targetSchema = activeCapabilities?.schemas - ? currentTab.schema + ? (fk.ref_schema ?? currentTab.schema) : undefined; const newTabId = addTab({ type: "table", activeTable: fk.ref_table, schema: targetSchema, + // Keep the new tab on the same database pool as the source tab so + // multi-database (PostgreSQL) connections query the right database. + database: currentTab.database, filterClause, // Reset clauses that may linger on an existing dedup'd tab sortClause: "", @@ -1931,11 +1951,16 @@ export const Editor = () => { } try { - // Fetch table columns + // Fetch table columns — route to the tab's schema/database so + // multi-database (PostgreSQL) connections resolve the right table. const columns = await invoke("get_columns", { connectionId: activeConnectionId, tableName: activeTab.activeTable, - ...(activeSchema ? { schema: activeSchema } : {}), + ...buildTableRoutingParams( + activeTab?.schema, + activeTab?.database, + activeSchema, + ), }); if (!columns || columns.length === 0) { @@ -2099,11 +2124,16 @@ export const Editor = () => { // Process insertions if (pendingInsertions && Object.keys(pendingInsertions).length > 0) { try { - // Fetch columns for validation + // Fetch columns for validation — route to the tab's schema/database + // so multi-database (PostgreSQL) connections resolve the right table. const columns = await invoke("get_columns", { connectionId: activeConnectionId, tableName: activeTable, - ...(activeSchema ? { schema: activeSchema } : {}), + ...buildTableRoutingParams( + activeTab?.schema, + activeTab?.database, + activeSchema, + ), }); const selectedDisplayIndices = new Set(); @@ -3815,7 +3845,8 @@ export const Editor = () => { activeFkQuery={activeFkQuery} connectionId={activeConnectionId} driver={activeDriver} - schema={activeSchema} + schema={activeTab?.schema ?? activeSchema} + database={activeTab?.database} onClose={() => setActiveFkQuery(null)} onNavigateToTab={handleForeignKeyNavigate} /> diff --git a/src/types/schema.ts b/src/types/schema.ts index 0f519cb8..63d107b2 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -12,6 +12,12 @@ export interface ForeignKey { column_name: string; ref_table: string; ref_column: string; + /** + * Schema of the referenced table. Set for schema-based drivers (PostgreSQL) + * so cross-schema references resolve correctly; absent for drivers without + * schemas (consumers fall back to the current schema). + */ + ref_schema?: string | null; } export interface Index { diff --git a/src/utils/database.ts b/src/utils/database.ts index 5d128e69..66a3b431 100644 --- a/src/utils/database.ts +++ b/src/utils/database.ts @@ -54,3 +54,41 @@ export function getEffectiveDatabase(db: string | string[]): string { } return db; } + +/** The `schema` / `database` params a table-scoped backend call should carry. */ +export interface TableRoutingParams { + schema?: string; + database?: string; +} + +/** + * Builds the `{ schema, database }` params for any table-scoped backend call + * (`get_columns`, `get_foreign_keys`, `update_record`, `insert_record`, + * `delete_record`, …) from an editor tab's own schema/database plus the + * connection's active schema. + * + * Why `database` matters: on schema-based multi-database connections + * (PostgreSQL) the backend keeps a separate connection pool per database, so a + * tab opened on `erp_demo.inventory.products` must route its metadata/DML to + * the `erp_demo` pool. If the database is dropped, the call hits the + * connection's primary database, finds no matching table, and returns no + * columns / no primary key — which silently turns the grid read-only. The data + * query already routes by `tabDatabase`; metadata calls must match it. + * + * The tab's `schema` takes precedence over the connection's `activeSchema` + * (the active schema is a single global, but each tab can view a different + * one). `database` is only emitted when the tab actually carries one, so flat + * multi-database drivers (MySQL, where the tab has no separate `database`) are + * unaffected. + */ +export function buildTableRoutingParams( + tabSchema: string | null | undefined, + tabDatabase: string | null | undefined, + activeSchema: string | null | undefined, +): TableRoutingParams { + const schema = tabSchema ?? activeSchema ?? undefined; + const params: TableRoutingParams = {}; + if (schema) params.schema = schema; + if (tabDatabase) params.database = tabDatabase; + return params; +} diff --git a/src/utils/editor.ts b/src/utils/editor.ts index 9b6982ea..54355ceb 100644 --- a/src/utils/editor.ts +++ b/src/utils/editor.ts @@ -139,6 +139,7 @@ export function findExistingTableTab( connectionId: string, tableName: string | undefined, schema?: string, + database?: string, ): Tab | undefined { if (!tableName) return undefined; return tabs.find( @@ -146,7 +147,14 @@ export function findExistingTableTab( t.connectionId === connectionId && t.type === "table" && t.activeTable === tableName && - (t.schema || undefined) === (schema || undefined), + (t.schema || undefined) === (schema || undefined) && + // Must match the database too: on schema-based multi-database + // connections (PostgreSQL) the same schema.table name exists in + // different databases, and each tab routes its query to its own + // database pool. Ignoring this reused a stale tab whose `database` + // pointed elsewhere, so the query ran against the wrong pool and + // PostgreSQL reported the relation as missing. + (t.database || undefined) === (database || undefined), ); } diff --git a/src/utils/tabCleaner.ts b/src/utils/tabCleaner.ts index 0a79c937..7fefe155 100644 --- a/src/utils/tabCleaner.ts +++ b/src/utils/tabCleaner.ts @@ -20,6 +20,13 @@ export interface CleanedTab { queryParams?: Record; notebookId?: string; schema?: string; + /** + * Database the tab's query is routed to (schema-based multi-database + * connections, e.g. PostgreSQL). Must be persisted: without it a restored + * table tab loses its database pool and the query runs against the + * connection's primary database (relation-not-found errors). + */ + database?: string; readOnly?: boolean; } @@ -49,6 +56,7 @@ export function cleanTabForStorage(tab: Tab): CleanedTab { queryParams: tab.queryParams, notebookId: tab.notebookId, schema: tab.schema, + database: tab.database, readOnly: tab.readOnly, }; } diff --git a/tests/hooks/useReferencedRecord.test.ts b/tests/hooks/useReferencedRecord.test.ts index d6ed85ec..eea6a0b2 100644 --- a/tests/hooks/useReferencedRecord.test.ts +++ b/tests/hooks/useReferencedRecord.test.ts @@ -80,5 +80,88 @@ describe('useReferencedRecord hook integration', () => { expect(mockInvoke).not.toHaveBeenCalled(); expect(res).toEqual({ columns: [], rows: [], affected_rows: 0 }); }); + + it("qualifies with the referenced table's schema for a cross-schema FK", async () => { + // Source tab lives in `sales`, but the FK points at inventory.products. + const mockInvoke = vi.mocked(invoke); + mockInvoke.mockResolvedValueOnce({ columns: [], rows: [], affected_rows: 0 }); + + await fetchReferencedRecord({ + connectionId: 'conn-123', + fk: { ...fk('fk_prod', 'product_id', 'products', 'id'), ref_schema: 'inventory' }, + value: 5, + driver: 'postgres', + schema: 'sales', + database: 'erp_demo', + }); + + expect(mockInvoke).toHaveBeenCalledWith('execute_query', { + connectionId: 'conn-123', + query: 'SELECT * FROM "inventory"."products" WHERE "id" = 5', + limit: 100, + page: 1, + schema: 'inventory', + database: 'erp_demo', + }); + }); + + it("routes the related-records query to the tab's database (regression)", async () => { + // Without `database`, execute_query hit the connection's primary + // database and PostgreSQL reported `relation "inventory.products" + // does not exist`. + const mockInvoke = vi.mocked(invoke); + mockInvoke.mockResolvedValueOnce({ columns: [], rows: [], affected_rows: 0 }); + + await fetchReferencedRecord({ + connectionId: 'conn-123', + fk: { ...fk('fk_prod', 'product_id', 'products', 'id'), ref_schema: 'inventory' }, + value: 1, + driver: 'postgres', + schema: 'inventory', + database: 'erp_demo', + }); + + const [, args] = mockInvoke.mock.calls[0]; + expect(args).toHaveProperty('database', 'erp_demo'); + }); + + it('falls back to the source schema when the FK reports no ref_schema', async () => { + const mockInvoke = vi.mocked(invoke); + mockInvoke.mockResolvedValueOnce({ columns: [], rows: [], affected_rows: 0 }); + + await fetchReferencedRecord({ + connectionId: 'conn-123', + fk: fk('fk_prod', 'product_id', 'products', 'id'), + value: 7, + driver: 'postgres', + schema: 'public', + database: 'erp_demo', + }); + + expect(mockInvoke).toHaveBeenCalledWith('execute_query', { + connectionId: 'conn-123', + query: 'SELECT * FROM "public"."products" WHERE "id" = 7', + limit: 100, + page: 1, + schema: 'public', + database: 'erp_demo', + }); + }); + + it('omits database for single-database connections', async () => { + const mockInvoke = vi.mocked(invoke); + mockInvoke.mockResolvedValueOnce({ columns: [], rows: [], affected_rows: 0 }); + + await fetchReferencedRecord({ + connectionId: 'conn-123', + fk: { ...fk('fk_prod', 'product_id', 'products', 'id'), ref_schema: 'inventory' }, + value: 3, + driver: 'postgres', + schema: 'inventory', + }); + + const [, args] = mockInvoke.mock.calls[0]; + expect(args).not.toHaveProperty('database'); + }); }); }); diff --git a/tests/utils/database.test.ts b/tests/utils/database.test.ts index be2348ae..d5c5b8c4 100644 --- a/tests/utils/database.test.ts +++ b/tests/utils/database.test.ts @@ -5,6 +5,7 @@ import { isMultiDatabaseSelection, getDatabaseList, getEffectiveDatabase, + buildTableRoutingParams, } from '../../src/utils/database'; import type { DriverCapabilities } from '../../src/types/plugins'; @@ -143,3 +144,60 @@ describe('getEffectiveDatabase', () => { expect(getEffectiveDatabase(['only'])).toBe('only'); }); }); + +describe('buildTableRoutingParams', () => { + // Regression guard: on schema-based multi-database (PostgreSQL) connections + // the metadata pool is keyed by database. Dropping `database` from + // get_columns/get_foreign_keys made the call hit the connection's primary + // database, return no columns/PK, and silently turn the grid read-only. + + it('routes a PostgreSQL multi-db tab to its schema AND database', () => { + expect(buildTableRoutingParams('inventory', 'erp_demo', 'public')).toEqual({ + schema: 'inventory', + database: 'erp_demo', + }); + }); + + it("prefers the tab's schema over the connection's active schema", () => { + // The active schema is a single global; each tab may view a different one. + expect(buildTableRoutingParams('inventory', 'erp_demo', 'sales')).toEqual({ + schema: 'inventory', + database: 'erp_demo', + }); + }); + + it('falls back to the active schema when the tab has none', () => { + expect(buildTableRoutingParams(undefined, 'erp_demo', 'public')).toEqual({ + schema: 'public', + database: 'erp_demo', + }); + }); + + it('omits database for flat multi-db (MySQL) tabs that carry no database', () => { + // MySQL connects server-wide; no per-database pool switch is needed, so the + // database key must NOT be emitted (it would otherwise regress that path). + expect(buildTableRoutingParams('myschema', undefined, null)).toEqual({ + schema: 'myschema', + }); + }); + + it('omits database when the tab database is null', () => { + expect(buildTableRoutingParams('public', null, null)).toEqual({ + schema: 'public', + }); + }); + + it('omits schema when neither tab nor active schema is set', () => { + expect(buildTableRoutingParams(null, 'erp_demo', null)).toEqual({ + database: 'erp_demo', + }); + }); + + it('returns an empty object when nothing is set (single-db connection)', () => { + expect(buildTableRoutingParams(undefined, undefined, null)).toEqual({}); + }); + + it('treats an empty-string schema as absent', () => { + expect(buildTableRoutingParams('', undefined, '')).toEqual({}); + }); +}); diff --git a/tests/utils/editor.test.ts b/tests/utils/editor.test.ts index 06da3bef..8b95aaca 100644 --- a/tests/utils/editor.test.ts +++ b/tests/utils/editor.test.ts @@ -238,6 +238,55 @@ describe("editor", () => { expect(result).toBeUndefined(); }); + + it("does not reuse a same-schema tab from a different database", () => { + // Schema-based multi-database (PostgreSQL): the same schema.table name + // exists in multiple databases; each tab routes to its own pool. Reusing + // across databases sent the query to the wrong database (relation-not-found). + const tabs: Tab[] = [ + createMockTab({ + id: "tab-other-db", + type: "table", + connectionId: "conn-1", + activeTable: "stock_levels", + schema: "inventory", + database: "other_db", + }), + ]; + + const result = findExistingTableTab( + tabs, + "conn-1", + "stock_levels", + "inventory", + "erp_demo", + ); + + expect(result).toBeUndefined(); + }); + + it("reuses a tab when table, schema AND database all match", () => { + const tabs: Tab[] = [ + createMockTab({ + id: "tab-erp", + type: "table", + connectionId: "conn-1", + activeTable: "stock_levels", + schema: "inventory", + database: "erp_demo", + }), + ]; + + const result = findExistingTableTab( + tabs, + "conn-1", + "stock_levels", + "inventory", + "erp_demo", + ); + + expect(result?.id).toBe("tab-erp"); + }); }); describe("getConnectionTabs", () => { diff --git a/tests/utils/tabCleaner.test.ts b/tests/utils/tabCleaner.test.ts index bd737bd5..33788259 100644 --- a/tests/utils/tabCleaner.test.ts +++ b/tests/utils/tabCleaner.test.ts @@ -169,6 +169,32 @@ describe('tabCleaner', () => { expect(cleaned.sortClause).toBe('created_at DESC'); expect(cleaned.limitClause).toBe(50); }); + + it('persists the schema and database for multi-database tabs', () => { + // Regression: `database` was dropped on save, so a restored PostgreSQL + // multi-database table tab lost its pool and queried the primary + // database (relation-not-found). + const tab: Tab = { + id: 'tab-pg', + title: 'stock_levels (erp_demo)', + type: 'table', + query: 'SELECT * FROM "inventory"."stock_levels"', + page: 1, + activeTable: 'stock_levels', + pkColumns: ['id'], + connectionId: 'conn-1', + schema: 'inventory', + database: 'erp_demo', + result: null, + error: '', + executionTime: null, + }; + + const cleaned = cleanTabForStorage(tab); + + expect(cleaned.schema).toBe('inventory'); + expect(cleaned.database).toBe('erp_demo'); + }); }); describe('restoreTabFromStorage', () => { @@ -361,5 +387,28 @@ describe('tabCleaner', () => { expect(restored.isLoading).toBe(false); expect(restored.selectedRows).toBeUndefined(); }); + + it('round-trips schema and database for multi-database table tabs', () => { + const originalTab: Tab = { + id: 'tab-pg-rt', + title: 'stock_levels (erp_demo)', + type: 'table', + query: 'SELECT * FROM "inventory"."stock_levels"', + page: 1, + activeTable: 'stock_levels', + pkColumns: ['id'], + connectionId: 'conn-1', + schema: 'inventory', + database: 'erp_demo', + result: null, + error: '', + executionTime: null, + }; + + const restored = restoreTabFromStorage(cleanTabForStorage(originalTab)); + + expect(restored.schema).toBe('inventory'); + expect(restored.database).toBe('erp_demo'); + }); }); }); From 89e50bf4733ef5d99cc1b41f74e28bd9291b843f Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Tue, 30 Jun 2026 21:24:50 +0200 Subject: [PATCH 03/12] feat(sidebar): TablePro-style active-schema dropdown for PostgreSQL multi-database connections Replace the per-schema tree nodes under a database with a single compact schema dropdown: pick one schema and its tables/views/routines render directly below, like TablePro. Adds a hideHeader mode to SidebarSchemaItem to render a schema's contents without its collapsible header, a resolveActiveSchema() helper (picked -> connection-active -> public/first), and optional triggerClassName/leadingIcon props on the shared Select so the sidebar trigger is compact with a schema icon. Adds the sidebar.schema key to all locales and tests for resolveActiveSchema. --- .../layout/sidebar/SidebarDatabaseItem.tsx | 61 ++++++++++++++++--- .../layout/sidebar/SidebarSchemaItem.tsx | 14 ++++- src/components/ui/Select.tsx | 19 ++++-- src/i18n/locales/de.json | 1 + src/i18n/locales/en.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh.json | 1 + src/utils/schema.ts | 22 +++++++ tests/utils/schema.test.ts | 35 +++++++++++ 13 files changed, 144 insertions(+), 15 deletions(-) diff --git a/src/components/layout/sidebar/SidebarDatabaseItem.tsx b/src/components/layout/sidebar/SidebarDatabaseItem.tsx index ec3cc2bf..5451457c 100644 --- a/src/components/layout/sidebar/SidebarDatabaseItem.tsx +++ b/src/components/layout/sidebar/SidebarDatabaseItem.tsx @@ -13,8 +13,10 @@ import { Network, Search, X, + Layers, } from "lucide-react"; import { Accordion } from "./Accordion"; +import { Select } from "../../ui/Select"; import { SidebarSchemaItem } from "./SidebarSchemaItem"; import { SidebarTableItem } from "./SidebarTableItem"; import { SidebarViewItem } from "./SidebarViewItem"; @@ -25,7 +27,7 @@ import type { TableColumn } from "../../../types/schema"; import type { ContextMenuData } from "../../../types/sidebar"; import type { DriverCapabilities } from "../../../types/plugins"; import { groupRoutinesByType } from "../../../utils/routines"; -import { formatObjectCount } from "../../../utils/schema"; +import { formatObjectCount, resolveActiveSchema } from "../../../utils/schema"; interface SidebarDatabaseItemProps { databaseName: string; @@ -144,6 +146,30 @@ export const SidebarDatabaseItem = ({ const isSchemaBased = schemaList !== undefined; const schemaDataMap = databaseData?.schemaDataMap ?? {}; + // TablePro-style active-schema picker: one schema is active per database and + // its objects render directly under a dropdown (no per-schema sub-nodes). The + // selection is local to this node; it defaults to the connection's active + // schema when that schema belongs to this database, otherwise the first one. + const [pickedSchema, setPickedSchema] = useState(null); + const effectiveSchema = resolveActiveSchema(pickedSchema, activeSchema, schemaList); + + // Lazily load the active schema's objects (the schema dropdown replaces the + // per-schema header whose toggle would otherwise trigger the load). + useEffect(() => { + if (!isSchemaBased || !isExpanded || !effectiveSchema) return; + const data = schemaDataMap[effectiveSchema]; + if (!data?.isLoaded && !data?.isLoading) { + onLoadDatabaseSchema?.(databaseName, effectiveSchema); + } + }, [ + isSchemaBased, + isExpanded, + effectiveSchema, + schemaDataMap, + databaseName, + onLoadDatabaseSchema, + ]); + // Auto-expand this database when it becomes the active one, e.g. after // picking a table from the Quick Navigator. Mirrors SidebarSchemaItem; done // during render (same-component setState) so the table item is mounted in @@ -267,13 +293,34 @@ export const SidebarDatabaseItem = ({ ) : (
- {schemaList?.map((schema) => ( + {/* TablePro-style active-schema dropdown: pick one schema; its + objects render directly below (no per-schema sub-nodes). */} +
+ setPickedSchema(s)} + placeholder={t("sidebar.schema")} + className="flex-1" + triggerClassName="px-3 py-1.5 text-sm" + disabled={isExporting} + /> +
+ )} + {/* Options */}