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/commands.rs b/src-tauri/src/commands.rs index c032bcfe..c3154445 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 @@ -2777,11 +2815,17 @@ pub async fn save_blob_to_file( pk_map: std::collections::HashMap, file_path: 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)?; + // On multi-database connections (e.g. PostgreSQL) route the blob lookup 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.save_blob_to_file( ¶ms, @@ -2804,11 +2848,17 @@ pub async fn fetch_blob_as_data_url( col_name: String, pk_map: std::collections::HashMap, schema: Option, + database: Option, ) -> Result { 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 the blob lookup 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?; let wire = drv .fetch_blob_as_data_url( @@ -3047,6 +3097,7 @@ pub async fn execute_query( limit: Option, page: Option, schema: Option, + database: Option, ) -> Result { log::info!( "Executing query on connection: {} | Query: {}", @@ -3059,7 +3110,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 { @@ -3133,6 +3189,7 @@ pub async fn execute_query_batch( limit: Option, page: Option, schema: Option, + database: Option, batch_id: Option, ) -> Result, String> { log::info!( @@ -3146,7 +3203,12 @@ pub async fn execute_query_batch( 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 whole batch 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?; @@ -3220,6 +3282,7 @@ pub async fn explain_query_plan( query: String, analyze: bool, schema: Option, + database: Option, ) -> Result { log::info!( "Explaining query on connection: {} | analyze: {} | Query: {}", @@ -3240,7 +3303,12 @@ pub async fn explain_query_plan( 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 explain 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 { @@ -3279,11 +3347,17 @@ pub async fn count_query( connection_id: String, query: String, schema: Option, + database: Option, ) -> Result { 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 count to the selected database's pool. + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let sanitized = query.trim().trim_end_matches(';').to_string(); @@ -3365,6 +3439,8 @@ pub async fn open_er_diagram_window( database_name: String, focus_table: Option, schema: Option, + database: Option, + schema_based: Option, ) -> Result<(), String> { use tauri::{WebviewUrl, WebviewWindowBuilder}; use urlencoding::encode; @@ -3392,16 +3468,31 @@ pub async fn open_er_diagram_window( url.push_str(&format!("&schema={}", encode(s))); } + // Schema-based multi-database (PostgreSQL): the database the schema lives in, + // distinct from `schema` and from `database_name` (a display label only), + // routes the diagram's metadata fetch to the right connection pool. + if let Some(db) = &database { + url.push_str(&format!("&database={}", encode(db))); + } + + // Tells the diagram page the driver organizes objects into schemas + // (PostgreSQL), so it can offer a schema picker. The page runs in its own + // window without the opener's capability context, hence the explicit flag. + if schema_based == Some(true) { + url.push_str("&schemaBased=1"); + } + // Derive a unique window label per (connection, database, schema) so that // diagrams for different databases on the same connection do not collide on a // shared label (which previously kept showing the first database's diagram). // Tauri window labels only allow a limited character set, so sanitize anything // else to '_'. let raw_label = format!( - "er-diagram:{}:{}:{}", + "er-diagram:{}:{}:{}:{}", connection_id, database_name, - schema.as_deref().unwrap_or("") + schema.as_deref().unwrap_or(""), + database.as_deref().unwrap_or("") ); let label: String = raw_label .chars() @@ -3550,13 +3641,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: {}", @@ -3581,6 +3676,7 @@ pub async fn get_view_definition( connection_id: String, view_name: String, schema: Option, + database: Option, ) -> Result { log::info!( "Fetching view definition for: {} on connection: {}", @@ -3591,7 +3687,12 @@ pub async fn get_view_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)?; + // On multi-database connections (e.g. PostgreSQL) route the lookup 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?; let result = drv @@ -3613,6 +3714,7 @@ pub async fn create_view( view_name: String, definition: String, schema: Option, + database: Option, ) -> Result<(), String> { log::info!( "Creating view: {} on connection: {}", @@ -3623,7 +3725,12 @@ pub async fn create_view( 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 the DDL 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?; let result = drv @@ -3645,6 +3752,7 @@ pub async fn alter_view( view_name: String, definition: String, schema: Option, + database: Option, ) -> Result<(), String> { log::info!( "Altering view: {} on connection: {}", @@ -3655,7 +3763,12 @@ pub async fn alter_view( 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 the DDL 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?; let result = drv @@ -3676,6 +3789,7 @@ pub async fn drop_view( connection_id: String, view_name: String, schema: Option, + database: Option, ) -> Result<(), String> { log::info!( "Dropping view: {} on connection: {}", @@ -3686,7 +3800,12 @@ pub async fn drop_view( 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 the DDL 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?; let result = drv.drop_view(¶ms, &view_name, schema.as_deref()).await; @@ -3705,6 +3824,7 @@ pub async fn get_view_columns( connection_id: String, view_name: String, schema: Option, + database: Option, ) -> Result, String> { log::info!( "Fetching view columns for: {} on connection: {}", @@ -3715,7 +3835,12 @@ pub async fn get_view_columns( 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 the lookup 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?; let result = drv @@ -3735,13 +3860,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 +3890,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 +3901,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()) @@ -3784,13 +3917,19 @@ pub async fn create_trigger( connection_id: String, trigger_sql: String, schema: Option, + database: Option, ) -> Result<(), String> { log::info!("Creating trigger on 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 the DDL 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?; let result = drv @@ -3812,6 +3951,7 @@ pub async fn drop_trigger( trigger_name: String, table_name: String, schema: Option, + database: Option, ) -> Result<(), String> { log::info!( "Dropping trigger: {} on connection: {}", @@ -3822,7 +3962,12 @@ pub async fn drop_trigger( 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 the DDL 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?; let result = drv @@ -3988,11 +4133,15 @@ pub async fn drop_index_action( table: String, index_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.drop_index(¶ms, &table, &index_name, schema.as_deref()) .await @@ -4005,11 +4154,15 @@ pub async fn drop_foreign_key_action( table: String, fk_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.drop_foreign_key(¶ms, &table, &fk_name, schema.as_deref()) .await diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 07242ad0..bc75469c 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -352,6 +352,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), }) @@ -459,6 +462,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 0ba089b1..40768f54 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(), }; @@ -1483,13 +1485,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/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/dump_commands.rs b/src-tauri/src/dump_commands.rs index 12b1f164..0e17a0a6 100644 --- a/src-tauri/src/dump_commands.rs +++ b/src-tauri/src/dump_commands.rs @@ -443,11 +443,18 @@ pub async fn import_database( connection_id: String, file_path: 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)?; + // Scope the import to the selected database on connections that expose + // multiple databases (e.g. PostgreSQL), so the statements run against the + // database the user picked rather than the connection's primary database. + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } let driver = saved_conn.params.driver.clone(); let pg_schema = schema.unwrap_or_else(|| "public".to_string()); let app_handle = app.clone(); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index f33e1551..bad0eac5 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -310,6 +310,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-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs index 0bfe3462..58996ece 100644 --- a/src-tauri/src/pool_manager.rs +++ b/src-tauri/src/pool_manager.rs @@ -293,13 +293,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 { @@ -968,57 +981,70 @@ pub async fn close_pool(params: &ConnectionParams) { close_pool_with_id(params, connection_id).await; } -/// Close a specific connection pool by connection_id -pub async fn close_pool_with_id(params: &ConnectionParams, connection_id: Option<&str>) { - let key = build_connection_key(params, connection_id); +/// Returns the keys under which pools for this connection may live: the exact +/// key for these params plus — for saved connections — every key sharing the +/// `{driver}:conn:{id}:` prefix. On multi-database connections (PostgreSQL, +/// MySQL) each selected database gets its own pool whose key embeds that +/// database name, so removing only the primary database's key would leak the +/// per-database pools of every other selected database. +pub(crate) fn keys_to_close

( + pools: &HashMap, + params: &ConnectionParams, + connection_id: Option<&str>, +) -> Vec { + let exact = build_connection_key(params, connection_id); + let mut keys: Vec = match connection_id { + Some(conn_id) => { + let prefix = format!("{}:conn:{}:", params.driver, conn_id); + pools + .keys() + .filter(|k| k.starts_with(&prefix)) + .cloned() + .collect() + } + None => Vec::new(), + }; + if !keys.contains(&exact) { + keys.push(exact); + } + keys +} +/// Close a specific connection pool by connection_id. +/// +/// For saved connections this closes EVERY pool belonging to the connection id +/// (one per selected database on multi-database connections), not just the +/// primary database's pool. +pub async fn close_pool_with_id(params: &ConnectionParams, connection_id: Option<&str>) { match params.driver.as_str() { "mysql" => { let mut pools = MYSQL_POOLS.write().await; - if let Some(pool) = pools.remove(&key) { - log::info!( - "Closing MySQL connection pool for: {} (key: {})", - params.database, - key - ); - pool.close().await; - log::info!( - "MySQL connection pool closed for: {} (key: {})", - params.database, - key - ); + for key in keys_to_close(&pools, params, connection_id) { + if let Some(pool) = pools.remove(&key) { + log::info!("Closing MySQL connection pool (key: {})", key); + pool.close().await; + log::info!("MySQL connection pool closed (key: {})", key); + } } } "postgres" => { let mut pools = POSTGRES_POOLS.write().await; - if let Some(pool) = pools.remove(&key) { - log::info!( - "Closing PostgreSQL connection pool for: {} (key: {})", - params.database, - key - ); - pool.close(); - log::info!( - "PostgreSQL connection pool closed for: {} (key: {})", - params.database, - key - ); + for key in keys_to_close(&pools, params, connection_id) { + if let Some(pool) = pools.remove(&key) { + log::info!("Closing PostgreSQL connection pool (key: {})", key); + pool.close(); + log::info!("PostgreSQL connection pool closed (key: {})", key); + } } } "sqlite" => { let mut pools = SQLITE_POOLS.write().await; - if let Some(pool) = pools.remove(&key) { - log::info!( - "Closing SQLite connection pool for: {} (key: {})", - params.database, - key - ); - pool.close().await; - log::info!( - "SQLite connection pool closed for: {} (key: {})", - params.database, - key - ); + for key in keys_to_close(&pools, params, connection_id) { + if let Some(pool) = pools.remove(&key) { + log::info!("Closing SQLite connection pool (key: {})", key); + pool.close().await; + log::info!("SQLite connection pool closed (key: {})", key); + } } } _ => {} diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs index 1b335db0..acd7fa8f 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; @@ -287,6 +287,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)] @@ -614,4 +643,73 @@ mod startup_script_tests { close_pool_with_id(¶ms, Some(&conn_id)).await; } + +} + +// --- keys_to_close: disconnect must sweep every per-database pool --- +#[cfg(test)] +mod keys_to_close_tests { + use crate::models::{ConnectionParams, DatabaseSelection}; + use crate::pool_manager::{build_connection_key, keys_to_close}; + use std::collections::HashMap; + + fn connection_params(driver: &str) -> ConnectionParams { + ConnectionParams { + driver: driver.to_string(), + host: Some("127.0.0.1".to_string()), + port: Some(5432), + username: Some("dec".to_string()), + password: Some("secret".to_string()), + database: DatabaseSelection::Single("dec".to_string()), + connection_id: Some("conn-1".to_string()), + ..Default::default() + } + } + + #[test] + fn keys_to_close_sweeps_all_databases_of_the_connection() { + let params = connection_params("postgres"); + // Simulate one pool per selected database (multi-database PostgreSQL) + // plus a pool belonging to a different connection and driver-alike keys + // that must NOT be closed. + let mut pools: HashMap = HashMap::new(); + pools.insert(build_connection_key(¶ms, Some("conn-1")), ()); + let mut other_db = params.clone(); + other_db.database = DatabaseSelection::Single("erp_demo".to_string()); + pools.insert(build_connection_key(&other_db, Some("conn-1")), ()); + let other_conn_key = build_connection_key(¶ms, Some("conn-2")); + pools.insert(other_conn_key.clone(), ()); + + let keys = keys_to_close(&pools, ¶ms, Some("conn-1")); + + assert_eq!(keys.len(), 2, "both conn-1 pools must be swept: {keys:?}"); + assert!(keys.iter().all(|k| k.contains(":conn:conn-1:"))); + assert!( + !keys.contains(&other_conn_key), + "pools of other connections must survive a disconnect" + ); + } + + #[test] + fn keys_to_close_without_connection_id_uses_exact_key_only() { + let mut params = connection_params("postgres"); + params.connection_id = None; + let mut pools: HashMap = HashMap::new(); + let exact = build_connection_key(¶ms, None); + pools.insert(exact.clone(), ()); + + let keys = keys_to_close(&pools, ¶ms, None); + assert_eq!(keys, vec![exact]); + } + + #[test] + fn keys_to_close_includes_exact_key_even_when_no_pool_matches() { + let params = connection_params("postgres"); + let pools: HashMap = HashMap::new(); + + // With no cached pools the exact key is still returned so the caller's + // `remove` is a no-op rather than a panic or a missed close. + let keys = keys_to_close(&pools, ¶ms, Some("conn-1")); + assert_eq!(keys, vec![build_connection_key(¶ms, Some("conn-1"))]); + } } diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index ddf18508..2a03c960 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -82,7 +82,7 @@ import { groupRoutinesByType } from "../../utils/routines"; import { formatObjectCount } from "../../utils/schema"; import { groupByDate, formatHistoryTime } from "../../utils/dateGroups"; import { SqlHighlight } from "../ui/SqlHighlight"; -import { isMultiDatabaseCapable } from "../../utils/database"; +import { isMultiDatabaseCapable, isSchemaBasedMultiDb } from "../../utils/database"; import { supportsManageTables } from "../../utils/driverCapabilities"; import { newConsoleForDatabase, newConsoleForTable } from "../../utils/newConsole"; import { @@ -134,6 +134,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar setSelectedDatabases, databaseDataMap, loadDatabaseData, + loadDatabaseSchemaData, refreshDatabaseData, connectionDataMap, connections, @@ -180,7 +181,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar label: string; data?: ContextMenuData; } | null>(null); - const [schemaModal, setSchemaModal] = useState<{ tableName: string; schema?: string } | null>(null); + const [schemaModal, setSchemaModal] = useState<{ tableName: string; schema?: string; database?: string } | null>(null); const [isCreateTableModalOpen, setIsCreateTableModalOpen] = useState(false); const [createTableTarget, setCreateTableTarget] = useState(DEFAULT_CREATE_TABLE_TARGET); const [isClipboardImportOpen, setIsClipboardImportOpen] = useState(false); @@ -188,6 +189,8 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar isOpen: boolean; tableName: string; column: TableColumn | null; + schema?: string; + database?: string; }>({ isOpen: false, tableName: "", column: null }); const [createIndexModal, setCreateIndexModal] = useState<{ isOpen: boolean; @@ -197,7 +200,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar isOpen: boolean; tableName: string; }>({ isOpen: false, tableName: "" }); - const [generateSQLModal, setGenerateSQLModal] = useState(null); + const [generateSQLModal, setGenerateSQLModal] = useState<{ tableName: string; schema?: string; database?: string } | null>(null); const setSidebarTab = onSidebarTabChange; const [historyToFavoriteSQL, setHistoryToFavoriteSQL] = useState(null); const [historyToFavoriteDB, setHistoryToFavoriteDB] = useState(null); @@ -236,6 +239,8 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar isOpen: boolean; viewName?: string; isNewView?: boolean; + schema?: string; + database?: string; }>({ isOpen: false }); const [triggerEditorModal, setTriggerEditorModal] = useState<{ @@ -243,6 +248,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar triggerName?: string; tableName?: string; schema?: string; + database?: string; isNewTrigger?: boolean; }>({ isOpen: false }); @@ -266,12 +272,54 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar setSchemaVersion((v) => v + 1); }; - const runQuery = (sql: string, queryName?: string, tableName?: string, preventAutoRun: boolean = false, schema?: string, readOnly?: boolean) => { + // Refreshes whichever sidebar node actually lists the table after a DROP TABLE + // (or similar table-list-changing action), so the deleted table disappears + // without a manual refresh-icon click. Bumping schemaVersion alone is not + // enough — it only re-fetches a table's own columns/FKs/indexes, not the + // tables/views list of the schema or database node it belongs to. + const refreshTableListAfterDrop = (schema?: string, database?: string) => { + if (database) { + // Schema-based multi-database (PostgreSQL): the table lives at + // database -> schema -> table. Refreshing the database node reloads its + // schema list and drops cached per-schema objects so they reload fresh. + refreshDatabaseData(database); + } else if (schema && isSchemaBasedMultiDb(activeCapabilities)) { + // Single-database PostgreSQL connection: the table lives directly under + // a schema node. + refreshSchemaData(schema); + } else if (schema && isMultiDatabaseCapable(activeCapabilities)) { + // Flat multi-database driver (MySQL): `schema` overloads the database name. + refreshDatabaseData(schema); + } else if (refreshTables) { + refreshTables(); + } + }; + + const runQuery = (sql: string, queryName?: string, tableName?: string, preventAutoRun: boolean = false, schema?: string, readOnly?: boolean, database?: string) => { navigate("/editor", { - state: { initialQuery: sql, queryName, tableName, preventAutoRun, schema, readOnly, targetConnectionId: activeConnectionId }, + state: { initialQuery: sql, queryName, tableName, preventAutoRun, schema, database, readOnly, targetConnectionId: activeConnectionId }, }); }; + // Runs a saved-query / history SQL scoped to the database it was saved with. + // Schema-based multi-database drivers (PostgreSQL) route by the tab's + // `database` field (the pool key), while flat drivers (MySQL) overload + // `schema` with the database name — putting a PostgreSQL database name in the + // `schema` slot would run `SET search_path TO ""` on the primary pool. + const runSavedSql = ( + sql: string, + queryName: string | undefined, + preventAutoRun: boolean, + db: string | null | undefined, + ) => { + const database = db ?? undefined; + if (isSchemaBasedMultiDb(activeCapabilities)) { + runQuery(sql, queryName, undefined, preventAutoRun, undefined, undefined, database); + } else { + runQuery(sql, queryName, undefined, preventAutoRun, database); + } + }; + // Notebook count for the tab badge — kept in sync with the active connection // and refreshed whenever notebooks change (save/rename/delete/import). const [notebookCount, setNotebookCount] = useState(0); @@ -394,6 +442,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 +510,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, @@ -580,6 +694,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar connectionName: activeConnectionName || "Unknown", databaseName: activeDatabaseName || "Unknown", ...(activeSchema ? { schema: activeSchema } : {}), + ...(activeCapabilities?.schemas === true ? { schemaBased: true } : {}), }); } catch (e) { console.error("Failed to open ER Diagram window:", e); @@ -619,6 +734,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar connectionName: activeConnectionName || "Unknown", databaseName: activeDatabaseName || "Unknown", ...(activeSchema ? { schema: activeSchema } : {}), + ...(activeCapabilities?.schemas === true ? { schemaBased: true } : {}), }); } catch (e) { console.error("Failed to open ER Diagram window:", e); @@ -729,7 +845,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar

setSelectedFavoriteId(q.id)} - onDoubleClick={() => runQuery(q.sql, q.name, undefined, false, q.database ?? undefined)} + onDoubleClick={() => runSavedSql(q.sql, q.name, false, q.database)} onContextMenu={(e) => handleContextMenu(e, "query", q.id, q.name, q) } @@ -772,7 +888,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar recoveryNotice={historyRecoveryNotice} onDismissRecoveryNotice={dismissHistoryRecoveryNotice} onDoubleClick={(entry) => { - runQuery(entry.sql, undefined, undefined, false, entry.database ?? undefined); + runSavedSql(entry.sql, undefined, false, entry.database); }} onContextMenu={(e, entry) => { handleContextMenu(e, "history", entry.id, entry.sql, entry as unknown as ContextMenuData); @@ -1067,8 +1183,8 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar onAddColumn={(t_name) => setModifyColumnModal({ isOpen: true, tableName: t_name, column: null }) } - onEditColumn={(t_name, c) => - setModifyColumnModal({ isOpen: true, tableName: t_name, column: c }) + onEditColumn={(t_name, c, nodeSchema, nodeDatabase) => + setModifyColumnModal({ isOpen: true, tableName: t_name, column: c, schema: nodeSchema, database: nodeDatabase }) } onAddIndex={(t_name) => setCreateIndexModal({ isOpen: true, tableName: t_name }) @@ -1289,12 +1405,18 @@ 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 }) } - onEditColumn={(t_name, c) => - setModifyColumnModal({ isOpen: true, tableName: t_name, column: c }) + onEditColumn={(t_name, c, nodeSchema, nodeDatabase) => + setModifyColumnModal({ isOpen: true, tableName: t_name, column: c, schema: nodeSchema, database: nodeDatabase }) } onAddIndex={(t_name) => setCreateIndexModal({ isOpen: true, tableName: t_name }) @@ -1358,6 +1480,14 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar connectionId: activeConnectionId || "", connectionName: activeConnectionName || "Unknown", databaseName: db, + // Schema-based multi-database (PostgreSQL): the node + // name is a database (pool key), not a schema — + // without this the diagram treats it as a schema on + // the primary pool and comes up empty. + ...(isSchemaBasedMultiDb(activeCapabilities) + ? { database: db } + : {}), + ...(activeCapabilities?.schemas === true ? { schemaBased: true } : {}), }); } catch (e) { console.error("Failed to open ER Diagram window:", e); @@ -1469,8 +1599,8 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar onAddColumn={(t_name) => setModifyColumnModal({ isOpen: true, tableName: t_name, column: null }) } - onEditColumn={(t_name, c) => - setModifyColumnModal({ isOpen: true, tableName: t_name, column: c }) + onEditColumn={(t_name, c, nodeSchema, nodeDatabase) => + setModifyColumnModal({ isOpen: true, tableName: t_name, column: c, schema: nodeSchema, database: nodeDatabase }) } onAddIndex={(t_name) => setCreateIndexModal({ isOpen: true, tableName: t_name }) @@ -1752,21 +1882,27 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar contextMenu.type === "table" ? (() => { const ctxSchema = contextMenu.data && "schema" in contextMenu.data ? contextMenu.data.schema : undefined; + // Schema-based multi-database (PostgreSQL): the table item stashes the + // database its metadata was fetched from in the context-menu payload, so + // every action below can route to that same connection pool. Absent for + // single-database connections and for flat multi-database drivers (MySQL), + // where `ctxSchema` already carries the database name. + const ctxDatabase = contextMenu.data && "database" in contextMenu.data ? contextMenu.data.database ?? undefined : undefined; return [ { label: t("sidebar.showData"), icon: PlaySquare, action: () => { const quotedTable = quoteTableRef(contextMenu.id, activeDriver, ctxSchema); - runQuery(`SELECT * FROM ${quotedTable}`, undefined, contextMenu.id, false, ctxSchema); + runQuery(`SELECT * FROM ${quotedTable}`, undefined, contextMenu.id, false, ctxSchema, undefined, ctxDatabase); }, }, { label: t("sidebar.newConsole"), icon: FileCode, action: () => { - const spec = newConsoleForTable(contextMenu.id, activeDriver, ctxSchema); - runQuery(spec.sql, spec.title, undefined, true, spec.schema); + const spec = newConsoleForTable(contextMenu.id, activeDriver, ctxSchema, ctxDatabase); + runQuery(spec.sql, spec.title, undefined, true, spec.schema, undefined, spec.database); }, }, { @@ -1774,13 +1910,13 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar icon: Hash, action: () => { const quotedTable = quoteTableRef(contextMenu.id, activeDriver, ctxSchema); - runQuery(`SELECT COUNT(*) as count FROM ${quotedTable}`, undefined, undefined, false, ctxSchema); + runQuery(`SELECT COUNT(*) as count FROM ${quotedTable}`, undefined, undefined, false, ctxSchema, undefined, ctxDatabase); }, }, { label: t("sidebar.viewSchema"), icon: FileText, - action: () => setSchemaModal({ tableName: contextMenu.id, schema: ctxSchema }), + action: () => setSchemaModal({ tableName: contextMenu.id, schema: ctxSchema, database: ctxDatabase }), }, activeCapabilities?.no_connection_required !== true ? { label: t("sidebar.viewERDiagram"), @@ -1790,9 +1926,11 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar await invoke("open_er_diagram_window", { connectionId: activeConnectionId || "", connectionName: activeConnectionName || "Unknown", - databaseName: activeDatabaseName || "Unknown", + databaseName: ctxDatabase || activeDatabaseName || "Unknown", focusTable: contextMenu.id, ...(ctxSchema ? { schema: ctxSchema } : {}), + ...(ctxDatabase ? { database: ctxDatabase } : {}), + ...(activeCapabilities?.schemas === true ? { schemaBased: true } : {}), }); } catch (e) { console.error("Failed to open ER Diagram window:", e); @@ -1802,7 +1940,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar supportsManageTables(activeCapabilities) ? { label: t("sidebar.generateSQL"), icon: FileCode, - action: () => setGenerateSQLModal(contextMenu.id), + action: () => setGenerateSQLModal({ tableName: contextMenu.id, schema: ctxSchema, database: ctxDatabase }), } : null, supportsManageTables(activeCapabilities) ? { label: t("clipboardImport.contextMenuLabel"), @@ -1818,7 +1956,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar label: t("sidebar.addColumn"), icon: Plus, action: () => - setModifyColumnModal({ isOpen: true, tableName: contextMenu.id, column: null }), + setModifyColumnModal({ isOpen: true, tableName: contextMenu.id, column: null, schema: ctxSchema, database: ctxDatabase }), } : null, supportsManageTables(activeCapabilities) ? { label: t("sidebar.deleteTable"), @@ -1837,8 +1975,10 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar connectionId: activeConnectionId, query: `DROP TABLE ${quotedTable}`, ...(ctxSchema ? { schema: ctxSchema } : {}), + ...(ctxDatabase ? { database: ctxDatabase } : {}), }); - if (refreshTables) refreshTables(); + refreshTableListAfterDrop(ctxSchema, ctxDatabase); + setSchemaVersion((v) => v + 1); } catch (e) { console.error(e); showAlert(t("sidebar.failDeleteTable") + String(e), { kind: "error" }); @@ -1863,6 +2003,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar if (contextMenu.data && "tableName" in contextMenu.data) { const t_name = contextMenu.data.tableName; const ctxSchema = "schema" in contextMenu.data ? contextMenu.data.schema : undefined; + const ctxDatabase = "database" in contextMenu.data ? contextMenu.data.database : undefined; if ( await ask( t("sidebar.deleteIndexConfirm", { name: contextMenu.id }), @@ -1875,6 +2016,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar table: t_name, indexName: contextMenu.id, ...(ctxSchema ? { schema: ctxSchema } : {}), + ...(ctxDatabase ? { database: ctxDatabase } : {}), }); setSchemaVersion((v) => v + 1); } catch (e) { @@ -1903,6 +2045,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar if (contextMenu.data && "tableName" in contextMenu.data) { const t_name = contextMenu.data.tableName; const ctxSchema = "schema" in contextMenu.data ? contextMenu.data.schema : undefined; + const ctxDatabase = "database" in contextMenu.data ? contextMenu.data.database : undefined; if ( await ask( t("sidebar.deleteFkConfirm", { name: contextMenu.id }), @@ -1915,6 +2058,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar table: t_name, fkName: contextMenu.id, ...(ctxSchema ? { schema: ctxSchema } : {}), + ...(ctxDatabase ? { database: ctxDatabase } : {}), }); setSchemaVersion((v) => v + 1); } catch (e) { @@ -1955,14 +2099,19 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar : [] : contextMenu.type === "view" ? (() => { + // The node's own schema/database (set by SidebarViewItem) + // route every action to the view's schema and, on + // schema-based multi-database connections (PostgreSQL), + // to its database's connection pool. const viewCtxSchema = contextMenu.data && "schema" in contextMenu.data ? contextMenu.data.schema : undefined; + const viewCtxDatabase = contextMenu.data && "database" in contextMenu.data ? contextMenu.data.database : undefined; return [ { label: t("sidebar.showData"), icon: PlaySquare, action: () => { const quotedView = quoteTableRef(contextMenu.id, activeDriver, viewCtxSchema); - runQuery(`SELECT * FROM ${quotedView}`, undefined, contextMenu.id); + runQuery(`SELECT * FROM ${quotedView}`, undefined, contextMenu.id, false, viewCtxSchema, undefined, viewCtxDatabase); }, }, { @@ -1970,14 +2119,14 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar icon: Hash, action: () => { const quotedView = quoteTableRef(contextMenu.id, activeDriver, viewCtxSchema); - runQuery(`SELECT COUNT(*) as count FROM ${quotedView}`); + runQuery(`SELECT COUNT(*) as count FROM ${quotedView}`, undefined, undefined, false, viewCtxSchema, undefined, viewCtxDatabase); }, }, { label: t("sidebar.editView"), icon: Edit, action: () => { - setViewEditorModal({ isOpen: true, viewName: contextMenu.id, isNewView: false }); + setViewEditorModal({ isOpen: true, viewName: contextMenu.id, isNewView: false, schema: viewCtxSchema, database: viewCtxDatabase }); }, }, { @@ -1997,10 +2146,12 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar ) ) { try { + const dropSchema = viewCtxSchema ?? activeSchema; await invoke("drop_view", { connectionId: activeConnectionId, viewName: contextMenu.id, - ...(activeSchema ? { schema: activeSchema } : {}), + ...(dropSchema ? { schema: dropSchema } : {}), + ...(viewCtxDatabase ? { database: viewCtxDatabase } : {}), }); if (refreshViews) refreshViews(); } catch (e) { @@ -2013,7 +2164,21 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar ]; })() : contextMenu.type === "routine" - ? [ + ? (() => { + // The node's own schema/database (set by + // SidebarRoutineItem) route the DDL lookup to the + // routine's schema and, on schema-based multi-database + // connections (PostgreSQL), to its database's pool. + const routineCtxSchema = + contextMenu.data && "schema" in contextMenu.data + ? (contextMenu.data.schema ?? undefined) + : undefined; + const routineCtxDatabase = + contextMenu.data && "database" in contextMenu.data + ? (contextMenu.data.database ?? undefined) + : undefined; + const routineSchema = routineCtxSchema ?? activeSchema ?? undefined; + return [ { label: t("sidebar.viewDefinition"), icon: FileText, @@ -2027,9 +2192,10 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar connectionId: activeConnectionId, routineName: contextMenu.id, routineType: routineType, - ...(activeSchema ? { schema: activeSchema } : {}), + ...(routineSchema ? { schema: routineSchema } : {}), + ...(routineCtxDatabase ? { database: routineCtxDatabase } : {}), }); - runQuery(definition, `${contextMenu.id} Definition`, undefined, true); + runQuery(definition, `${contextMenu.id} Definition`, undefined, true, routineCtxSchema, undefined, routineCtxDatabase); } catch (e) { console.error(e); showAlert( @@ -2044,13 +2210,19 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar icon: Copy, action: () => navigator.clipboard.writeText(contextMenu.id), }, - ] + ]; + })() : contextMenu.type === "trigger" ? (() => { + // The node's schema/database (set by SidebarTriggerItem) + // route every action to the trigger's schema and, on + // schema-based multi-database connections (PostgreSQL), + // to its database's connection pool. const triggerData = contextMenu.data && 'table_name' in contextMenu.data - ? contextMenu.data as unknown as TriggerInfo & { schema?: string } + ? contextMenu.data as unknown as TriggerInfo & { schema?: string; database?: string } : null; const triggerSchema = triggerData?.schema ?? activeSchema ?? undefined; + const triggerDatabase = triggerData?.database ?? undefined; return [ { label: t("sidebar.viewTriggerDefinition"), @@ -2062,8 +2234,9 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar triggerName: contextMenu.id, tableName: triggerData?.table_name ?? "", ...(triggerSchema ? { schema: triggerSchema } : {}), + ...(triggerDatabase ? { database: triggerDatabase } : {}), }); - runQuery(definition, `${contextMenu.id} Definition`, undefined, true, triggerSchema, true); + runQuery(definition, `${contextMenu.id} Definition`, undefined, true, triggerSchema, true, triggerDatabase); } catch (e) { console.error(e); showAlert( @@ -2082,6 +2255,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar triggerName: contextMenu.id, tableName: triggerData?.table_name, schema: triggerSchema, + database: triggerDatabase, isNewTrigger: false, }); }, @@ -2108,6 +2282,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar triggerName: contextMenu.id, tableName: triggerData?.table_name ?? "", ...(triggerSchema ? { schema: triggerSchema } : {}), + ...(triggerDatabase ? { database: triggerDatabase } : {}), }); if (refreshTriggers) refreshTriggers(); } catch (e) { @@ -2125,8 +2300,8 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar label: t("sidebar.newConsole"), icon: FileCode, action: () => { - const spec = newConsoleForDatabase(contextMenu.id); - runQuery(spec.sql, spec.title, undefined, true, spec.schema); + const spec = newConsoleForDatabase(contextMenu.id, isSchemaBasedMultiDb(activeCapabilities)); + runQuery(spec.sql, spec.title, undefined, true, spec.schema, undefined, spec.database); }, }, { @@ -2148,6 +2323,14 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar connectionId: activeConnectionId || "", connectionName: activeConnectionName || "Unknown", databaseName: contextMenu.id, + // Schema-based multi-database (PostgreSQL): the node + // name is a database (pool key), not a schema — + // without this the diagram treats it as a schema on + // the primary pool and comes up empty. + ...(isSchemaBasedMultiDb(activeCapabilities) + ? { database: contextMenu.id } + : {}), + ...(activeCapabilities?.schemas === true ? { schemaBased: true } : {}), }); } catch (e) { console.error("Failed to open ER Diagram window:", e); @@ -2172,17 +2355,17 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar { label: t("sidebar.insertToEditor"), icon: FileInput, - action: () => runQuery(historyEntry.sql, undefined, undefined, true, historyEntry.database ?? undefined), + action: () => runSavedSql(historyEntry.sql, undefined, true, historyEntry.database), }, { label: t("sidebar.runQuery"), icon: Play, - action: () => runQuery(historyEntry.sql, undefined, undefined, false, historyEntry.database ?? undefined), + action: () => runSavedSql(historyEntry.sql, undefined, false, historyEntry.database), }, { label: t("sidebar.openInNewTab"), icon: Plus, - action: () => runQuery(historyEntry.sql, undefined, undefined, true, historyEntry.database ?? undefined), + action: () => runSavedSql(historyEntry.sql, undefined, true, historyEntry.database), }, { label: t("sidebar.addToFavorites"), @@ -2218,7 +2401,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar action: () => { if (contextMenu.data && "sql" in contextMenu.data) { const sq = contextMenu.data as SavedQuery; - runQuery(sq.sql, sq.name, undefined, false, sq.database ?? undefined); + runSavedSql(sq.sql, sq.name, false, sq.database); } }, }, @@ -2249,6 +2432,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar isOpen={true} tableName={schemaModal.tableName} schema={schemaModal.schema} + database={schemaModal.database} onClose={() => setSchemaModal(null)} /> )} @@ -2308,6 +2492,8 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar tableName={modifyColumnModal.tableName} driver={activeDriver || "sqlite"} column={modifyColumnModal.column} + schema={modifyColumnModal.schema} + database={modifyColumnModal.database} /> )} @@ -2336,7 +2522,9 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar {generateSQLModal && ( setGenerateSQLModal(null)} /> )} @@ -2375,6 +2563,8 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar connectionId={activeConnectionId} viewName={viewEditorModal.viewName} isNewView={viewEditorModal.isNewView} + schema={viewEditorModal.schema} + database={viewEditorModal.database} onSuccess={() => { if (refreshViews) refreshViews(); }} @@ -2389,6 +2579,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar triggerName={triggerEditorModal.triggerName} tableName={triggerEditorModal.tableName} schema={triggerEditorModal.schema} + database={triggerEditorModal.database} driver={activeDriver ?? undefined} isNewTrigger={triggerEditorModal.isNewTrigger} onSuccess={() => { diff --git a/src/components/layout/sidebar/SidebarColumnItem.tsx b/src/components/layout/sidebar/SidebarColumnItem.tsx index 4cba26b8..a8b17b2d 100644 --- a/src/components/layout/sidebar/SidebarColumnItem.tsx +++ b/src/components/layout/sidebar/SidebarColumnItem.tsx @@ -18,6 +18,9 @@ interface SidebarColumnItemProps { onEdit: (column: TableColumn) => void; isView?: boolean; schema?: string; + /** Schema-based multi-database (PostgreSQL): routes column DDL (drop column) + * to this database's connection pool. */ + database?: string; canManage?: boolean; } @@ -30,6 +33,7 @@ export const SidebarColumnItem = ({ onEdit, isView = false, schema, + database, canManage, }: SidebarColumnItemProps) => { const { t } = useTranslation(); @@ -64,6 +68,7 @@ export const SidebarColumnItem = ({ connectionId, query, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }); onRefresh(); diff --git a/src/components/layout/sidebar/SidebarDatabaseItem.tsx b/src/components/layout/sidebar/SidebarDatabaseItem.tsx index ea7e9d1e..a9732ffc 100644 --- a/src/components/layout/sidebar/SidebarDatabaseItem.tsx +++ b/src/components/layout/sidebar/SidebarDatabaseItem.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { supportsManageTables } from "../../../utils/driverCapabilities"; import { useTranslation } from "react-i18next"; import { @@ -13,8 +13,11 @@ 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"; import { SidebarRoutineItem } from "./SidebarRoutineItem"; @@ -24,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; @@ -50,7 +53,14 @@ interface SidebarDatabaseItemProps { data?: ContextMenuData, ) => void; onAddColumn: (tableName: string) => void; - onEditColumn: (tableName: string, col: TableColumn) => void; + /** The node's schema/database are forwarded so the edit-column modal can + * route its DDL to the right schema and connection pool. */ + onEditColumn: ( + tableName: string, + col: TableColumn, + schema?: string, + database?: string, + ) => void; onAddIndex: (tableName: string) => void; onDropIndex: (tableName: string, indexName: string) => void; onAddForeignKey: (tableName: string) => void; @@ -62,6 +72,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 +114,12 @@ export const SidebarDatabaseItem = ({ onImport, onViewDiagram, capabilities, + onLoadDatabaseSchema, + onSchemaTableClick, + onSchemaTableDoubleClick, + onSchemaViewDoubleClick, + onSchemaRoutineDoubleClick, + onSchemaTriggerDoubleClick, }: SidebarDatabaseItemProps) => { const { t } = useTranslation(); @@ -121,6 +147,36 @@ 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 = useMemo(() => databaseData?.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 @@ -152,7 +208,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 +293,68 @@ export const SidebarDatabaseItem = ({ {t("sidebar.loadingSchema")}
+ ) : isSchemaBased ? ( + (schemaList?.length ?? 0) === 0 ? ( +
+ {t("sidebar.noSchemas")} +
+ ) : ( +
+ {/* 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 */}
) : isGeometric ? ( 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/components/ui/RowEditorSidebar.tsx b/src/components/ui/RowEditorSidebar.tsx index 8ec66a04..7f7cfcf3 100644 --- a/src/components/ui/RowEditorSidebar.tsx +++ b/src/components/ui/RowEditorSidebar.tsx @@ -24,6 +24,9 @@ interface RowEditorSidebarProps { tableName?: string | null; pkColumns?: string[] | null; schema?: string | null; + /** Schema-based multi-database (PostgreSQL): routes row-scoped backend calls + * (blob fetch/download) to this database's connection pool. */ + database?: string | null; } export const RowEditorSidebar = ({ @@ -44,6 +47,7 @@ export const RowEditorSidebar = ({ tableName, pkColumns, schema, + database, }: RowEditorSidebarProps) => { const { t } = useTranslation(); const { width, startResize } = useRowEditorResize(); @@ -179,6 +183,7 @@ export const RowEditorSidebar = ({ tableName={tableName} pkMap={pkMap} schema={schema} + database={database} /> { const { t } = useTranslation(); const { getSchema } = useEditor(); @@ -148,7 +152,7 @@ const SchemaDiagramContent = ({ setSelectedTable(null); }, []); - // Effetto per impostare il focus iniziale dalla URL + // Effect to set the initial focus from the URL useEffect(() => { const focusTable = searchParams.get("focusTable"); if (focusTable && allNodes.length > 0) { @@ -184,7 +188,7 @@ const SchemaDiagramContent = ({ setLoading(true); try { - const fetchedSchema = await getSchema(connectionId, undefined, schema); + const fetchedSchema = await getSchema(connectionId, undefined, schema, database); if (!isMounted) return; // Build nodes and edges with optimizations @@ -269,9 +273,10 @@ const SchemaDiagramContent = ({ setEdges, layoutDirection, schema, + database, ]); - // Effetto per filtrare i nodi quando una tabella è selezionata + // Effect to filter the nodes when a table is selected useEffect(() => { if (!selectedTable || allNodes.length === 0) { // Mostra tutti i nodi @@ -294,12 +299,12 @@ const SchemaDiagramContent = ({ } }); - // Filtra i nodi per mostrare solo la tabella selezionata e le sue relazioni + // Filter the nodes to show only the selected table and its relationships const filteredNodes = allNodes.filter((node) => relatedTables.has(node.id), ); - // Filtra gli edge per mostrare solo quelli tra le tabelle filtrate + // Filter the edges to show only those between the filtered tables const filteredEdges = allEdges.filter( (edge) => relatedTables.has(edge.source) && relatedTables.has(edge.target), @@ -446,18 +451,21 @@ interface SchemaDiagramProps { connectionId: string; refreshTrigger: number; schema?: string; + database?: string; } export const SchemaDiagram = ({ connectionId, refreshTrigger, schema, + database, }: SchemaDiagramProps) => ( ); diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx index 693733d6..c8b3c1a4 100644 --- a/src/components/ui/Select.tsx +++ b/src/components/ui/Select.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useCallback, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { Search, ChevronDown, X } from "lucide-react"; import clsx from "clsx"; @@ -15,6 +15,11 @@ interface SelectProps { hasError?: boolean; searchable?: boolean; labels?: Record; + /** Overrides the trigger's size/spacing classes (default: `px-3 py-2 text-sm`). + * Used to render a compact trigger, e.g. in the sidebar. */ + triggerClassName?: string; + /** Optional icon rendered before the selected value inside the trigger. */ + leadingIcon?: ReactNode; } export const Select = ({ @@ -29,6 +34,8 @@ export const Select = ({ hasError = false, searchable = true, labels, + triggerClassName, + leadingIcon, }: SelectProps) => { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -165,7 +172,8 @@ export const Select = ({ onClick={handleToggle} disabled={disabled} className={clsx( - "w-full bg-base border rounded px-3 py-2 text-sm text-primary flex items-center justify-between transition-colors", + "w-full bg-base border rounded text-primary flex items-center justify-between transition-colors", + triggerClassName ?? "px-3 py-2 text-sm", disabled ? "opacity-50 cursor-not-allowed border-default" : hasError @@ -174,11 +182,12 @@ export const Select = ({ isOpen && !disabled && !hasError ? "border-blue-500 ring-1 ring-blue-500" : "" )} > - - {value ? getLabel(value) : placeholder} + + {leadingIcon} + {value ? getLabel(value) : placeholder} 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/contexts/EditorContext.ts b/src/contexts/EditorContext.ts index 1692c788..d405a2ab 100644 --- a/src/contexts/EditorContext.ts +++ b/src/contexts/EditorContext.ts @@ -30,7 +30,7 @@ export interface EditorContextType { partial: Partial, ) => void; setActiveTabId: (id: string) => void; - getSchema: (connectionId: string, schemaVersion?: number, schema?: string) => Promise; + getSchema: (connectionId: string, schemaVersion?: number, schema?: string, database?: string) => Promise; } export const EditorContext = createContext(undefined); diff --git a/src/contexts/EditorProvider.tsx b/src/contexts/EditorProvider.tsx index 7db727cf..98fdb5fd 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); @@ -364,8 +365,11 @@ export const EditorProvider = ({ children }: { children: ReactNode }) => { connectionId: string, schemaVersion?: number, schema?: string, + database?: string, ): Promise => { - const cacheKey = schema ? `${connectionId}:${schema}` : connectionId; + const cacheKey = schema + ? `${connectionId}:${schema}:${database ?? ""}` + : connectionId; const cached = schemaCacheRef.current[cacheKey]; if (shouldUseCachedSchema(cached, schemaVersion)) { @@ -375,6 +379,7 @@ export const EditorProvider = ({ children }: { children: ReactNode }) => { const data = await invoke("get_schema_snapshot", { connectionId, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }); // Update cache in ref (no state update = no re-render) 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/i18n/locales/de.json b/src/i18n/locales/de.json index 2c71b48c..49aa165a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -111,6 +111,7 @@ "databases": "Datenbanken", "failGetRoutineDefinition": "Abrufen der Routinen-Definition fehlgeschlagen: ", "schemas": "Schemata", + "schema": "Schema", "noSchemas": "Keine Schemata gefunden", "loadingSchemas": "Schemata werden geladen...", "schemaLoadError": "Schemata konnten nicht geladen werden", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 85fb24fc..5502e39c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -124,6 +124,7 @@ "databases": "Databases", "failGetRoutineDefinition": "Failed to get routine definition: ", "schemas": "Schemas", + "schema": "Schema", "noSchemas": "No schemas found", "loadingSchemas": "Loading schemas...", "schemaLoadError": "Failed to load schemas", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ac679ce7..33081107 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -111,6 +111,7 @@ "databases": "Bases de Datos", "failGetRoutineDefinition": "Error al obtener la definición de la rutina: ", "schemas": "Esquemas", + "schema": "Esquema", "noSchemas": "No se encontraron esquemas", "loadingSchemas": "Cargando esquemas...", "schemaLoadError": "No se pudieron cargar los esquemas", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2420cb4f..c70e3a65 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -111,6 +111,7 @@ "databases": "Bases de données", "failGetRoutineDefinition": "Échec de récupération de la définition de la routine : ", "schemas": "Schémas", + "schema": "Schéma", "noSchemas": "Aucun schéma trouvé", "loadingSchemas": "Chargement des schémas...", "schemaLoadError": "Échec du chargement des schémas", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 5b7df5ff..1d500eca 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -111,6 +111,7 @@ "databases": "Database", "failGetRoutineDefinition": "Impossibile recuperare la definizione della routine: ", "schemas": "Schemi", + "schema": "Schema", "noSchemas": "Nessuno schema trovato", "loadingSchemas": "Caricamento schemi...", "schemaLoadError": "Caricamento degli schemi fallito", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9914178f..0590e0e2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -124,6 +124,7 @@ "databases": "データベース", "failGetRoutineDefinition": "ルーチン定義の取得に失敗しました: ", "schemas": "スキーマ", + "schema": "スキーマ", "noSchemas": "スキーマが見つかりません", "loadingSchemas": "スキーマを読み込み中...", "schemaLoadError": "スキーマの読み込みに失敗しました", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 54cc92ab..8bd4a546 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -124,6 +124,7 @@ "databases": "Базы данных", "failGetRoutineDefinition": "Не удалось получить определение подпрограммы: ", "schemas": "Схемы", + "schema": "Схема", "noSchemas": "Схемы не найдены", "loadingSchemas": "Загрузка схем...", "schemaLoadError": "Не удалось загрузить схемы", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 059d7dfb..7a3af370 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -110,6 +110,7 @@ "databases": "数据库", "failGetRoutineDefinition": "获取存储过程定义失败:", "schemas": "模式", + "schema": "模式", "noSchemas": "未找到模式", "loadingSchemas": "加载模式中...", "schemaLoadError": "加载模式失败", diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 26cfc6e2..27fef478 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, isSchemaBasedMultiDb, buildTableRoutingParams } from "../utils/database"; import { isReadonly } from "../utils/driverCapabilities"; import { generateTempId, @@ -138,6 +138,7 @@ interface EditorState { preventAutoRun?: boolean; readOnly?: boolean; schema?: string; + database?: string; targetConnectionId?: string; title?: string; } @@ -288,6 +289,13 @@ export const Editor = () => { title: `Console - ${tab.title}`, query: query, connectionId: tab.connectionId, + // Preserve the source tab's routing so the console runs against the same + // pool: schema-based multi-database (PostgreSQL) needs `database` (the + // pool key) plus the real `schema`; flat multi-database (MySQL) carries + // the database name in `schema`. Dropping these ran the console against + // the connection's primary database (relation-not-found). + schema: tab.schema, + database: tab.database, }); }, [addTab, activeDriver, activeCapabilities?.schemas], @@ -365,6 +373,24 @@ export const Editor = () => { const isNotebookTab = activeTab?.type === "notebook"; const isMultiDb = isMultiDatabaseCapable(activeCapabilities) && selectedDatabases.length > 1; + // Schema-based multi-database (PostgreSQL): a non-table tab targets a database + // via its `database` field (the pool key), NOT by overloading `schema` with + // the database name the way flat drivers (MySQL) do. execute_query routes to + // the selected database's pool by `database`; a `schema` equal to a database + // name would instead run `SET search_path TO ""` on the primary pool and + // fail with relation-not-found. + const isSchemaBasedConn = isSchemaBasedMultiDb(activeCapabilities); + // The database the active non-table tab currently targets. + const activeTabDatabase = + (isSchemaBasedConn ? activeTab?.database : activeTab?.schema) || + selectedDatabases[0]; + // Initial routing params for a newly created non-table tab on a multi-database + // connection (see isSchemaBasedConn above for the schema-vs-database split). + const initialTabDatabaseParams = (): { schema?: string; database?: string } => { + if (!isMultiDb) return {}; + const db = selectedDatabases[0]; + return isSchemaBasedConn ? { database: db } : { schema: db }; + }; const isEditorOpen = !isTableTab && (activeTab?.isEditorOpen ?? activeTab?.type !== "table"); @@ -389,7 +415,9 @@ export const Editor = () => { let dbDisplay: string; if (isMultiDb) { dbDisplay = - activeTab?.schema ?? selectedDatabases[0] ?? activeDatabaseName; + (isSchemaBasedConn ? activeTab?.database : activeTab?.schema) ?? + selectedDatabases[0] ?? + activeDatabaseName; } else { dbDisplay = activeDatabaseName; } @@ -404,11 +432,13 @@ export const Editor = () => { }, [ activeTabId, activeTab?.schema, + activeTab?.database, activeConnectionName, activeDatabaseName, activeSchema, activeCapabilities, isMultiDb, + isSchemaBasedConn, selectedDatabases, ]); @@ -559,20 +589,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[]; @@ -736,9 +776,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 +800,7 @@ export const Editor = () => { limit: pageSize, page: pageNum, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }); const end = performance.now(); @@ -794,7 +839,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 }); } @@ -883,7 +933,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 +1031,7 @@ export const Editor = () => { page: 1, batchId, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }, ); } catch (err) { @@ -1038,6 +1091,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 +1110,7 @@ export const Editor = () => { limit: pageSize, page: pageNum, ...(schema ? { schema } : {}), + ...(database ? { database } : {}), }); const end = performance.now(); @@ -1116,6 +1171,9 @@ export const Editor = () => { connectionId: activeConnectionId, query: tab.query, schema: tab.schema ?? activeSchema, + // Schema-based multi-database (PostgreSQL): count on the tab's + // database pool, mirroring how execute_query routes the data query. + ...(tab.database ? { database: tab.database } : {}), }); const latest = tabsRef.current.find((t) => t.id === tab.id) ?? tab; if (!latest.result?.pagination) return; @@ -1591,14 +1649,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: "", @@ -1920,11 +1983,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) { @@ -2088,11 +2156,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(); @@ -2157,8 +2230,20 @@ 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 — but that + // fallback must never fire on schema-based drivers, where `tab.schema` + // is a PostgreSQL schema (e.g. "public"), not a database: sending it as + // `database` would route the write to a pool for a database named after + // the schema (connection failure, or worse the wrong database). + const editSchema = activeTab?.database ? (activeTab?.schema ?? activeSchema) : activeSchema; + const databaseParam = activeTab?.database + ? { database: activeTab.database } + : !isSchemaBasedConn && + isMultiDatabaseCapable(activeCapabilities) && + activeTab?.schema ? { database: activeTab.schema } : {}; @@ -2170,7 +2255,7 @@ export const Editor = () => { connectionId: activeConnectionId, table: activeTable, pkMap, - ...(activeSchema ? { schema: activeSchema } : {}), + ...(editSchema ? { schema: editSchema } : {}), ...databaseParam, }), ), @@ -2187,7 +2272,7 @@ export const Editor = () => { pkMap: u.pkVal, colName: u.colName, newVal: u.newVal, - ...(activeSchema ? { schema: activeSchema } : {}), + ...(editSchema ? { schema: editSchema } : {}), ...databaseParam, }), ), @@ -2202,7 +2287,7 @@ export const Editor = () => { connectionId: activeConnectionId, table: activeTable, data: insertion.data, - ...(activeSchema ? { schema: activeSchema } : {}), + ...(editSchema ? { schema: editSchema } : {}), ...databaseParam, }), ), @@ -2272,6 +2357,7 @@ export const Editor = () => { applyToAll, activeSchema, activeCapabilities, + isSchemaBasedConn, showAlert, ]); @@ -2488,7 +2574,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 +2597,7 @@ export const Editor = () => { preventAutoRun, readOnly: navReadOnly, schema: navSchema, + database: navDatabase, title: navTitle, } = state; const tabId = addTab({ @@ -2519,6 +2606,7 @@ export const Editor = () => { query: sql, activeTable: table, schema: navSchema, + database: navDatabase, readOnly: navReadOnly, }); @@ -2647,12 +2735,16 @@ export const Editor = () => { }); setExportMenuOpen(false); - // On multi-database connections (e.g. MySQL) scope the export to the - // selected database so the query runs against the database the user is - // viewing rather than the connection's primary database. The tab may not - // carry its own schema (e.g. a console query), so fall back to the active - // database — mirroring how execute_query resolves the schema. - const targetDatabase = activeTab?.schema ?? activeSchema ?? undefined; + // On multi-database connections scope the export to the database the + // user is viewing rather than the connection's primary database, + // mirroring how execute_query routes. Schema-based drivers (PostgreSQL) + // carry the database separately on the tab — `tab.schema` there is a + // PostgreSQL schema, never a database name — while flat drivers (MySQL) + // overload `schema` with the database name, falling back to the active + // database for tabs that don't carry one (e.g. a console query). + const targetDatabase = isSchemaBasedConn + ? activeTab?.database + : (activeTab?.schema ?? activeSchema ?? undefined); const databaseParam = isMultiDatabaseCapable(activeCapabilities) && targetDatabase ? { database: targetDatabase } @@ -2860,7 +2952,7 @@ export const Editor = () => { {tab.title} {tab.type === "console" && isMultiDb && ( - ({tab.schema || selectedDatabases[0]}) + ({(isSchemaBasedConn ? tab.database : tab.schema) || selectedDatabases[0]}) )} @@ -2894,7 +2986,7 @@ export const Editor = () => { onClick={() => addTab({ type: "console", - ...(isMultiDb ? { schema: selectedDatabases[0] } : {}), + ...initialTabDatabaseParams(), }) } className="flex items-center justify-center w-9 h-full text-muted hover:text-primary hover:bg-surface-secondary border-l border-default transition-colors shrink-0" @@ -2917,7 +3009,7 @@ export const Editor = () => { addTab({ type: "notebook", notebookId, - ...(isMultiDb ? { schema: selectedDatabases[0] } : {}), + ...initialTabDatabaseParams(), }); }} className="flex items-center justify-center w-9 h-full text-orange-400 hover:text-primary hover:bg-surface-secondary border-l border-default transition-colors shrink-0" @@ -3096,7 +3188,7 @@ export const Editor = () => { > - {activeTab.schema || selectedDatabases[0]} + {activeTabDatabase} @@ -3111,12 +3203,16 @@ export const Editor = () => {
{activeFkQuery && activeConnectionId && ( @@ -3796,7 +3894,8 @@ export const Editor = () => { activeFkQuery={activeFkQuery} connectionId={activeConnectionId} driver={activeDriver} - schema={activeSchema} + schema={activeTab?.schema ?? activeSchema} + database={activeTab?.database} onClose={() => setActiveFkQuery(null)} onNavigateToTab={handleForeignKeyNavigate} /> @@ -3868,9 +3967,25 @@ export const Editor = () => { setSaveQueryModal({ ...saveQueryModal, isOpen: false }) } initialSql={saveQueryModal.sql} - initialDatabase={activeTab?.schema ?? activeSchema ?? activeDatabaseName} + // Schema-based multi-database (PostgreSQL): the saved query's database + // is the tab's `database` (the pool key), never its PostgreSQL schema. + // Flat drivers (MySQL) keep overloading `schema` with the database name. + initialDatabase={ + isSchemaBasedConn + ? (activeTab?.database ?? activeDatabaseName) + : (activeTab?.schema ?? activeSchema ?? activeDatabaseName) + } databases={isMultiDb ? selectedDatabases : undefined} - onSave={async (name, sql, database) => await saveQuery(name, sql, database ?? activeTab?.schema ?? activeSchema ?? activeDatabaseName)} + onSave={async (name, sql, database) => + await saveQuery( + name, + sql, + database ?? + (isSchemaBasedConn + ? (activeTab?.database ?? activeDatabaseName) + : (activeTab?.schema ?? activeSchema ?? activeDatabaseName)), + ) + } title={t("editor.saveQuery")} /> )} @@ -3896,6 +4011,7 @@ export const Editor = () => { query={visualExplainQuery ?? activeTab?.query ?? ""} connectionId={activeConnectionId ?? ""} schema={activeTab?.schema ?? activeSchema ?? undefined} + database={activeTab?.database} /> { const connectionName = searchParams.get('connectionName') || 'Unknown'; const databaseName = searchParams.get('databaseName') || 'Unknown'; const schema = searchParams.get('schema') || undefined; + // Schema-based multi-database (PostgreSQL): the database the schema lives in, + // so the diagram's metadata fetch routes to the right connection pool. + const database = searchParams.get('database') || undefined; + // Schema-based driver (PostgreSQL): the opener sets this flag so the page — + // which runs in its own window without the opener's capability context — + // knows it can offer a schema picker. + const isSchemaBased = searchParams.get('schemaBased') === '1'; + + // Schema picker (schema-based drivers only): list the target database's + // schemas and let the user re-scope the diagram without reopening it. + const [availableSchemas, setAvailableSchemas] = useState([]); + const [pickedSchema, setPickedSchema] = useState(schema ?? null); + useEffect(() => { + if (!isSchemaBased || !connectionId) return; + let cancelled = false; + invoke('get_schemas', { + connectionId, + ...(database ? { database } : {}), + }) + .then((schemas) => { + if (cancelled) return; + setAvailableSchemas(schemas); + // Keep the URL's schema when valid, otherwise fall back sensibly + // ("public" or the first schema) so the diagram is never empty. + setPickedSchema((prev) => resolveActiveSchema(prev, schema, schemas)); + }) + .catch((e) => console.error('Failed to load schemas for diagram:', e)); + return () => { + cancelled = true; + }; + }, [isSchemaBased, connectionId, database, schema]); // On a single connection that exposes multiple databases (e.g. MySQL), the // diagram must be scoped to the selected database rather than the connection's // primary one. See resolveDiagramSchema for the full rationale. - const effectiveSchema = resolveDiagramSchema(schema, databaseName); + // + // When the driver is schema-based (PostgreSQL), the picker owns the schema + // and `databaseName` must NOT be reused as a schema fallback — it is a + // database name, and treating it as a schema would set search_path to a + // non-existent schema. + const effectiveSchema = isSchemaBased + ? (pickedSchema ?? schema) + : database + ? schema + : resolveDiagramSchema(schema, databaseName); const toggleFullscreen = () => { if (!document.fullscreenElement) { @@ -71,9 +114,25 @@ export const SchemaDiagramPage = () => { {/* Minimal Header */}

- {databaseName}{schema ? ` / ${schema}` : ''} ({connectionName}) + {databaseName} + {!isSchemaBased && schema ? ` / ${schema}` : ''} + {isSchemaBased && effectiveSchema ? ` / ${effectiveSchema}` : ''} + {` (${connectionName})`}

+ {/* Schema picker (schema-based drivers, i.e. PostgreSQL): re-scope + the diagram to another schema without reopening the window. */} + {isSchemaBased && availableSchemas.length > 0 && ( +