diff --git a/README.md b/README.md index 9d101c5..49ad2e4 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,217 @@ # RSQL -A high-performance PostgreSQL client built with Tauri v2, React, and Rust. Designed from the ground up to be fast — even with millions of rows. +A high-performance, open-source PostgreSQL client built with Tauri v2, React, and Rust. Designed from the ground up to be fast — even with millions of rows. -![Website screenshot](docs/rsql.png) +Free forever. No account, no telemetry, no feature gating. -## Important -App signing is in progress. To allow on macOS, use the following command: +![RSQL screenshot](docs/rsql.png) -```bash -xattr -dr com.apple.quarantine /Applications/RSQL.app -``` +> **macOS note:** App signing is in progress. To allow on macOS: +> ```bash +> xattr -dr com.apple.quarantine /Applications/RSQL.app +> ``` + +--- +## Features + +### Query Editor +- **Monaco SQL editor** — syntax highlighting, context-aware autocomplete (schemas, tables, columns, aliases), SQL snippets, SQL formatter +- **Multi-tab interface** — open multiple queries side by side, split editor mode +- **Query history** — searchable execution history with timing, row counts, and timestamps (last 500 queries) +- **Workspaces** — save and restore groups of tabs across sessions +- **Query timeout** — configurable per-query timeout (5s to 10min) +- **Multi-statement execution** — `SELECT 1; INSERT ...; SELECT * FROM users;` handled natively + +### Results +- **WebGL canvas grid** — `@glideapps/glide-data-grid` renders directly to canvas. Zero DOM nodes per cell. Smooth 60fps scrolling through millions of rows +- **Virtual pagination** — server-side cursors with 2,000-row pages. Only ~24 pages kept in memory at any time. 5M+ rows, same frontend memory as 1K rows +- **Inline editing** — click to edit cells. Generates `UPDATE`/`DELETE` with proper quoting and transactions +- **Record view** — form-style single-row viewer for wide tables +- **Column sorting & filtering** — full-text search across results (debounced at 200ms) +- **Result pinning & diff** — pin a result, run another query, see added/removed/changed rows (diff computed in Rust) +- **Export** — CSV, JSON, SQL INSERT, Markdown, XML. Copy to clipboard or save to file +- **CSV import** — import CSV files with column mapping preview + +### Schema Explorer +- **Tree sidebar** — schemas, tables, views, materialized views, functions, trigger functions, indexes, constraints, triggers, rules, RLS policies +- **Object properties** — detailed modal with columns, indexes, foreign keys, generated DDL, and a visual structure editor (ALTER TABLE builder) +- **ERD diagrams** — auto-generated entity-relationship diagrams with FK lines, drag-and-drop layout, SVG export +- **FK navigation** — click any foreign key value in the grid to jump directly to the referenced row +- **Schema diff** — compare two schemas side by side, see modified/added/removed objects +- **Command palette** — `Cmd+K` fuzzy search across tables, views, functions, connections, actions, and workspaces + +### PostGIS & Spatial +- **Map view** — automatic detection of geometry/geography columns (WKT, GeoJSON, EWKB). Rendered on OpenStreetMap tiles via Leaflet with Point, LineString, and Polygon support + +### Performance & Monitoring +- **EXPLAIN visualizer** — `EXPLAIN (ANALYZE, FORMAT JSON)` rendered as an interactive plan tree with cost breakdown, row estimates vs actuals, and timing per node +- **Performance monitor** — dedicated dashboard with tabs: + - **Overview** — database-level statistics + - **Activity** — live `pg_stat_activity` (active sessions, running queries) + - **Tables** — seq scans, index scans, inserts, updates, deletes, dead tuples, last vacuum/analyze + - **Indexes** — index usage statistics + - **Locks** — active lock monitoring + - **Bloat** — table bloat detection + - **History** — query execution timeline + +### Administration +- **Roles panel** — view roles with permission grants +- **Extensions panel** — installed and available PostgreSQL extensions +- **Enums panel** — browse ENUM types and their values +- **PG settings** — view all PostgreSQL configuration parameters +- **LISTEN/NOTIFY** — subscribe to channels, send notifications, discover channels from triggers + +### Developer Tools +- **Inline terminal** — built-in PTY terminal via `portable-pty` + `xterm.js`. Run psql, migrations, or any shell command without leaving the app +- **DDL generation** — generate `CREATE` statements for any database object +- **OS notifications** — notify on long-running queries (>5s) when the app is unfocused + +### Connection Management +- **Multiple connections** — manage and switch between databases +- **SSH tunnels** — connect to remote databases through SSH (password and key file auth) via native Rust `russh` +- **SSL/TLS** — secure connections with `postgres-native-tls` +- **Connection pooling** — dual pool: 16 connections for queries, 8 for metadata. Query and metadata traffic never block each other +- **Test connection** — verify connectivity before saving + +--- ## Why It's Fast -### Zero-Copy Wire Protocol -Queries use PostgreSQL's **simple_query protocol** — the server returns all values as pre-formatted text. No type conversion, no ORM mapping, no intermediate representations. Raw text goes straight from the TCP socket to the frontend. +RSQL is not just another Electron wrapper around a web UI. Every layer of the stack is optimized for throughput and responsiveness. ### Packed Binary IPC -Results are encoded as flat strings with ASCII unit/record separators (`\x1F` / `\x1E`), not nested JSON arrays. This eliminates JSON serialization overhead entirely for result data. A 100K-row result serializes in microseconds, not milliseconds. +Results are encoded as flat strings with ASCII unit/record separators (`\x1F` / `\x1E`), not nested JSON arrays. A 100K-row result serializes in microseconds. No per-cell quoting, no array nesting, no JSON overhead. -### Pre-Allocated String Packing -Row packing uses a single pre-allocated `String` buffer with capacity estimation. No intermediate `Vec` per row, no `.join()` chains, no `.replace()` allocations. Separator sanitization is done inline, character by character. - -### Virtual Pagination with Server-Side Cursors -Large results (>2K rows) use PostgreSQL cursors with `FETCH` batching. Pages are pre-packed into cache-friendly strings on the Rust side. Page serving is O(1) — zero packing at read time. Only pages near the viewport are kept in memory; distant pages are evicted automatically. +### Zero-Copy Wire Protocol +Queries use PostgreSQL's `simple_query` protocol — the server returns all values as pre-formatted text. No type conversion, no ORM mapping, no intermediate representations. -### Dual Connection Pool -Each database connection maintains two TCP sockets: -- **Query connection** — user queries, EXPLAIN, virtual pagination -- **Metadata connection** — schema loading, table info, activity monitoring +### SIMD JSON Serialization +All IPC command responses use `sonic-rs` (SIMD-accelerated, AVX2/SSE4/NEON) instead of `serde_json`. Results are returned as raw `tauri::ipc::Response` — zero re-serialization by the framework. ~2-3x faster than serde_json for typical payloads. -This means metadata loads never block while a long query runs, and vice versa. +### Virtual Pagination with Server-Side Cursors +Large results use PostgreSQL cursors (`DECLARE CURSOR` + `FETCH FORWARD 10,000`). Pages of 2,000 rows are pre-packed into cache-friendly strings on the Rust side. Page serving is O(1) — zero processing at read time. The frontend keeps ~24 pages around the viewport; distant pages are LRU-evicted. No row limit on virtual pagination. ### WebGL Canvas Rendering -The results grid renders directly to a WebGL canvas via `@glideapps/glide-data-grid`. No DOM nodes per cell. Scrolling through 500K rows is as smooth as scrolling through 50. - -Virtual scroll invalidation uses `requestAnimationFrame` batching — multiple page fetches within one frame cause only one re-render. Theme override objects are pre-computed once, not re-created per cell. +The results grid renders to a single `` element via `@glideapps/glide-data-grid`. O(1) DOM complexity regardless of dataset size. No layout thrashing, GPU-accelerated paint. ### Parallel Processing -Results over 50K rows use Rayon for parallel page packing across CPU cores. Below that threshold, sequential processing is faster due to cache locality. +Results over 50K rows use `rayon` for parallel page packing across CPU cores. Below that threshold, sequential processing wins due to cache locality. -### Debounced Search -Full-text search across results is debounced at 200ms to avoid filtering 50K+ rows on every keystroke. +### Dual Connection Pool +Each database maintains two `deadpool-postgres` pools: +- **16 connections** for queries — user SQL, EXPLAIN, virtual pagination +- **8 connections** for metadata — schema loading, autocomplete, activity monitoring -### SIMD JSON Serialization -All IPC command responses bypass Tauri's default `serde_json` serializer. Instead, results are pre-serialized with `sonic-rs` (SIMD-accelerated) and returned as raw `tauri::ipc::Response` — zero re-serialization by the framework. ~3.5x faster than serde_json for typical payloads. +Metadata loads never block while a long query runs, and vice versa. -### Multi-Statement Execution -`simple_query` handles `SELECT 1; INSERT ...; SELECT * FROM users;` natively. Returns the last result set that had rows — no splitting or reparsing on the client side. +### Pre-Allocated String Packing +Row packing uses a single pre-allocated `String` buffer with capacity estimation. No intermediate `Vec` per row, no `.join()` chains. Separator sanitization is done inline, character by character. -## Features +--- -- **Monaco SQL editor** — syntax highlighting, context-aware autocomplete (schemas, tables, columns, aliases), SQL snippets, formatter -- **Results grid** — WebGL canvas, column sorting, inline editing (UPDATE/DELETE with transactions), export (CSV, JSON, SQL, Markdown, XML) -- **Database explorer** — tree sidebar with schemas, tables, views, materialized views, functions, triggers, indexes, constraints, policies -- **ERD diagrams** — interactive entity-relationship diagrams with FK lines, drag-and-drop, SVG export -- **FK navigation** — click foreign key values to jump to referenced rows -- **Map view** — automatic detection of PostGIS geometry/geography columns (WKT, GeoJSON, EWKB), rendered on OpenStreetMap tiles via Leaflet with Point, LineString, and Polygon support -- **EXPLAIN visualizer** — `EXPLAIN (ANALYZE, FORMAT JSON)` with plan tree rendering -- **Performance monitor** — live `pg_stat_activity`, database stats, table stats -- **Diff tool** — pin a result, run another query, see added/removed rows (diff computed in Rust) -- **Inline terminal** — built-in PTY terminal via `portable-pty` + `xterm.js` -- **Command palette** — Cmd+K/Cmd+P fuzzy search across all database objects, actions, and saved workspaces -- **Workspaces** — save and restore tab groups across sessions -- **Query history** — searchable execution history with timing and row counts -- **Notifications** — OS-level notifications for long-running queries (>5s) when app is unfocused +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Frontend (React 19 + TypeScript) │ +│ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │ +│ │ Monaco │ │ Leaflet │ │ Glide Data Grid │ │ +│ │ Editor │ │ Maps │ │ (WebGL Canvas) │ │ +│ └──────────┘ └──────────┘ └────────────────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │ +│ │ xterm.js│ │ cmdk │ │ Zustand Stores │ │ +│ │ Terminal │ │ Palette │ │ (State Mgmt) │ │ +│ └──────────┘ └──────────┘ └────────────────────┘ │ +├─────────────────────────────────────────────────────┤ +│ Tauri v2 IPC (packed binary \x1F/\x1E format) │ +├─────────────────────────────────────────────────────┤ +│ Backend (Rust) │ +│ ┌──────────────┐ ┌────────────┐ ┌─────────────┐ │ +│ │ tokio-postgres│ │ sonic-rs │ │ rayon │ │ +│ │ (zero-copy) │ │ (SIMD) │ │ (parallel) │ │ +│ └──────────────┘ └────────────┘ └─────────────┘ │ +│ ┌──────────────┐ ┌────────────┐ ┌─────────────┐ │ +│ │ deadpool │ │ russh │ │ libsql │ │ +│ │ (pooling) │ │ (SSH) │ │ (local db) │ │ +│ └──────────────┘ └────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` ## Tech Stack | Layer | Technology | |-------|-----------| -| Frontend | React 19, TypeScript, Zustand, Monaco Editor, Leaflet | -| UI | Tailwind CSS v4, shadcn/ui, oklch color system | -| Results Grid | @glideapps/glide-data-grid (WebGL canvas) | +| Frontend | React 19, TypeScript, Zustand, Tailwind CSS v4, shadcn/ui | +| Editor | Monaco Editor with monaco-sql-languages | +| Results Grid | @glideapps/glide-data-grid v6 (WebGL canvas) | +| Maps | Leaflet + OpenStreetMap | | Terminal | xterm.js + portable-pty | | Backend | Rust, Tauri v2, tokio-postgres (simple_query protocol) | -| Performance | sonic-rs (SIMD JSON), rayon (parallel packing), packed binary IPC, dual connection pool | +| Serialization | sonic-rs v0.5 (SIMD JSON) | +| Parallelism | rayon v1.11 | +| Connection Pool | deadpool-postgres v0.14 (16 query + 8 metadata) | +| SSH | russh v0.57 (native async Rust SSH) | +| Local Storage | libsql (SQLite — connections, queries, workspaces, page snapshots) | + +--- + +## Performance Numbers + +All numbers are verified from source code — not marketing estimates. + +| Metric | Value | Source | +|--------|-------|--------| +| Cursor fetch size | 10,000 rows/round-trip | `CURSOR_FETCH_SIZE` in common.rs | +| Page size | 2,000 rows/page | `VITE_PAGE_SIZE` default | +| Frontend cache window | ~24 pages in memory | results-panel.tsx | +| Concurrent page fetches | 6 parallel requests | results-panel.tsx | +| Query connection pool | 16 connections | deadpool config | +| Metadata connection pool | 8 connections | deadpool config | +| Parallel packing threshold | 50,000+ rows | rayon in common.rs | +| IPC format | `\x1F` cell / `\x1E` row separators | common.rs | +| Search debounce | 200ms | results-panel.tsx | +| Grid row height | 32px | results-grid.tsx | +| Column width | 80–400px (auto-calculated from first 100 rows) | results-grid.tsx | + +### vs. Competitors + +| | RSQL | pgAdmin | DBeaver | DataGrip | TablePlus | +|---|---|---|---|---|---| +| **Price** | **Free** | Free | $0/$250/yr | $229/yr | $99 | +| **Runtime** | System WebView | Python + browser | JVM (Java 21) | JVM | Native | +| **Binary size** | **~20 MB** | ~180 MB | ~200 MB | ~600 MB | ~40 MB | +| **Grid tech** | **Canvas (WebGL)** | DOM table | SWT native | Swing | Native | +| **Memory** | **~80–150 MB** | ~200–400 MB | ~500 MB–1 GB | ~700 MB–2 GB | ~100–200 MB | +| **EXPLAIN visualizer** | Yes | Yes | Yes | Partial | No | +| **PostGIS map** | Yes | No | Yes | No | No | +| **Built-in terminal** | Yes | No | No | Yes | No | +| **Command palette** | Yes | No | No | Yes | Yes | +| **Schema diff** | Yes | No | Pro only | Yes | No | +| **FK navigation** | Yes | No | Partial | Yes | No | +| **Canvas grid** | **Yes** | No | No | No | No | +| **Open source** | **Yes** | Yes | Community | No | No | + +--- + +## Roadmap + +Planned features, roughly in priority order: + +- [ ] **Safe mode / production guard** — color-coded connections (red=production, yellow=staging, green=dev), read-only mode for production, explicit confirm for DML/DDL +- [ ] **AI-powered text-to-SQL** — natural language → SQL with schema context, support for OpenAI/Claude/Ollama local models (bring your own key) +- [ ] **Inline charts** — bar, line, pie charts directly in the results panel for aggregate queries +- [ ] **Query parameterization** — detect `$1`/`:param` placeholders, show input panel, execute with native PG parameterized queries +- [ ] **Visual query builder** — drag tables from the schema browser, auto-generate JOINs, build WHERE clauses visually +- [ ] **Multi-format import** — Excel (.xlsx), Parquet, JSON array import via Rust crates (calamine, arrow/parquet) +- [ ] **Schema migration scripts** — generate runnable ALTER/CREATE migration scripts from schema diff results +- [ ] **Backup & restore GUI** — wrapper around pg_dump/pg_restore with format selection, schema/data-only options +- [ ] **RLS policy editor** — visual editor for Row-Level Security policies (USING/WITH CHECK expressions) +- [ ] **Local DuckDB execution** — run SQL against CSV/Parquet files without a PostgreSQL server +- [ ] **Vim-style navigation** — Monaco vim mode + keyboard-only grid/sidebar navigation +- [ ] **Multi-database support** — MySQL, SQLite, Redis via the existing `drivers/` architecture + +--- ## Development @@ -93,38 +228,38 @@ yarn tauri build ## Release Workflow -The release workflow (`.github/workflows/release.yml`) builds release artifacts, signs updater metadata, and currently signs Windows and Linux artifacts. macOS signing/notarization is intentionally still pending. +The release workflow (`.github/workflows/release.yml`) builds release artifacts, signs updater metadata, and currently signs Windows and Linux artifacts. macOS signing/notarization is in progress. + +The app checks GitHub Releases for updates via `https://github.com/rust-dd/rust-sql/releases/latest/download/latest.json`, with a manual "Check for Updates" action plus a silent startup check. -Updater support is now wired into the app runtime as well. The app checks GitHub Releases via `https://github.com/rust-dd/rust-sql/releases/latest/download/latest.json`, and the packaged build enables a manual "Check for Updates" action plus a silent startup check. +### Required Secrets -Required updater secrets: +**Updater:** +- `TAURI_UPDATER_PUBLIC_KEY` — public key from `yarn tauri signer generate` +- `TAURI_SIGNING_PRIVATE_KEY` — private updater signing key +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (optional) -- `TAURI_UPDATER_PUBLIC_KEY` (public key content generated by `yarn tauri signer generate`) -- `TAURI_SIGNING_PRIVATE_KEY` (path or content of the private updater signing key) -- Optional: `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` +**Windows:** +- `WINDOWS_CERTIFICATE` — base64-encoded `.pfx` +- `WINDOWS_CERTIFICATE_PASSWORD` +- `WINDOWS_TIMESTAMP_URL` (optional, defaults to `http://timestamp.digicert.com`) -Platform code signing / notarization secrets: +**Linux:** +- `TAURI_SIGNING_RPM_KEY` — ASCII-armored private GPG key +- `TAURI_SIGNING_RPM_KEY_PASSPHRASE` (optional) +- `APPIMAGETOOL_SIGN_PASSPHRASE` +- `SIGN_KEY` (optional — GPG key id for AppImage signing) -- Windows: `WINDOWS_CERTIFICATE` (base64-encoded `.pfx`) -- Windows: `WINDOWS_CERTIFICATE_PASSWORD` -- Windows optional: `WINDOWS_TIMESTAMP_URL` (defaults to `http://timestamp.digicert.com`) -- Linux: `TAURI_SIGNING_RPM_KEY` (ASCII-armored private GPG key) -- Linux optional: `TAURI_SIGNING_RPM_KEY_PASSPHRASE` -- Linux/AppImage: `APPIMAGETOOL_SIGN_PASSPHRASE` -- Linux optional: `SIGN_KEY` (specific GPG key id or fingerprint for AppImage signing) -- macOS later: `APPLE_CERTIFICATE` (base64-encoded `.p12` Developer ID Application certificate) -- macOS later: `APPLE_CERTIFICATE_PASSWORD` -- macOS later: `APPLE_SIGNING_IDENTITY` (for example: `Developer ID Application: Your Name (TEAMID)`) -- macOS later notarization option A: `APPLE_ID`, `APPLE_PASSWORD` (app-specific password), `APPLE_TEAM_ID` -- macOS later notarization option B: `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_P8` +**macOS (pending):** +- `APPLE_CERTIFICATE` — base64-encoded `.p12` Developer ID Application certificate +- `APPLE_CERTIFICATE_PASSWORD` +- `APPLE_SIGNING_IDENTITY` +- Notarization: `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID` or `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_P8` -Notes: +Push a tag like `v1.x.x` to trigger a release build. -- `bundle.createUpdaterArtifacts` is enabled, so release builds will generate signed updater artifacts and `latest.json`. -- The updater uses the latest published GitHub release. Draft releases are not visible to clients until you publish them. -- If you build locally without `TAURI_UPDATER_PUBLIC_KEY`, the app still builds, but the updater plugin stays disabled for that build. -- The current release workflow requires the updater secrets above, plus the Windows and Linux signing secrets listed here. macOS signing secrets are documented for the later notarized rollout. +--- -For manual runs (`workflow_dispatch`), provide the release tag explicitly, for example `v1.x.x`. +## License -After the updater secrets are configured, pushing a tag like `v1.x.x` builds release artifacts and publishes signed updater metadata. +Open source. See [LICENSE](LICENSE) for details. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..8f13332 --- /dev/null +++ b/biome.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": [ + "**", + "!**/dist", + "!**/node_modules", + "!**/target", + "!**/build", + "!**/.next", + "!**/*.css", + "!src-tauri/target" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100, + "lineEnding": "lf" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "warn", + "noArrayIndexKey": "warn", + "noUnknownAtRules": "off" + }, + "style": { + "noNonNullAssertion": "warn" + }, + "a11y": { + "noStaticElementInteractions": "off", + "useKeyWithClickEvents": "off", + "noSvgWithoutTitle": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "jsxQuoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all", + "arrowParentheses": "always", + "bracketSpacing": true, + "bracketSameLine": false + } + }, + "json": { + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": ["src/monaco/completion-provider/snippets.ts"], + "linter": { + "rules": { + "suspicious": { + "noTemplateCurlyInString": "off" + } + } + } + } + ] +} diff --git a/package.json b/package.json index ace97ac..d5d9f0a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,11 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "format": "biome format --write .", + "lint": "biome lint .", + "check": "biome check --write .", + "check:ci": "biome check ." }, "dependencies": { "@glideapps/glide-data-grid": "^6.0.3", @@ -40,6 +44,7 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@biomejs/biome": "2.4.15", "@tailwindcss/vite": "^4.1.13", "@tauri-apps/cli": "^2", "@types/leaflet": "^1.9.21", diff --git a/src-tauri/src/app_setup.rs b/src-tauri/src/app_setup.rs new file mode 100644 index 0000000..3b95dd2 --- /dev/null +++ b/src-tauri/src/app_setup.rs @@ -0,0 +1,209 @@ +use std::{collections::BTreeMap, sync::Arc}; +use tauri::Manager; +use tauri::menu::{AboutMetadata, MenuBuilder, SubmenuBuilder}; +use tokio::sync::Mutex; + +use crate::{AppState, LOCAL_DB_NAME, terminal, utils}; + +pub fn setup_app(app: &mut tauri::App) -> Result<(), Box> { + #[cfg(desktop)] + if let Some(pubkey) = option_env!("TAURI_UPDATER_PUBLIC_KEY") { + app.handle() + .plugin(tauri_plugin_updater::Builder::new().pubkey(pubkey).build())?; + } else { + tracing::info!( + "Updater disabled because TAURI_UPDATER_PUBLIC_KEY was not set at build time" + ); + } + + let app_handle = app.handle().clone(); + + tauri::async_runtime::block_on(async move { + let db_path = if cfg!(debug_assertions) { + LOCAL_DB_NAME.to_string() + } else { + let app_dir = app_handle + .path() + .app_data_dir() + .expect("Failed to resolve app data directory"); + std::fs::create_dir_all(&app_dir).ok(); + app_dir.join(LOCAL_DB_NAME).to_string_lossy().to_string() + }; + + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("Failed to open local database"); + + let conn = db.connect().expect("Failed to create connection"); + conn.execute( + "CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + driver TEXT NOT NULL DEFAULT 'PGSQL', + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + database TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL DEFAULT '', + port TEXT NOT NULL DEFAULT '', + ssl TEXT NOT NULL DEFAULT 'false' + )", + (), + ) + .await + .expect("Failed to create projects table"); + + conn.execute( + "CREATE TABLE IF NOT EXISTS queries ( + id TEXT PRIMARY KEY, + sql TEXT NOT NULL DEFAULT '' + )", + (), + ) + .await + .expect("Failed to create queries table"); + + conn.execute( + "CREATE TABLE IF NOT EXISTS workspaces ( + name TEXT PRIMARY KEY, + tabs TEXT NOT NULL DEFAULT '[]' + )", + (), + ) + .await + .expect("Failed to create workspaces table"); + + conn.execute( + "CREATE TABLE IF NOT EXISTS virtual_query_snapshots ( + query_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + sql TEXT NOT NULL, + columns_packed TEXT NOT NULL DEFAULT '', + total_rows INTEGER NOT NULL DEFAULT 0, + page_size INTEGER NOT NULL DEFAULT 0, + col_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + )", + (), + ) + .await + .expect("Failed to create virtual_query_snapshots table"); + + conn.execute( + "CREATE TABLE IF NOT EXISTS virtual_query_pages ( + query_id TEXT NOT NULL, + page_index INTEGER NOT NULL, + packed_page TEXT NOT NULL DEFAULT '', + PRIMARY KEY (query_id, page_index) + )", + (), + ) + .await + .expect("Failed to create virtual_query_pages table"); + + // Best-effort orphan cleanup in case app exited before tab-close cleanup. + conn.execute( + "DELETE FROM virtual_query_pages + WHERE query_id NOT IN (SELECT query_id FROM virtual_query_snapshots)", + (), + ) + .await + .ok(); + + for col in [ + "ssh_enabled", + "ssh_host", + "ssh_port", + "ssh_user", + "ssh_password", + "ssh_key_path", + ] { + conn.execute( + &format!( + "ALTER TABLE projects ADD COLUMN {} TEXT NOT NULL DEFAULT ''", + col + ), + (), + ) + .await + .ok(); // Ignore "column already exists" errors + } + + let state = AppState { + clients: Arc::new(Mutex::new(BTreeMap::new())), + meta_clients: Arc::new(Mutex::new(BTreeMap::new())), + cancel_tokens: Arc::new(Mutex::new(BTreeMap::new())), + client_ssl: Arc::new(Mutex::new(BTreeMap::new())), + local_db: db, + resource_monitor: Arc::new(Mutex::new(utils::ResourceMonitor::new())), + virtual_cache: Arc::new(Mutex::new(BTreeMap::new())), + notify_handles: Arc::new(Mutex::new(BTreeMap::new())), + ssh_tunnels: Arc::new(Mutex::new(BTreeMap::new())), + }; + app_handle.manage(state); + + let terminal_state = terminal::TerminalState { + sessions: Arc::new(Mutex::new(std::collections::HashMap::new())), + }; + app_handle.manage(terminal_state); + }); + + let handle = app.handle(); + + let app_menu = SubmenuBuilder::new(handle, "RSQL") + .about(Some(AboutMetadata { + name: Some("RSQL".into()), + version: Some(env!("CARGO_PKG_VERSION").into()), + copyright: Some("\u{00a9} 2025 rust-dd".into()), + comments: Some( + "Modern SQL client for PostgreSQL.\nBuilt with Tauri, React, and Rust.".into(), + ), + website: Some("https://github.com/rust-dd/rust-sql".into()), + website_label: Some("GitHub".into()), + ..Default::default() + })) + .separator() + .services() + .separator() + .hide() + .hide_others() + .show_all() + .separator() + .quit() + .build()?; + + let edit_menu = SubmenuBuilder::new(handle, "Edit") + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .select_all() + .build()?; + + let view_menu = SubmenuBuilder::new(handle, "View").fullscreen().build()?; + + let window_menu = SubmenuBuilder::new(handle, "Window") + .minimize() + .maximize() + .separator() + .close_window() + .build()?; + + let menu = MenuBuilder::new(handle) + .items(&[&app_menu, &edit_menu, &view_menu, &window_menu]) + .build()?; + + handle.set_menu(menu)?; + + #[cfg(debug_assertions)] + { + let window = app + .get_webview_window("main") + .expect("main window not found"); + window.open_devtools(); + window.close_devtools(); + } + + Ok(()) +} diff --git a/src-tauri/src/dbs/query.rs b/src-tauri/src/dbs/query.rs index 8e0261e..6ae7a90 100644 --- a/src-tauri/src/dbs/query.rs +++ b/src-tauri/src/dbs/query.rs @@ -1,5 +1,5 @@ -use crate::common::enums::AppError; use crate::AppState; +use crate::common::enums::AppError; use std::collections::BTreeMap; use tauri::{AppHandle, Manager, Result, State}; diff --git a/src-tauri/src/dbs/workspace.rs b/src-tauri/src/dbs/workspace.rs index a0f0ceb..5a6ea44 100644 --- a/src-tauri/src/dbs/workspace.rs +++ b/src-tauri/src/dbs/workspace.rs @@ -1,5 +1,5 @@ -use crate::common::enums::AppError; use crate::AppState; +use crate::common::enums::AppError; use tauri::{Result, State}; #[tauri::command(rename_all = "snake_case")] diff --git a/src-tauri/src/drivers/common.rs b/src-tauri/src/drivers/common.rs deleted file mode 100644 index fb672e7..0000000 --- a/src-tauri/src/drivers/common.rs +++ /dev/null @@ -1,2137 +0,0 @@ -use deadpool_postgres::Pool; -use rayon::prelude::*; -use std::sync::Arc; -use std::time::Instant; -use tokio::time as tokio_time; -use tokio_postgres::{Client, SimpleQueryMessage}; - -use crate::common::enums::AppError; -use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; - -/// Safely get a pool Arc from the AppState client map. -/// Returns a cloned Arc so the caller can drop the MutexGuard immediately. -pub fn get_pool( - clients_guard: &std::collections::BTreeMap>, - project_id: &str, -) -> Result, AppError> { - clients_guard - .get(project_id) - .cloned() - .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string())) -} - -/// Process simple_query messages, returning the last result set that had rows. -/// If no result set had rows but commands ran, returns synthetic "N rows affected". -/// If nothing at all, returns empty vecs. -fn process_simple_messages(messages: Vec) -> (Vec, Vec>) { - let mut cur_columns: Vec = Vec::new(); - let mut cur_rows: Vec> = Vec::new(); - let mut last_columns: Vec = Vec::new(); - let mut last_rows: Vec> = Vec::new(); - let mut has_row_result = false; - let mut total_affected: u64 = 0; - - for msg in messages { - match msg { - SimpleQueryMessage::Row(row) => { - let col_count = row.columns().len(); - if cur_columns.is_empty() { - cur_columns = Vec::with_capacity(col_count); - for c in row.columns() { - cur_columns.push(c.name().to_owned()); - } - } - let mut cells = Vec::with_capacity(col_count); - for i in 0..col_count { - cells.push(row.get(i).unwrap_or("null").to_owned()); - } - cur_rows.push(cells); - } - SimpleQueryMessage::CommandComplete(n) => { - if !cur_rows.is_empty() { - last_columns = std::mem::take(&mut cur_columns); - last_rows = std::mem::take(&mut cur_rows); - has_row_result = true; - } else { - cur_columns.clear(); - cur_rows.clear(); - } - total_affected += n; - } - _ => {} - } - } - - // Handle trailing rows (shouldn't happen but be safe) - if !cur_rows.is_empty() { - return (cur_columns, cur_rows); - } - - if has_row_result { - (last_columns, last_rows) - } else if total_affected > 0 { - ( - vec!["Result".into()], - vec![vec![format!("{} rows affected", total_affected)]], - ) - } else { - (Vec::new(), Vec::new()) - } -} - -/// Execute a timed query and return (columns, rows_as_strings, elapsed_ms). -/// Uses simple_query protocol — PG returns all values as text, no type conversion needed. -/// Supports multi-statement: returns the last result set that had rows. -pub async fn execute_query( - client: &Client, - sql: &str, -) -> Result<(Vec, Vec>, f32), AppError> { - let start = Instant::now(); - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let (columns, rows) = process_simple_messages(messages); - let elapsed = start.elapsed().as_millis() as f32; - Ok((columns, rows, elapsed)) -} - -/// Cell separator for packed format (Unit Separator, ASCII 0x1F) -const CELL_SEP: char = '\x1F'; -/// Row separator for packed format (Record Separator, ASCII 0x1E) -const ROW_SEP: char = '\x1E'; - -/// Join string slices with a char separator — avoids .to_string() on the separator. -#[inline] -fn join_sep(items: &[String], sep: char) -> String { - let total: usize = items.iter().map(|s| s.len()).sum::() + items.len(); - let mut out = String::with_capacity(total); - for (i, item) in items.iter().enumerate() { - if i > 0 { - out.push(sep); - } - out.push_str(item); - } - out -} - -/// Events emitted during streamed query execution. -#[derive(serde::Serialize, Clone)] -#[serde(tag = "type")] -pub enum QueryStreamEvent { - #[serde(rename = "columns")] - Columns { columns: String, total_rows: usize }, - #[serde(rename = "chunk")] - Chunk { data: String }, - #[serde(rename = "done")] - Done { elapsed: f32, capped: bool }, -} - -/// Maximum rows to send to the frontend to prevent OOM in the webview. -const MAX_STREAM_ROWS: usize = 500_000; -/// Rows fetched per cursor FETCH round-trip. -const CURSOR_FETCH_SIZE: usize = 10_000; - -/// Execute a timed query and return results in compact packed string format. -/// Format: "col1\x1Fcol2\x1E row1val1\x1Frow1val2\x1E row2val1\x1Frow2val2" -/// Uses simple_query protocol with multi-statement support. -pub async fn execute_query_packed(client: &Client, sql: &str) -> Result<(String, f32), AppError> { - let start = Instant::now(); - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let (columns, rows) = process_simple_messages(messages); - - if columns.is_empty() { - return Ok((String::new(), start.elapsed().as_millis() as f32)); - } - - let header = join_sep(&columns, CELL_SEP); - let body = pack_rows_vec(&rows); - - let packed = if body.is_empty() { - header - } else { - let mut s = String::with_capacity(header.len() + 1 + body.len()); - s.push_str(&header); - s.push(ROW_SEP); - s.push_str(&body); - s - }; - let elapsed = start.elapsed().as_millis() as f32; - Ok((packed, elapsed)) -} - -/// Stream query results using a PostgreSQL cursor. -/// Fetches rows in batches from the server — never loads the full result into Rust memory. -/// Caps at MAX_STREAM_ROWS to protect the webview from OOM. -pub async fn execute_query_streamed( - client: &Client, - sql: &str, - stream_id: &str, - app: &tauri::AppHandle, -) -> Result<(), AppError> { - use tauri::Emitter; - - let start = Instant::now(); - let event_name = format!("query-stream-{}", stream_id); - - // Begin transaction + declare cursor for memory-efficient streaming - client - .batch_execute("BEGIN") - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let cursor_sql = format!("DECLARE _rsql_cur NO SCROLL CURSOR FOR {}", sql); - match client.batch_execute(&cursor_sql).await { - Ok(_) => { - // Cursor-based fetch loop using simple_query for zero type conversion - let fetch_sql = format!("FETCH {} FROM _rsql_cur", CURSOR_FETCH_SIZE); - let mut total_sent: usize = 0; - let mut columns_sent = false; - let mut capped = false; - - loop { - let messages = match client.simple_query(&fetch_sql).await { - Ok(msgs) => msgs, - Err(e) => { - let _ = client.batch_execute("CLOSE _rsql_cur; ROLLBACK").await; - return Err(AppError::QueryFailed(e.to_string())); - } - }; - - let mut batch_rows: Vec> = Vec::new(); - let mut batch_columns: Option> = None; - - for msg in messages { - if let SimpleQueryMessage::Row(row) = msg { - let col_count = row.columns().len(); - if batch_columns.is_none() { - let mut cols = Vec::with_capacity(col_count); - for c in row.columns() { - cols.push(c.name().to_owned()); - } - batch_columns = Some(cols); - } - let mut cells = Vec::with_capacity(col_count); - for i in 0..col_count { - cells.push(row.get(i).unwrap_or("null").to_owned()); - } - batch_rows.push(cells); - } - } - - if batch_rows.is_empty() { - break; - } - - // Emit columns on first batch - if !columns_sent && let Some(cols) = batch_columns { - let header = join_sep(&cols, CELL_SEP); - let _ = app.emit( - &event_name, - QueryStreamEvent::Columns { - columns: header, - total_rows: 0, - }, - ); - columns_sent = true; - } - - let packed = pack_rows_vec(&batch_rows); - let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); - - total_sent += batch_rows.len(); - if total_sent >= MAX_STREAM_ROWS { - capped = true; - break; - } - } - - // No rows at all - if !columns_sent { - let _ = app.emit( - &event_name, - QueryStreamEvent::Columns { - columns: String::new(), - total_rows: 0, - }, - ); - } - - // Clean up cursor + transaction - client.batch_execute("CLOSE _rsql_cur").await.ok(); - client.batch_execute("COMMIT").await.ok(); - - let elapsed = start.elapsed().as_millis() as f32; - let _ = app.emit(&event_name, QueryStreamEvent::Done { elapsed, capped }); - } - Err(_cursor_err) => { - // DECLARE CURSOR failed (non-SELECT query like INSERT/UPDATE/DDL) - client.batch_execute("ROLLBACK").await.ok(); - - // Re-execute with simple_query for multi-statement support - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let (columns, rows) = process_simple_messages(messages); - - if columns.is_empty() { - let _ = app.emit( - &event_name, - QueryStreamEvent::Columns { - columns: String::new(), - total_rows: 0, - }, - ); - } else { - let header = join_sep(&columns, CELL_SEP); - let _ = app.emit( - &event_name, - QueryStreamEvent::Columns { - columns: header, - total_rows: rows.len(), - }, - ); - - let packed = pack_rows_vec(&rows); - let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); - } - - let elapsed = start.elapsed().as_millis() as f32; - let _ = app.emit( - &event_name, - QueryStreamEvent::Done { - elapsed, - capped: false, - }, - ); - } - } - - Ok(()) -} - -/// A cached query: pre-packed page strings for zero-copy serving. -/// Each page is a single large String (~1-2 MB) so the OS reclaims RSS on drop. -pub struct CachedQuery { - pages: Vec, - page_size: usize, -} - -/// In-memory virtual cache: query_id → pre-packed pages. -pub type VirtualCache = std::collections::BTreeMap; - -/// Pack a slice of rows (each row = Vec) into wire format. -/// Pre-allocates capacity and writes directly — zero intermediate allocations. -fn pack_rows_vec(rows: &[Vec]) -> String { - if rows.is_empty() { - return String::new(); - } - // Estimate capacity: avg ~20 chars per cell - let est = rows.len() * rows.first().map_or(10, |r| r.len()) * 20; - let mut out = String::with_capacity(est); - - for (ri, row) in rows.iter().enumerate() { - if ri > 0 { - out.push(ROW_SEP); - } - for (ci, cell) in row.iter().enumerate() { - if ci > 0 { - out.push(CELL_SEP); - } - // Inline separator sanitization — avoids .replace() allocations - for ch in cell.chars() { - if ch == CELL_SEP || ch == ROW_SEP { - out.push(' '); - } else { - out.push(ch); - } - } - } - } - out -} - -/// Execute a query in one shot using simple_query protocol. -/// Pre-packs results into page-sized strings cached in-memory. -/// Returns (columns_packed, total_rows, first_page_packed, elapsed_ms). -/// If the SQL is non-SELECT / returns 0 rows, returns empty columns_packed signal -/// with a synthetic affected-rows message in first_page_packed when applicable. -pub async fn execute_virtual( - client: &Client, - cache: &tokio::sync::Mutex, - sql: &str, - query_id: &str, - page_size: usize, -) -> Result<(String, usize, String, f32), AppError> { - let start = Instant::now(); - - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let (columns, all_rows) = process_simple_messages(messages); - - // Non-SELECT or empty result - if columns.is_empty() { - let elapsed = start.elapsed().as_millis() as f32; - return Ok((String::new(), 0, String::new(), elapsed)); - } - - // Synthetic "N rows affected" result — pass through as fallback format - if columns.len() == 1 && columns[0] == "Result" { - let mut fallback = String::with_capacity(64); - fallback.push_str(&columns[0]); - fallback.push(ROW_SEP); - if let Some(r) = all_rows.first() { - fallback.push_str(&join_sep(r, CELL_SEP)); - } - let elapsed = start.elapsed().as_millis() as f32; - return Ok((String::new(), 0, fallback, elapsed)); - } - - let total_rows = all_rows.len(); - - // Pre-pack into pages — use rayon only for large results (>50K rows) - let chunks: Vec<&[Vec]> = all_rows.chunks(page_size).collect(); - let pages: Vec = if total_rows > 50_000 { - chunks - .par_iter() - .map(|chunk| pack_rows_vec(chunk)) - .collect() - } else { - chunks.iter().map(|chunk| pack_rows_vec(chunk)).collect() - }; - - let columns_packed = join_sep(&columns, CELL_SEP); - let first_page_packed = pages.first().cloned().unwrap_or_default(); - - // Store pre-packed pages in cache - { - let mut c = cache.lock().await; - c.insert(query_id.to_string(), CachedQuery { pages, page_size }); - } - - let elapsed = start.elapsed().as_millis() as f32; - Ok((columns_packed, total_rows, first_page_packed, elapsed)) -} - -/// Fetch a pre-packed page from the in-memory cache. O(1) — no packing at serve time. -pub async fn fetch_virtual_page( - cache: &tokio::sync::Mutex, - query_id: &str, - _col_count: usize, - offset: usize, - _limit: usize, -) -> Result { - let c = cache.lock().await; - let entry = c - .get(query_id) - .ok_or_else(|| AppError::QueryFailed(format!("Virtual query {} not found", query_id)))?; - - let page_index = offset / entry.page_size; - Ok(entry.pages.get(page_index).cloned().unwrap_or_default()) -} - -/// Remove a query from the in-memory cache. Large page strings are freed → OS reclaims RSS. -pub async fn close_virtual( - cache: &tokio::sync::Mutex, - query_id: &str, -) -> Result<(), AppError> { - let mut c = cache.lock().await; - c.remove(query_id); - Ok(()) -} - -/// Load schemas with a timeout. The query string is driver-specific. -pub async fn load_schemas(client: &Client, query_sql: &str) -> Result { - let rows = tokio_time::timeout( - tokio_time::Duration::from_secs(10), - client.query(query_sql, &[]), - ) - .await - .map_err(|_| AppError::QueryTimeout)? - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| r.get(0)).collect()) -} - -/// Load all user databases from pg_database. -pub async fn load_databases(pool: &Pool) -> Result, AppError> { - let client = pool.get().await.map_err(|e| AppError::DatabaseError(e.to_string()))?; - let rows = client - .query( - "SELECT datname FROM pg_database WHERE datallowconn = true AND datistemplate = false ORDER BY datname", - &[], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) -} - -/// Load tablespaces from the server. -pub async fn load_tablespaces(pool: &Pool) -> Result, AppError> { - let client = pool.get().await.map_err(|e| AppError::DatabaseError(e.to_string()))?; - let rows = client - .query( - "SELECT spcname, pg_catalog.pg_get_userbyid(spcowner) AS owner, \ - COALESCE(pg_catalog.pg_tablespace_location(oid), '') AS location \ - FROM pg_catalog.pg_tablespace ORDER BY spcname", - &[], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - Ok(rows.iter().map(|r| { - (r.get::<_, String>(0), r.get::<_, String>(1), r.get::<_, String>(2)) - }).collect()) -} - -/// Load tables for a given schema. -pub async fn load_tables( - client: &Client, - query_sql: &str, - schema: &str, -) -> Result { - let rows = client - .query(query_sql, &[&schema]) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| (r.get(0), r.get(1))).collect()) -} - -/// Load columns for a given schema and table. -pub async fn load_columns( - client: &Client, - schema: &str, - table: &str, -) -> Result { - let rows = client - .query( - r#"SELECT column_name - FROM information_schema.columns - WHERE table_schema = $1 AND table_name = $2 - ORDER BY ordinal_position"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) -} - -/// Column detail info: (name, data_type, nullable, default_value) -pub type ColumnDetail = (String, String, bool, Option); - -/// Load detailed column info for a given schema and table. -pub async fn load_column_details( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns - WHERE table_schema = $1 AND table_name = $2 - ORDER BY ordinal_position"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let data_type: String = r.get(1); - let nullable_str: String = r.get(2); - let default_val: Option = r.get(3); - (name, data_type, nullable_str == "YES", default_val) - }) - .collect()) -} - -/// Index info: (index_name, column_name, is_unique, is_primary) -pub type IndexDetail = (String, String, bool, bool); - -/// Load indexes for a given schema and table. -pub async fn load_indexes( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT - i.relname AS index_name, - a.attname AS column_name, - ix.indisunique AS is_unique, - ix.indisprimary AS is_primary - FROM pg_index ix - JOIN pg_class t ON t.oid = ix.indrelid - JOIN pg_class i ON i.oid = ix.indexrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) - WHERE n.nspname = $1 AND t.relname = $2 - ORDER BY i.relname, a.attnum"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let index_name: String = r.get(0); - let column_name: String = r.get(1); - let is_unique: bool = r.get(2); - let is_primary: bool = r.get(3); - (index_name, column_name, is_unique, is_primary) - }) - .collect()) -} - -/// Trigger info: (trigger_name, event, timing) -pub type TriggerDetail = (String, String, String); - -/// Load triggers for a given schema and table. -pub async fn load_triggers( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT DISTINCT trigger_name, event_manipulation, action_timing - FROM information_schema.triggers - WHERE trigger_schema = $1 AND event_object_table = $2 - ORDER BY trigger_name"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let event: String = r.get(1); - let timing: String = r.get(2); - (name, event, timing) - }) - .collect()) -} - -/// Rule info: (rule_name, event) -pub type RuleDetail = (String, String); - -/// Load rules for a given schema and table. -pub async fn load_rules( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT rulename, ev_type - FROM pg_rules - WHERE schemaname = $1 AND tablename = $2 - ORDER BY rulename"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let event: String = r.get(1); - (name, event) - }) - .collect()) -} - -/// Policy info: (policy_name, permissive, command) -pub type PolicyDetail = (String, String, String); - -/// Load RLS policies for a given schema and table. -pub async fn load_policies( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT pol.polname, - CASE WHEN pol.polpermissive THEN 'PERMISSIVE' ELSE 'RESTRICTIVE' END, - CASE pol.polcmd - WHEN 'r' THEN 'SELECT' - WHEN 'a' THEN 'INSERT' - WHEN 'w' THEN 'UPDATE' - WHEN 'd' THEN 'DELETE' - WHEN '*' THEN 'ALL' - ELSE pol.polcmd::text - END - FROM pg_policy pol - JOIN pg_class c ON c.oid = pol.polrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 AND c.relname = $2 - ORDER BY pol.polname"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let perm: String = r.get(1); - let cmd: String = r.get(2); - (name, perm, cmd) - }) - .collect()) -} - -/// View info: (view_name) -pub async fn load_views(client: &Client, schema: &str) -> Result, AppError> { - let rows = client - .query( - r#"SELECT table_name - FROM information_schema.views - WHERE table_schema = $1 - ORDER BY table_name"#, - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) -} - -/// Load materialized views for a schema. -pub async fn load_materialized_views( - client: &Client, - schema: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT matviewname - FROM pg_matviews - WHERE schemaname = $1 - ORDER BY matviewname"#, - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) -} - -/// Function info: (name, return_type, arguments) -pub type FunctionInfo = (String, String, String); - -/// Load functions for a schema (excluding trigger functions and aggregates). -pub async fn load_functions(client: &Client, schema: &str) -> Result, AppError> { - let rows = client - .query( - r#"SELECT p.proname, - pg_get_function_result(p.oid), - pg_get_function_arguments(p.oid) - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = $1 - AND p.prokind IN ('f', 'p') - AND pg_get_function_result(p.oid) != 'trigger' - ORDER BY p.proname"#, - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let ret: String = r.get(1); - let args: String = r.get(2); - (name, ret, args) - }) - .collect()) -} - -/// Load trigger functions for a schema (functions that return trigger). -pub async fn load_trigger_functions( - client: &Client, - schema: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT p.proname, - pg_get_function_arguments(p.oid) - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = $1 - AND pg_get_function_result(p.oid) = 'trigger' - ORDER BY p.proname"#, - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let args: String = r.get(1); - (name, args) - }) - .collect()) -} - -/// Database stats: (stat_name, stat_value) -pub type DbStat = (String, String); - -/// Load pg_stat_activity - active connections and queries. -pub async fn load_activity(client: &Client) -> Result>, AppError> { - let rows = client - .query( - r#"SELECT - pid::text, - COALESCE(usename, '') AS usename, - COALESCE(datname, '') AS datname, - COALESCE(state, 'unknown') AS state, - COALESCE(wait_event_type, '') AS wait_event_type, - COALESCE(wait_event, '') AS wait_event, - COALESCE(LEFT(query, 500), '') AS query, - COALESCE(EXTRACT(EPOCH FROM (now() - query_start))::text, '0') AS duration_sec, - COALESCE(backend_type, '') AS backend_type, - COALESCE(client_addr::text, 'local') AS client_addr - FROM pg_stat_activity - WHERE datname = current_database() - ORDER BY state, query_start NULLS LAST"#, - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -/// Load pg_stat_database - database-level stats. -pub async fn load_database_stats(client: &Client) -> Result, AppError> { - let rows = client - .query( - r#"SELECT - 'Active Connections' AS stat, numbackends::text AS val FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Transactions Committed', xact_commit::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Transactions Rolled Back', xact_rollback::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Blocks Read (disk)', blks_read::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Blocks Hit (cache)', blks_hit::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Cache Hit Ratio', - CASE WHEN (blks_hit + blks_read) > 0 - THEN ROUND(blks_hit::numeric / (blks_hit + blks_read) * 100, 2)::text || '%' - ELSE 'N/A' - END - FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Rows Returned', tup_returned::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Rows Fetched', tup_fetched::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Rows Inserted', tup_inserted::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Rows Updated', tup_updated::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Rows Deleted', tup_deleted::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Temp Files', temp_files::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Temp Bytes', pg_size_pretty(temp_bytes) FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Deadlocks', deadlocks::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Database Size', pg_size_pretty(pg_database_size(current_database()))"#, - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let val: String = r.get(1); - (name, val) - }) - .collect()) -} - -/// Load pg_stat_user_tables - table-level stats. -pub async fn load_table_stats(client: &Client) -> Result>, AppError> { - let rows = client - .query( - r#"SELECT - schemaname, - relname, - COALESCE(seq_scan, 0)::text AS seq_scan, - COALESCE(seq_tup_read, 0)::text AS seq_tup_read, - COALESCE(idx_scan, 0)::text AS idx_scan, - COALESCE(idx_tup_fetch, 0)::text AS idx_tup_fetch, - COALESCE(n_tup_ins, 0)::text AS inserts, - COALESCE(n_tup_upd, 0)::text AS updates, - COALESCE(n_tup_del, 0)::text AS deletes, - COALESCE(n_live_tup, 0)::text AS live_tuples, - COALESCE(n_dead_tup, 0)::text AS dead_tuples, - COALESCE(last_vacuum::text, 'never') AS last_vacuum, - COALESCE(last_autovacuum::text, 'never') AS last_autovacuum, - COALESCE(last_analyze::text, 'never') AS last_analyze - FROM pg_stat_user_tables - ORDER BY seq_scan + COALESCE(idx_scan, 0) DESC - LIMIT 100"#, - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..14).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -/// Constraint info: (constraint_name, constraint_type, column_name) -pub type ConstraintDetail = (String, String, String); - -/// Load constraints for a given schema and table. -pub async fn load_constraints( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT - tc.constraint_name, - tc.constraint_type, - COALESCE(kcu.column_name, '') - FROM information_schema.table_constraints tc - LEFT JOIN information_schema.key_column_usage kcu - ON kcu.constraint_name = tc.constraint_name - AND kcu.table_schema = tc.table_schema - AND kcu.table_name = tc.table_name - WHERE tc.table_schema = $1 AND tc.table_name = $2 - ORDER BY tc.constraint_name, kcu.ordinal_position"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let ctype: String = r.get(1); - let col: String = r.get(2); - (name, ctype, col) - }) - .collect()) -} - -/// FK relation: (source_table, source_column, target_table, target_column) -pub type ForeignKeyInfo = (String, String, String, String); - -/// Table statistics: Vec of (key, value) pairs -pub type ObjectStats = Vec<(String, String)>; - -/// FK detail: (constraint_name, source_schema, source_table, source_column, target_schema, target_table, target_column, on_update, on_delete) -pub type FKDetail = ( - String, - String, - String, - String, - String, - String, - String, - String, - String, -); - -/// Load live statistics for a table. -pub async fn load_table_statistics( - client: &Client, - schema: &str, - table: &str, -) -> Result { - let rows = client - .query( - r#"SELECT - c.reltuples::bigint::text, - pg_size_pretty(pg_table_size(c.oid)), - pg_size_pretty(pg_indexes_size(c.oid)), - pg_size_pretty(pg_total_relation_size(c.oid)), - COALESCE(s.last_vacuum::text, 'never'), - COALESCE(s.last_analyze::text, 'never'), - COALESCE(s.last_autovacuum::text, 'never'), - COALESCE(s.last_autoanalyze::text, 'never'), - COALESCE(s.n_dead_tup, 0)::text, - COALESCE(s.n_live_tup, 0)::text, - COALESCE(s.seq_scan, 0)::text, - COALESCE(s.idx_scan, 0)::text - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid - WHERE n.nspname = $1 AND c.relname = $2 - LIMIT 1"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let keys = [ - "row_estimate", - "table_size", - "index_size", - "total_size", - "last_vacuum", - "last_analyze", - "last_autovacuum", - "last_autoanalyze", - "dead_tuples", - "live_tuples", - "seq_scan", - "idx_scan", - ]; - - if let Some(row) = rows.first() { - Ok(keys - .iter() - .enumerate() - .map(|(i, k)| { - let val: Option = row.try_get(i).ok(); - (k.to_string(), val.unwrap_or_else(|| "-".into())) - }) - .collect()) - } else { - Ok(Vec::new()) - } -} - -/// Load outgoing or incoming FK details for a table. -pub async fn load_fk_details( - client: &Client, - schema: &str, - table: &str, - direction: &str, // "outgoing" or "incoming" -) -> Result, AppError> { - let where_clause = if direction == "incoming" { - "nsp_tgt.nspname = $1 AND tgt.relname = $2" - } else { - "nsp.nspname = $1 AND src.relname = $2" - }; - - let sql = format!( - r#"SELECT - con.conname, - nsp.nspname, - src.relname, - a_src.attname, - nsp_tgt.nspname, - tgt.relname, - a_tgt.attname, - CASE con.confupdtype - WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' - WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' - WHEN 'd' THEN 'SET DEFAULT' ELSE '' END, - CASE con.confdeltype - WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' - WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' - WHEN 'd' THEN 'SET DEFAULT' ELSE '' END - FROM pg_constraint con - JOIN pg_class src ON src.oid = con.conrelid - JOIN pg_namespace nsp ON nsp.oid = src.relnamespace - JOIN pg_class tgt ON tgt.oid = con.confrelid - JOIN pg_namespace nsp_tgt ON nsp_tgt.oid = tgt.relnamespace - JOIN pg_attribute a_src ON a_src.attrelid = con.conrelid AND a_src.attnum = ANY(con.conkey) - JOIN pg_attribute a_tgt ON a_tgt.attrelid = con.confrelid AND a_tgt.attnum = ANY(con.confkey) - WHERE con.contype = 'f' AND {where_clause} - ORDER BY con.conname"# - ); - - let rows = client - .query(&sql, &[&schema, &table]) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - ( - r.get(0), - r.get(1), - r.get(2), - r.get(3), - r.get(4), - r.get(5), - r.get(6), - r.get(7), - r.get(8), - ) - }) - .collect()) -} - -/// Load view metadata. -pub async fn load_view_info( - client: &Client, - schema: &str, - view: &str, -) -> Result { - let rows = client - .query( - r#"SELECT - COALESCE(v.is_updatable, 'NO'), - COALESCE(v.check_option, 'NONE'), - pg_get_viewdef(c.oid, true) - FROM information_schema.views v - JOIN pg_class c ON c.relname = v.table_name - JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = v.table_schema - WHERE v.table_schema = $1 AND v.table_name = $2 - LIMIT 1"#, - &[&schema, &view], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - if let Some(row) = rows.first() { - Ok(vec![ - ("is_updatable".into(), row.get::<_, String>(0)), - ("check_option".into(), row.get::<_, String>(1)), - ("definition".into(), row.get::<_, String>(2)), - ]) - } else { - Ok(Vec::new()) - } -} - -/// Load materialized view metadata. -pub async fn load_matview_info( - client: &Client, - schema: &str, - matview: &str, -) -> Result { - let sql = r#"SELECT - c.reltuples::bigint::text, - pg_size_pretty(pg_total_relation_size(c.oid)), - CASE WHEN m.ispopulated THEN 'YES' ELSE 'NO' END, - m.definition - FROM pg_matviews m - JOIN pg_class c ON c.relname = m.matviewname - JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = m.schemaname - WHERE m.schemaname = $1 AND m.matviewname = $2 - LIMIT 1"#; - - let rows = client - .query(sql, &[&schema, &matview]) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - if let Some(row) = rows.first() { - Ok(vec![ - ("row_estimate".into(), row.get::<_, String>(0)), - ("total_size".into(), row.get::<_, String>(1)), - ("is_populated".into(), row.get::<_, String>(2)), - ("definition".into(), row.get::<_, String>(3)), - ]) - } else { - Ok(Vec::new()) - } -} - -/// Load function metadata. -pub async fn load_function_info( - client: &Client, - schema: &str, - func_name: &str, -) -> Result { - let rows = client - .query( - r#"SELECT - l.lanname, - CASE p.provolatile WHEN 'i' THEN 'IMMUTABLE' WHEN 's' THEN 'STABLE' WHEN 'v' THEN 'VOLATILE' ELSE '' END, - p.proisstrict::text, - p.prosecdef::text, - p.procost::text, - p.prorows::text, - pg_get_function_result(p.oid), - pg_get_function_arguments(p.oid), - p.prosrc - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - JOIN pg_language l ON l.oid = p.prolang - WHERE n.nspname = $1 AND p.proname = $2 - LIMIT 1"#, - &[&schema, &func_name], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let keys = [ - "language", - "volatility", - "is_strict", - "security_definer", - "estimated_cost", - "estimated_rows", - "return_type", - "arguments", - "source", - ]; - - if let Some(row) = rows.first() { - Ok(keys - .iter() - .enumerate() - .map(|(i, k)| { - let val: Option = row.try_get(i).ok(); - (k.to_string(), val.unwrap_or_default()) - }) - .collect()) - } else { - Ok(Vec::new()) - } -} - -/// Generate full DDL for an object. Returns lines of DDL as a single String. -pub async fn generate_full_ddl( - client: &Client, - schema: &str, - name: &str, - object_type: &str, // "table", "view", "matview", "function" -) -> Result { - match object_type { - "table" => generate_table_ddl(client, schema, name).await, - "view" => generate_view_ddl(client, schema, name).await, - "matview" => generate_matview_ddl(client, schema, name).await, - "function" | "trigger-function" => generate_function_ddl(client, schema, name).await, - _ => Err(AppError::QueryFailed(format!( - "Unknown object type: {}", - object_type - ))), - } -} - -async fn generate_table_ddl( - client: &Client, - schema: &str, - table: &str, -) -> Result { - // Use simple_query so we can handle the complex CTE in one shot - let sql = format!( - r#"WITH col_ddl AS ( - SELECT ordinal_position, - ' "' || column_name || '" ' || - CASE - WHEN udt_name = 'varchar' THEN 'character varying' || COALESCE('(' || character_maximum_length || ')', '') - WHEN udt_name = 'bpchar' THEN 'character' || COALESCE('(' || character_maximum_length || ')', '') - WHEN udt_name = 'numeric' AND numeric_precision IS NOT NULL THEN 'numeric(' || numeric_precision || COALESCE(',' || numeric_scale, '') || ')' - ELSE data_type - END || - CASE WHEN is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END || - CASE WHEN column_default IS NOT NULL THEN ' DEFAULT ' || column_default ELSE '' END AS col_def - FROM information_schema.columns - WHERE table_schema = '{schema}' AND table_name = '{table}' -) -SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# - ); - - let col_result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - let mut col_defs = String::new(); - for msg in &col_result { - if let SimpleQueryMessage::Row(row) = msg { - col_defs = row.get(0).unwrap_or("").to_string(); - } - } - - let mut ddl = format!("CREATE TABLE \"{schema}\".\"{table}\" (\n{col_defs}\n);\n"); - - // Helper: extract single-column text rows from simple_query results - fn collect_lines(messages: &[SimpleQueryMessage]) -> Vec { - let mut out = Vec::new(); - for msg in messages { - if let SimpleQueryMessage::Row(row) = msg { - if let Some(line) = row.get(0) { - if !line.is_empty() { - out.push(line.to_string()); - } - } - } - } - out - } - - // Constraints (PK, FK, UNIQUE, CHECK) - let con_sql = format!( - r#"SELECT 'ALTER TABLE "{schema}"."{table}" ADD CONSTRAINT "' || con.conname || '" ' || pg_get_constraintdef(con.oid) || ';' - FROM pg_constraint con - JOIN pg_class c ON c.oid = con.conrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '{schema}' AND c.relname = '{table}' - ORDER BY CASE con.contype WHEN 'p' THEN 0 WHEN 'u' THEN 1 WHEN 'f' THEN 2 ELSE 3 END"# - ); - let con_result = client - .simple_query(&con_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&con_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // Indexes (non-constraint) - let idx_sql = format!( - r#"SELECT pg_get_indexdef(i.indexrelid) || ';' - FROM pg_index i - JOIN pg_class tbl ON tbl.oid = i.indrelid - JOIN pg_namespace n ON n.oid = tbl.relnamespace - WHERE n.nspname = '{schema}' AND tbl.relname = '{table}' - AND NOT i.indisprimary - AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid)"# - ); - let idx_result = client - .simple_query(&idx_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&idx_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // Triggers - let trig_sql = format!( - r#"SELECT pg_get_triggerdef(t.oid) || ';' - FROM pg_trigger t - JOIN pg_class c ON c.oid = t.tgrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '{schema}' AND c.relname = '{table}' - AND NOT t.tgisinternal"# - ); - let trig_result = client - .simple_query(&trig_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&trig_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // RLS - let rls_sql = format!( - r#"SELECT CASE WHEN c.relrowsecurity THEN 'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY;' ELSE '' END - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '{schema}' AND c.relname = '{table}'"# - ); - let rls_result = client - .simple_query(&rls_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&rls_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // Policies - let pol_sql = format!( - r#"SELECT 'CREATE POLICY "' || pol.polname || '" ON "{schema}"."{table}"' || - CASE pol.polcmd WHEN 'r' THEN ' FOR SELECT' WHEN 'a' THEN ' FOR INSERT' WHEN 'w' THEN ' FOR UPDATE' WHEN 'd' THEN ' FOR DELETE' WHEN '*' THEN '' END || - CASE WHEN pol.polpermissive THEN ' AS PERMISSIVE' ELSE ' AS RESTRICTIVE' END || - COALESCE(E'\n USING (' || pg_get_expr(pol.polqual, pol.polrelid) || ')', '') || - COALESCE(E'\n WITH CHECK (' || pg_get_expr(pol.polwithcheck, pol.polrelid) || ')', '') || - ';' - FROM pg_policy pol - JOIN pg_class c ON c.oid = pol.polrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '{schema}' AND c.relname = '{table}'"# - ); - let pol_result = client - .simple_query(&pol_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&pol_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // Table comment - let cmt_sql = format!( - r#"SELECT 'COMMENT ON TABLE "{schema}"."{table}" IS ' || quote_literal(d.description) || ';' - FROM pg_description d - JOIN pg_class c ON c.oid = d.objoid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '{schema}' AND c.relname = '{table}' AND d.objsubid = 0"# - ); - let cmt_result = client - .simple_query(&cmt_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&cmt_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // Column comments - let col_cmt_sql = format!( - r#"SELECT 'COMMENT ON COLUMN "{schema}"."{table}"."' || a.attname || '" IS ' || quote_literal(d.description) || ';' - FROM pg_description d - JOIN pg_class c ON c.oid = d.objoid - JOIN pg_namespace n ON n.oid = c.relnamespace - JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid - WHERE n.nspname = '{schema}' AND c.relname = '{table}' AND d.objsubid > 0 - ORDER BY d.objsubid"# - ); - let col_cmt_result = client - .simple_query(&col_cmt_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&col_cmt_result) { - ddl.push_str(&line); - ddl.push('\n'); - } - - Ok(ddl.trim_end().to_string()) -} - -async fn generate_view_ddl(client: &Client, schema: &str, view: &str) -> Result { - let sql = format!( - r#"SELECT 'CREATE OR REPLACE VIEW "{schema}"."{view}" AS' || E'\n' || pg_get_viewdef('"{schema}"."{view}"'::regclass, true) || ';'"# - ); - let result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for msg in &result { - if let SimpleQueryMessage::Row(row) = msg { - return Ok(row.get(0).unwrap_or("").to_string()); - } - } - Ok(String::new()) -} - -async fn generate_matview_ddl( - client: &Client, - schema: &str, - matview: &str, -) -> Result { - let sql = format!( - r#"SELECT 'CREATE MATERIALIZED VIEW "{schema}"."{matview}" AS' || E'\n' || definition - FROM pg_matviews - WHERE schemaname = '{schema}' AND matviewname = '{matview}'"# - ); - let result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let mut ddl = String::new(); - for msg in &result { - if let SimpleQueryMessage::Row(row) = msg { - ddl = row.get(0).unwrap_or("").to_string(); - } - } - - // Indexes on matview - let idx_sql = format!( - r#"SELECT pg_get_indexdef(i.indexrelid) || ';' - FROM pg_index i - JOIN pg_class tbl ON tbl.oid = i.indrelid - JOIN pg_namespace n ON n.oid = tbl.relnamespace - WHERE n.nspname = '{schema}' AND tbl.relname = '{matview}'"# - ); - let idx_result = client - .simple_query(&idx_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for msg in &idx_result { - if let SimpleQueryMessage::Row(row) = msg { - if let Some(line) = row.get(0) { - ddl.push('\n'); - ddl.push_str(line); - } - } - } - - Ok(ddl.trim_end().to_string()) -} - -async fn generate_function_ddl( - client: &Client, - schema: &str, - func_name: &str, -) -> Result { - let sql = format!( - r#"SELECT pg_get_functiondef(p.oid) - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = '{schema}' AND p.proname = '{func_name}' - LIMIT 1"# - ); - let result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for msg in &result { - if let SimpleQueryMessage::Row(row) = msg { - return Ok(row.get(0).unwrap_or("").to_string()); - } - } - Ok(String::new()) -} - -/// Load all foreign key relationships for a given schema. -pub async fn load_foreign_keys( - client: &Client, - schema: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT - kcu.table_name AS source_table, - kcu.column_name AS source_column, - ccu.table_name AS target_table, - ccu.column_name AS target_column - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.table_schema = tc.table_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = $1 - ORDER BY kcu.table_name, kcu.column_name"#, - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let src_table: String = r.get(0); - let src_col: String = r.get(1); - let tgt_table: String = r.get(2); - let tgt_col: String = r.get(3); - (src_table, src_col, tgt_table, tgt_col) - }) - .collect()) -} - -pub async fn parse_csv_preview( - file_path: &str, - max_rows: usize, -) -> Result<(Vec, Vec>), AppError> { - let mut rdr = csv::ReaderBuilder::new() - .has_headers(true) - .from_path(file_path) - .map_err(|e| AppError::QueryFailed(format!("Failed to read CSV: {}", e)))?; - - let headers: Vec = rdr - .headers() - .map_err(|e| AppError::QueryFailed(format!("Failed to parse CSV headers: {}", e)))? - .iter() - .map(|h| h.to_string()) - .collect(); - - let mut rows = Vec::new(); - for result in rdr.records().take(max_rows) { - let record = - result.map_err(|e| AppError::QueryFailed(format!("CSV parse error: {}", e)))?; - rows.push(record.iter().map(|f| f.to_string()).collect()); - } - - Ok((headers, rows)) -} - -pub async fn import_csv_to_table( - client: &deadpool_postgres::Client, - file_path: &str, - schema: &str, - table: &str, - column_mapping: &[(usize, String)], -) -> Result { - let mut rdr = csv::ReaderBuilder::new() - .has_headers(true) - .from_path(file_path) - .map_err(|e| AppError::QueryFailed(format!("Failed to read CSV: {}", e)))?; - - if column_mapping.is_empty() { - return Err(AppError::QueryFailed( - "No column mapping provided".to_string(), - )); - } - - let col_names: Vec = column_mapping - .iter() - .map(|(_, name)| format!("\"{}\"", name)) - .collect(); - let placeholders: Vec = (1..=column_mapping.len()) - .map(|i| format!("${}", i)) - .collect(); - - let insert_sql = format!( - "INSERT INTO \"{}\".\"{}\" ({}) VALUES ({})", - schema, - table, - col_names.join(", "), - placeholders.join(", "), - ); - - let statement = client - .prepare(&insert_sql) - .await - .map_err(|e| AppError::QueryFailed(format!("Failed to prepare statement: {}", e)))?; - - client - .execute("BEGIN", &[]) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let mut imported = 0usize; - for result in rdr.records() { - let record = result.map_err(|e| { - AppError::QueryFailed(format!("CSV parse error at row {}: {}", imported + 1, e)) - })?; - - let values: Vec = column_mapping - .iter() - .map(|(idx, _)| record.get(*idx).unwrap_or("").to_string()) - .collect(); - - let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = values - .iter() - .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync)) - .collect(); - - match client.execute(&statement, ¶ms).await { - Ok(_) => imported += 1, - Err(e) => { - client.execute("ROLLBACK", &[]).await.ok(); - return Err(AppError::QueryFailed(format!( - "Import failed at row {}: {}", - imported + 1, - e - ))); - } - } - } - - client - .execute("COMMIT", &[]) - .await - .map_err(|e| AppError::QueryFailed(format!("Failed to commit: {}", e)))?; - - Ok(imported) -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct PgRole { - pub name: String, - pub superuser: bool, - pub create_db: bool, - pub create_role: bool, - pub login: bool, - pub replication: bool, - pub bypass_rls: bool, - pub conn_limit: i32, - pub valid_until: String, - pub member_of: Vec, -} - -pub async fn load_roles(client: &deadpool_postgres::Client) -> Result, AppError> { - let rows = client - .query( - "SELECT r.rolname, r.rolsuper, r.rolcreatedb, r.rolcreaterole, - r.rolcanlogin, r.rolreplication, r.rolbypassrls, r.rolconnlimit, - COALESCE(r.rolvaliduntil::text, ''), - COALESCE(array_agg(m.rolname) FILTER (WHERE m.rolname IS NOT NULL), '{}')::text[] - FROM pg_roles r - LEFT JOIN pg_auth_members am ON am.member = r.oid - LEFT JOIN pg_roles m ON m.oid = am.roleid - GROUP BY r.oid, r.rolname, r.rolsuper, r.rolcreatedb, r.rolcreaterole, - r.rolcanlogin, r.rolreplication, r.rolbypassrls, r.rolconnlimit, r.rolvaliduntil - ORDER BY r.rolname", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let mut roles = Vec::new(); - for row in rows { - roles.push(PgRole { - name: row.get::<_, String>(0), - superuser: row.get::<_, bool>(1), - create_db: row.get::<_, bool>(2), - create_role: row.get::<_, bool>(3), - login: row.get::<_, bool>(4), - replication: row.get::<_, bool>(5), - bypass_rls: row.get::<_, bool>(6), - conn_limit: row.get::<_, i32>(7), - valid_until: row.get::<_, String>(8), - member_of: row.get::<_, Vec>(9), - }); - } - - Ok(roles) -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct TableGrant { - pub schema: String, - pub table: String, - pub grantee: String, - pub privileges: Vec, -} - -pub async fn load_table_grants( - client: &deadpool_postgres::Client, - role_name: &str, -) -> Result, AppError> { - let rows = client - .query( - "SELECT table_schema, table_name, grantee, - array_agg(privilege_type ORDER BY privilege_type)::text[] - FROM information_schema.table_privileges - WHERE grantee = $1 - GROUP BY table_schema, table_name, grantee - ORDER BY table_schema, table_name", - &[&role_name], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let mut grants = Vec::new(); - for row in rows { - grants.push(TableGrant { - schema: row.get(0), - table: row.get(1), - grantee: row.get(2), - privileges: row.get::<_, Vec>(3), - }); - } - - Ok(grants) -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct DbGrant { - pub database: String, - pub privilege: String, -} - -pub async fn load_database_grants( - client: &deadpool_postgres::Client, - role_name: &str, -) -> Result, AppError> { - let rows = client - .query( - "SELECT datname, privilege_type - FROM pg_database - CROSS JOIN LATERAL ( - SELECT privilege_type - FROM (VALUES ('CONNECT'), ('CREATE'), ('TEMPORARY')) AS privs(privilege_type) - WHERE has_database_privilege($1, datname, privilege_type) - ) t - WHERE NOT datistemplate - ORDER BY datname, privilege_type", - &[&role_name], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let mut grants = Vec::new(); - for row in rows { - grants.push(DbGrant { - database: row.get(0), - privilege: row.get(1), - }); - } - - Ok(grants) -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct SchemaObject { - pub object_type: String, - pub name: String, - pub definition: String, -} - -pub async fn extract_schema_objects( - client: &deadpool_postgres::Client, - schema: &str, -) -> Result, AppError> { - let mut objects = Vec::new(); - - // Tables with columns - let rows = client - .query( - "SELECT c.relname, - string_agg( - a.attname || ' ' || pg_catalog.format_type(a.atttypid, a.atttypmod) || - CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END || - COALESCE(' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid), ''), - ', ' ORDER BY a.attnum - ) - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped - LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum - WHERE n.nspname = $1 AND c.relkind = 'r' - GROUP BY c.relname ORDER BY c.relname", - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - for row in &rows { - objects.push(SchemaObject { - object_type: "table".to_string(), - name: row.get(0), - definition: row.get::<_, Option>(1).unwrap_or_default(), - }); - } - - // Views - let rows = client - .query( - "SELECT viewname, definition FROM pg_views WHERE schemaname = $1 ORDER BY viewname", - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - for row in &rows { - objects.push(SchemaObject { - object_type: "view".to_string(), - name: row.get(0), - definition: row.get::<_, Option>(1).unwrap_or_default(), - }); - } - - // Materialized views - let rows = client - .query( - "SELECT matviewname, definition FROM pg_matviews WHERE schemaname = $1 ORDER BY matviewname", - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - for row in &rows { - objects.push(SchemaObject { - object_type: "matview".to_string(), - name: row.get(0), - definition: row.get::<_, Option>(1).unwrap_or_default(), - }); - } - - // Functions - let rows = client - .query( - "SELECT p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', - COALESCE(pg_get_functiondef(p.oid), '') - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = $1 ORDER BY p.proname", - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - for row in &rows { - objects.push(SchemaObject { - object_type: "function".to_string(), - name: row.get(0), - definition: row.get::<_, Option>(1).unwrap_or_default(), - }); - } - - // Indexes - let rows = client - .query( - "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY indexname", - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - for row in &rows { - objects.push(SchemaObject { - object_type: "index".to_string(), - name: row.get(0), - definition: row.get::<_, Option>(1).unwrap_or_default(), - }); - } - - Ok(objects) -} - -pub async fn discover_notify_channels( - client: &deadpool_postgres::Client, -) -> Result, AppError> { - // Extract channel names from: - // 1. pg_notify() calls in trigger function bodies - // 2. NOTIFY statements in trigger function bodies - // 3. Currently active listeners from pg_stat_activity - let rows = client - .query( - r#"SELECT DISTINCT channel FROM ( - SELECT (regexp_matches(prosrc, 'pg_notify\s*\(\s*''([^'']+)''', 'gi'))[1] AS channel - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - UNION - SELECT (regexp_matches(prosrc, '\mNOTIFY\s+([a-zA-Z_][a-zA-Z0-9_]*)', 'gi'))[1] AS channel - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - ) sub - WHERE channel IS NOT NULL - ORDER BY channel"#, - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) -} - -pub async fn load_active_locks( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - l.pid::text, - COALESCE(a.usename, '') AS user, - COALESCE(l.mode, '') AS mode, - COALESCE(l.locktype, '') AS locktype, - CASE WHEN l.granted THEN 'granted' ELSE 'waiting' END AS status, - COALESCE(c.relname, '') AS relation, - COALESCE(n.nspname, '') AS schema, - COALESCE(left(a.query, 200), '') AS query, - COALESCE(extract(epoch from now() - a.query_start)::text, '0') AS duration, - COALESCE(a.wait_event_type || ':' || a.wait_event, '') AS wait_event - FROM pg_locks l - JOIN pg_stat_activity a ON a.pid = l.pid - LEFT JOIN pg_class c ON c.oid = l.relation - LEFT JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE a.pid != pg_backend_pid() - ORDER BY NOT l.granted, l.pid", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_index_usage( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - s.schemaname, - s.relname AS table, - s.indexrelname AS index, - pg_size_pretty(pg_relation_size(i.indexrelid)) AS size, - COALESCE(s.idx_scan, 0)::text AS scans, - COALESCE(s.idx_tup_read, 0)::text AS tuples_read, - COALESCE(s.idx_tup_fetch, 0)::text AS tuples_fetched, - CASE - WHEN s.idx_scan = 0 THEN 'unused' - WHEN s.idx_scan < 10 THEN 'rarely_used' - ELSE 'active' - END AS status, - COALESCE(pg_get_indexdef(i.indexrelid), '') AS definition - FROM pg_stat_user_indexes s - JOIN pg_index i ON i.indexrelid = s.indexrelid - WHERE NOT i.indisprimary - ORDER BY s.idx_scan ASC, pg_relation_size(i.indexrelid) DESC", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..9).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_table_bloat( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - schemaname, - relname AS table, - n_live_tup::text AS live_tuples, - n_dead_tup::text AS dead_tuples, - CASE WHEN n_live_tup > 0 - THEN round(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 1)::text - ELSE '0' - END AS bloat_pct, - pg_size_pretty(pg_total_relation_size(relid)) AS total_size, - COALESCE(last_vacuum::text, 'never') AS last_vacuum, - COALESCE(last_autovacuum::text, 'never') AS last_autovacuum, - COALESCE(last_analyze::text, 'never') AS last_analyze, - COALESCE(last_autoanalyze::text, 'never') AS last_autoanalyze - FROM pg_stat_user_tables - ORDER BY n_dead_tup DESC", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_extensions( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - e.extname AS name, - e.extversion AS installed_version, - COALESCE(a.default_version, '') AS default_version, - COALESCE(a.comment, '') AS comment, - n.nspname AS schema - FROM pg_extension e - JOIN pg_namespace n ON n.oid = e.extnamespace - LEFT JOIN pg_available_extensions a ON a.name = e.extname - ORDER BY e.extname", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..5).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_available_extensions( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - a.name, - COALESCE(a.default_version, '') AS version, - COALESCE(a.comment, '') AS comment - FROM pg_available_extensions a - LEFT JOIN pg_extension e ON e.extname = a.name - WHERE e.oid IS NULL - ORDER BY a.name", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..3).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_enum_types( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - n.nspname AS schema, - t.typname AS name, - string_agg(e.enumlabel, ', ' ORDER BY e.enumsortorder) AS labels - FROM pg_type t - JOIN pg_namespace n ON n.oid = t.typnamespace - JOIN pg_enum e ON e.enumtypid = t.oid - WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - GROUP BY n.nspname, t.typname - ORDER BY n.nspname, t.typname", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..3).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_pg_settings( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - name, - COALESCE(setting, '') AS setting, - COALESCE(unit, '') AS unit, - category, - COALESCE(short_desc, '') AS description, - context, - COALESCE(source, '') AS source, - COALESCE(boot_val, '') AS boot_val, - COALESCE(reset_val, '') AS reset_val - FROM pg_settings - ORDER BY category, name", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..9).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} diff --git a/src-tauri/src/drivers/mod.rs b/src-tauri/src/drivers/mod.rs index 7878916..c23eeb7 100644 --- a/src-tauri/src/drivers/mod.rs +++ b/src-tauri/src/drivers/mod.rs @@ -1,2 +1 @@ -pub mod common; pub mod pgsql; diff --git a/src-tauri/src/drivers/pgsql.rs b/src-tauri/src/drivers/pgsql.rs deleted file mode 100644 index e85733d..0000000 --- a/src-tauri/src/drivers/pgsql.rs +++ /dev/null @@ -1,1484 +0,0 @@ -use std::{ - collections::BTreeMap, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use deadpool_postgres::{Manager as PgManager, ManagerConfig, Pool, RecyclingMethod}; - -use crate::AppState; -use crate::common::enums::{AppError, ProjectConnectionStatus}; -use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; -use crate::drivers::common::{ - ColumnDetail, ConstraintDetail, DbGrant, DbStat, FKDetail, ForeignKeyInfo, FunctionInfo, - IndexDetail, ObjectStats, PgRole, PolicyDetail, RuleDetail, SchemaObject, TableGrant, - TriggerDetail, close_virtual, discover_notify_channels, execute_query, execute_query_packed, - execute_query_streamed, execute_virtual, extract_schema_objects, fetch_virtual_page, - generate_full_ddl, get_pool, import_csv_to_table, load_active_locks, load_activity, - load_available_extensions, load_column_details, load_columns, load_constraints, - load_database_grants, load_database_stats, load_databases, load_enum_types, load_extensions, - load_fk_details, load_foreign_keys, load_function_info, load_functions, load_index_usage, - load_indexes, load_materialized_views, load_matview_info, load_pg_settings, load_policies, - load_roles, load_rules, load_schemas, load_table_bloat, load_table_grants, - load_table_statistics, load_table_stats, load_tables, load_tablespaces, load_trigger_functions, - load_triggers, load_view_info, load_views, parse_csv_preview, -}; - -use futures_util::StreamExt; -use native_tls::TlsConnector; -use postgres_native_tls::MakeTlsConnector; -use tauri::ipc::Response; -use tauri::{AppHandle, Emitter, Manager, Result, State}; -use tokio::time::{Duration, sleep}; -use tokio_postgres::{AsyncMessage, CancelToken, Config, NoTls}; - -const CELL_SEP: char = '\x1F'; -const SNAPSHOT_PAGE_WRITE_RETRIES: usize = 3; - -fn is_sqlite_lock_error(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - lower.contains("database is locked") || lower.contains("database busy") -} - -/// Walk the full std::error::Error source chain into a single string. -fn full_error_chain(e: &dyn std::error::Error) -> String { - let mut msg = e.to_string(); - let mut src = e.source(); - while let Some(cause) = src { - msg.push_str(": "); - msg.push_str(&cause.to_string()); - src = cause.source(); - } - msg -} - -fn create_pg_pool( - cfg: &Config, - use_ssl: bool, - max_size: usize, -) -> std::result::Result { - let manager_config = ManagerConfig { - recycling_method: RecyclingMethod::Custom("ROLLBACK".into()), - }; - - if use_ssl { - let tls_connector = TlsConnector::builder() - .build() - .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; - let tls = MakeTlsConnector::new(tls_connector); - let manager = PgManager::from_config(cfg.clone(), tls, manager_config); - Pool::builder(manager) - .max_size(max_size) - .build() - .map_err(|e| AppError::ConnectionFailed(e.to_string())) - } else { - let manager = PgManager::from_config(cfg.clone(), NoTls, manager_config); - Pool::builder(manager) - .max_size(max_size) - .build() - .map_err(|e| AppError::ConnectionFailed(e.to_string())) - } -} - -async fn acquire_client( - pools_mutex: &tokio::sync::Mutex>>, - project_id: &str, -) -> std::result::Result { - let pool = { - let pools = pools_mutex.lock().await; - get_pool(&pools, project_id)? - }; - - pool.get() - .await - .map_err(|e| AppError::ConnectionFailed(e.to_string())) -} - -async fn apply_statement_timeout( - client: &deadpool_postgres::Client, - timeout_ms: u32, -) { - if timeout_ms > 0 { - client - .simple_query(&format!("SET statement_timeout = {}", timeout_ms)) - .await - .ok(); - } -} - -async fn reset_statement_timeout( - client: &deadpool_postgres::Client, - timeout_ms: u32, -) { - if timeout_ms > 0 { - client.simple_query("RESET statement_timeout").await.ok(); - } -} - -async fn set_cancel_token( - app_state: &AppState, - project_id: &str, - token: CancelToken, -) -> std::result::Result<(), AppError> { - let mut cancel_tokens = app_state.cancel_tokens.lock().await; - cancel_tokens.insert(project_id.to_string(), token); - Ok(()) -} - -#[derive(Clone)] -struct VirtualSnapshotMeta { - project_id: String, - sql: String, - page_size: usize, - col_count: usize, -} - -fn now_unix_secs() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or_default() -} - -async fn snapshot_upsert_metadata( - app_state: &AppState, - project_id: &str, - query_id: &str, - sql: &str, - columns_packed: &str, - total_rows: usize, - page_size: usize, - col_count: usize, -) -> std::result::Result<(), AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - conn.execute( - "INSERT OR REPLACE INTO virtual_query_snapshots ( - query_id, project_id, sql, columns_packed, total_rows, page_size, col_count, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - libsql::params![ - query_id, - project_id, - sql, - columns_packed, - total_rows as i64, - page_size as i64, - col_count as i64, - now_unix_secs(), - ], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - Ok(()) -} - -async fn snapshot_store_page( - app_state: &AppState, - query_id: &str, - page_index: usize, - packed_page: &str, -) -> std::result::Result<(), AppError> { - if packed_page.is_empty() { - return Ok(()); - } - - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - for attempt in 0..SNAPSHOT_PAGE_WRITE_RETRIES { - match conn - .execute( - "INSERT OR IGNORE INTO virtual_query_pages (query_id, page_index, packed_page) - VALUES (?1, ?2, ?3)", - libsql::params![query_id, page_index as i64, packed_page], - ) - .await - { - Ok(_) => return Ok(()), - Err(e) => { - let msg = e.to_string(); - if is_sqlite_lock_error(&msg) { - if attempt + 1 < SNAPSHOT_PAGE_WRITE_RETRIES { - sleep(Duration::from_millis((attempt as u64 + 1) * 8)).await; - continue; - } - // Snapshot persistence is best-effort; skip noisy lock errors. - tracing::debug!( - "Skipping snapshot page persist for {} page {} due to SQLite lock", - query_id, - page_index - ); - return Ok(()); - } - return Err(AppError::DatabaseError(msg)); - } - } - } - - Ok(()) -} - -async fn snapshot_load_page( - app_state: &AppState, - query_id: &str, - page_index: usize, -) -> std::result::Result, AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let mut rows = conn - .query( - "SELECT packed_page - FROM virtual_query_pages - WHERE query_id = ?1 AND page_index = ?2 - LIMIT 1", - libsql::params![query_id, page_index as i64], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let maybe_row = rows - .next() - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - if let Some(row) = maybe_row { - let packed: String = row - .get(0) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - Ok(Some(packed)) - } else { - Ok(None) - } -} - -async fn snapshot_load_metadata( - app_state: &AppState, - query_id: &str, -) -> std::result::Result, AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let mut rows = conn - .query( - "SELECT project_id, sql, page_size, col_count - FROM virtual_query_snapshots - WHERE query_id = ?1 - LIMIT 1", - libsql::params![query_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let maybe_row = rows - .next() - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let Some(row) = maybe_row else { - return Ok(None); - }; - - let project_id: String = row - .get(0) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let sql: String = row - .get(1) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let page_size_i64: i64 = row - .get(2) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let col_count_i64: i64 = row - .get(3) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - if page_size_i64 <= 0 { - return Ok(None); - } - - Ok(Some(VirtualSnapshotMeta { - project_id, - sql, - page_size: page_size_i64 as usize, - col_count: col_count_i64.max(0) as usize, - })) -} - -async fn snapshot_cleanup_query( - app_state: &AppState, - query_id: &str, -) -> std::result::Result<(), AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - conn.execute( - "DELETE FROM virtual_query_pages WHERE query_id = ?1", - libsql::params![query_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - conn.execute( - "DELETE FROM virtual_query_snapshots WHERE query_id = ?1", - libsql::params![query_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - Ok(()) -} - -async fn restore_virtual_from_snapshot( - app_state: &AppState, - query_id: &str, -) -> std::result::Result { - let Some(meta) = snapshot_load_metadata(app_state, query_id).await? else { - return Ok(false); - }; - - let client = acquire_client(&app_state.clients, &meta.project_id).await?; - set_cancel_token(app_state, &meta.project_id, client.cancel_token()).await?; - - let (columns_packed, total_rows, first_page_packed, _) = execute_virtual( - &client, - &app_state.virtual_cache, - &meta.sql, - query_id, - meta.page_size, - ) - .await?; - - if columns_packed.is_empty() { - return Ok(false); - } - - let col_count = if meta.col_count > 0 { - meta.col_count - } else { - columns_packed.split(CELL_SEP).count() - }; - - if let Err(e) = snapshot_upsert_metadata( - app_state, - &meta.project_id, - query_id, - &meta.sql, - &columns_packed, - total_rows, - meta.page_size, - col_count, - ) - .await - { - tracing::warn!( - "Failed to refresh snapshot metadata for {}: {:?}", - query_id, - e - ); - } - if let Err(e) = snapshot_store_page(app_state, query_id, 0, &first_page_packed).await { - tracing::warn!( - "Failed to refresh snapshot first page for {}: {:?}", - query_id, - e - ); - } - - Ok(true) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_test_connection( - key: [&str; 6], -) -> Result { - let user = key[0]; - let password = key[1]; - let database = key[2]; - let host = key[3]; - let port: u16 = key[4].parse().unwrap_or(5432); - let use_ssl = key[5] == "true"; - - let mut cfg = Config::new(); - cfg.user(user) - .password(password) - .dbname(database) - .host(host) - .port(port); - - let pool = create_pg_pool(&cfg, use_ssl, 1)?; - let client = pool - .get() - .await - .map_err(|e| AppError::ConnectionFailed(full_error_chain(&e)))?; - - let row = client - .query_one("SELECT version()", &[]) - .await - .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; - - let version: String = row.get(0); - Ok(version) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_connector( - project_id: &str, - key: Option<[&str; 6]>, - ssh: Option>, - app: AppHandle, -) -> Result { - let app_state = app.state::(); - { - let clients = app_state.clients.lock().await; - if clients.contains_key(project_id) { - return Ok(ProjectConnectionStatus::Connected); - } - } - - let (user, password, database, host, port_str, use_ssl) = match key { - Some(key) => ( - key[0].to_string(), - key[1].to_string(), - key[2].to_string(), - key[3].to_string(), - key[4].to_string(), - key[5] == "true", - ), - None => { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let mut rows = conn - .query( - "SELECT username, password, database, host, port, ssl FROM projects WHERE id = ?1", - libsql::params![project_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let row = rows - .next() - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))? - .ok_or_else(|| AppError::ProjectNotFound(project_id.to_string()))?; - ( - row.get::(0).unwrap_or_default(), - row.get::(1).unwrap_or_default(), - row.get::(2).unwrap_or_default(), - row.get::(3).unwrap_or_default(), - row.get::(4).unwrap_or_default(), - row.get::(5).map(|s| s == "true").unwrap_or(false), - ) - } - }; - - // Determine effective host/port, potentially through an SSH tunnel - let (effective_host, effective_port_str) = if let Some(ref ssh_params) = ssh { - // ssh_params: [ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_path] - if ssh_params.len() >= 3 && !ssh_params[0].is_empty() { - let ssh_host = &ssh_params[0]; - let ssh_port: u16 = ssh_params[1].parse().unwrap_or(22); - let ssh_user = &ssh_params[2]; - let ssh_password = ssh_params - .get(3) - .filter(|s| !s.is_empty()) - .map(|s| s.as_str()); - let ssh_key_path = ssh_params - .get(4) - .filter(|s| !s.is_empty()) - .map(|s| s.as_str()); - - // Stop any existing tunnel for this project - app_state.ssh_tunnels.lock().await.remove(project_id); - - let tunnel = crate::ssh::start_tunnel( - ssh_host, - ssh_port, - ssh_user, - ssh_password, - ssh_key_path, - &host, - port_str.parse().unwrap_or(5432), - ) - .await - .map_err(|e| AppError::ConnectionFailed(e))?; - - let local_port = tunnel.local_port; - app_state - .ssh_tunnels - .lock() - .await - .insert(project_id.to_string(), tunnel); - - ("127.0.0.1".to_string(), local_port.to_string()) - } else { - (host.clone(), port_str.clone()) - } - } else { - (host.clone(), port_str.clone()) - }; - - let port: u16 = effective_port_str.parse().unwrap_or(5432); - let mut cfg = Config::new(); - cfg.user(&user) - .password(&password) - .dbname(&database) - .host(&effective_host) - .port(port); - - // Create two pools: one for user queries, one for metadata. - let query_pool = match create_pg_pool(&cfg, use_ssl, 16) { - Ok(p) => Arc::new(p), - Err(e) => { - tracing::error!("Query pool creation failed: {:?}", e); - return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); - } - }; - let meta_pool = match create_pg_pool(&cfg, use_ssl, 8) { - Ok(p) => Arc::new(p), - Err(e) => { - tracing::error!("Meta pool creation failed: {:?}", e); - return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); - } - }; - - // Validate connectivity eagerly so connector keeps previous fail/connected behavior. - let query_client = match query_pool.get().await { - Ok(c) => c, - Err(e) => { - tracing::error!("Query pool initial connection failed: {:?}", e); - return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); - } - }; - if let Err(e) = meta_pool.get().await { - tracing::error!("Meta pool initial connection failed: {:?}", e); - return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); - } - - { - let mut clients = app_state.clients.lock().await; - clients.insert(project_id.to_string(), Arc::clone(&query_pool)); - } - { - let mut meta_clients = app_state.meta_clients.lock().await; - meta_clients.insert(project_id.to_string(), Arc::clone(&meta_pool)); - } - { - let mut cancel_tokens = app_state.cancel_tokens.lock().await; - cancel_tokens.insert(project_id.to_string(), query_client.cancel_token()); - } - { - let mut client_ssl = app_state.client_ssl.lock().await; - client_ssl.insert(project_id.to_string(), use_ssl); - } - - Ok(ProjectConnectionStatus::Connected) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_databases(project_id: &str, app: AppHandle) -> Result> { - let app_state = app.state::(); - let pool = { - let pools = app_state.meta_clients.lock().await; - get_pool(&pools, project_id)? - }; - - load_databases(&pool).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_tablespaces( - project_id: &str, - app: AppHandle, -) -> Result> { - let app_state = app.state::(); - let pool = { - let pools = app_state.meta_clients.lock().await; - get_pool(&pools, project_id)? - }; - - load_tablespaces(&pool).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_schemas( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_schemas( - &client, - r#"SELECT schema_name FROM information_schema.schemata - WHERE schema_name NOT IN ('pg_catalog', 'information_schema') - ORDER BY schema_name"#, - ) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_tables( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_tables( - &client, - r#"SELECT table_name, - pg_size_pretty(pg_total_relation_size('"' || table_schema || '"."' || table_name || '"')) AS size - FROM information_schema.tables - WHERE table_schema = $1 - ORDER BY table_name"#, - schema, - ) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_columns( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_columns(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_column_details( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_column_details(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_indexes( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_indexes(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_constraints( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_constraints(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_triggers( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_triggers(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_rules( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_rules(&client, schema, table).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_policies( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_policies(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_views( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_views(&client, schema).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_materialized_views( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_materialized_views(&client, schema) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_functions( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_functions(&client, schema).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_trigger_functions( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_trigger_functions(&client, schema) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_activity( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - let result = load_activity(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_database_stats( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_database_stats(&client).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_table_stats( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - let result = load_table_stats(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_foreign_keys( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_foreign_keys(&client, schema).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_run_query( - project_id: &str, - sql: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; - - let result = execute_query(&client, sql).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_cancel_query(project_id: &str, app_state: State<'_, AppState>) -> Result { - let cancel_token = { - let cancel_tokens = app_state.cancel_tokens.lock().await; - cancel_tokens - .get(project_id) - .cloned() - .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string()))? - }; - - let use_ssl = { - let client_ssl = app_state.client_ssl.lock().await; - *client_ssl.get(project_id).unwrap_or(&false) - }; - - if use_ssl { - let tls_connector = TlsConnector::builder() - .build() - .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; - let tls = MakeTlsConnector::new(tls_connector); - cancel_token - .cancel_query(tls) - .await - .map_err(|e| AppError::QueryFailed(format!("Failed to cancel query: {e}")))?; - } else { - cancel_token - .cancel_query(NoTls) - .await - .map_err(|e| AppError::QueryFailed(format!("Failed to cancel query: {e}")))?; - } - - Ok(true) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_run_query_packed( - project_id: &str, - sql: &str, - timeout_ms: Option, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; - - let timeout = timeout_ms.unwrap_or(0); - apply_statement_timeout(&client, timeout).await; - let result = execute_query_packed(&client, sql).await; - reset_statement_timeout(&client, timeout).await; - - let result = result?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_run_query_streamed( - project_id: &str, - sql: &str, - stream_id: &str, - app: AppHandle, -) -> Result<()> { - let app_state = app.state::(); - let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; - - execute_query_streamed(&client, sql, stream_id, &app) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_execute_virtual( - project_id: &str, - sql: &str, - query_id: &str, - page_size: usize, - timeout_ms: Option, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; - - let timeout = timeout_ms.unwrap_or(0); - apply_statement_timeout(&client, timeout).await; - let result = - execute_virtual(&client, &app_state.virtual_cache, sql, query_id, page_size).await; - reset_statement_timeout(&client, timeout).await; - let result = result?; - - let col_count = if result.0.is_empty() { - 0 - } else { - result.0.split(CELL_SEP).count() - }; - if let Err(e) = snapshot_upsert_metadata( - &app_state, project_id, query_id, sql, &result.0, result.1, page_size, col_count, - ) - .await - { - tracing::warn!( - "Failed to persist virtual snapshot metadata for {}: {:?}", - query_id, - e - ); - } - if let Err(e) = snapshot_store_page(&app_state, query_id, 0, &result.2).await { - tracing::warn!( - "Failed to persist virtual snapshot first page for {}: {:?}", - query_id, - e - ); - } - - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_fetch_page( - query_id: &str, - col_count: usize, - offset: usize, - limit: usize, - app_state: State<'_, AppState>, -) -> Result { - let page_index = if limit == 0 { 0 } else { offset / limit }; - - match fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit).await { - Ok(packed) => { - if let Err(e) = snapshot_store_page(&app_state, query_id, page_index, &packed).await { - tracing::warn!("Failed to persist fetched page for {}: {:?}", query_id, e); - } - let json = - sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; - return Ok(Response::new(json)); - } - Err(err) => { - tracing::debug!( - "Virtual cache miss for query {}, trying snapshot fallback: {:?}", - query_id, - err - ); - } - } - - if let Some(packed) = snapshot_load_page(&app_state, query_id, page_index).await? { - let json = - sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; - return Ok(Response::new(json)); - } - - if restore_virtual_from_snapshot(&app_state, query_id).await? { - let packed = - fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit) - .await?; - if let Err(e) = snapshot_store_page(&app_state, query_id, page_index, &packed).await { - tracing::warn!( - "Failed to persist restored page for {} (page {}): {:?}", - query_id, - page_index, - e - ); - } - let json = - sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; - return Ok(Response::new(json)); - } - - Err(AppError::QueryFailed(format!( - "Virtual query {} not found in memory and no snapshot available", - query_id - )) - .into()) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_close_virtual(query_id: &str, app_state: State<'_, AppState>) -> Result<()> { - close_virtual(&app_state.virtual_cache, query_id).await?; - if let Err(e) = snapshot_cleanup_query(&app_state, query_id).await { - tracing::warn!( - "Failed to cleanup virtual snapshot for {}: {:?}", - query_id, - e - ); - } - Ok(()) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_table_statistics( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_table_statistics(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_fk_details( - project_id: &str, - schema: &str, - table: &str, - direction: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_fk_details(&client, schema, table, direction) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_view_info( - project_id: &str, - schema: &str, - view: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_view_info(&client, schema, view) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_matview_info( - project_id: &str, - schema: &str, - matview: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_matview_info(&client, schema, matview) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_function_info( - project_id: &str, - schema: &str, - func_name: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_function_info(&client, schema, func_name) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_generate_ddl( - project_id: &str, - schema: &str, - name: &str, - object_type: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - generate_full_ddl(&client, schema, name, object_type) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_csv_preview(file_path: &str) -> Result<(Vec, Vec>)> { - parse_csv_preview(file_path, 5).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_csv_import( - project_id: &str, - file_path: &str, - schema: &str, - table: &str, - column_mapping: Vec<(usize, String)>, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - import_csv_to_table(&client, file_path, schema, table, &column_mapping) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_listen_start(project_id: &str, channel: &str, app: AppHandle) -> Result { - let app_handle = app.clone(); - let app_state = app_handle.state::(); - let listen_key = format!("{}:{}", project_id, channel); - - { - let handles = app_state.notify_handles.lock().await; - if handles.contains_key(&listen_key) { - return Ok(true); // Already listening - } - } - - // Get connection config from local db - let (cfg, use_ssl) = { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let mut rows = conn - .query( - "SELECT username, password, database, host, port, ssl FROM projects WHERE id = ?1", - libsql::params![project_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let row = rows - .next() - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))? - .ok_or_else(|| AppError::ProjectNotFound(project_id.to_string()))?; - - let mut cfg = Config::new(); - cfg.user(&row.get::(0).unwrap_or_default()) - .password(&row.get::(1).unwrap_or_default()) - .dbname(&row.get::(2).unwrap_or_default()) - .host(&row.get::(3).unwrap_or_default()) - .port( - row.get::(4) - .unwrap_or_default() - .parse() - .unwrap_or(5432), - ); - let ssl = row.get::(5).map(|s| s == "true").unwrap_or(false); - (cfg, ssl) - }; - - let channel_owned = channel.to_string(); - let event_name = format!("pg-notify-{}", project_id); - - let handle = tokio::spawn(async move { - // Helper: drive a connection, forwarding notifications as Tauri events - async fn listen_loop( - client: tokio_postgres::Client, - mut connection: tokio_postgres::Connection, - channel: &str, - event_name: &str, - app: &AppHandle, - ) where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, - T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, - { - let listen_sql = format!("LISTEN \"{}\"", channel.replace('"', "\"\"")); - if let Err(e) = client.batch_execute(&listen_sql).await { - tracing::error!("LISTEN command failed: {:?}", e); - return; - } - tracing::info!("LISTEN started on channel: {}", channel); - - let mut stream = futures_util::stream::poll_fn(move |cx| connection.poll_message(cx)); - - while let Some(msg) = stream.next().await { - match msg { - Ok(AsyncMessage::Notification(n)) => { - let payload = serde_json::json!({ - "channel": n.channel(), - "payload": n.payload(), - }); - let _ = app.emit(event_name, payload); - } - Ok(_) => {} - Err(e) => { - tracing::error!("LISTEN stream error: {:?}", e); - break; - } - } - } - tracing::info!("LISTEN ended on channel: {}", channel); - drop(client); - } - - if use_ssl { - let tls_connector = match TlsConnector::builder().build() { - Ok(c) => c, - Err(e) => { - tracing::error!("LISTEN TLS error: {:?}", e); - return; - } - }; - let tls = MakeTlsConnector::new(tls_connector); - match cfg.connect(tls).await { - Ok((client, connection)) => { - listen_loop(client, connection, &channel_owned, &event_name, &app).await; - } - Err(e) => tracing::error!("LISTEN connect error: {:?}", e), - } - } else { - match cfg.connect(NoTls).await { - Ok((client, connection)) => { - listen_loop(client, connection, &channel_owned, &event_name, &app).await; - } - Err(e) => tracing::error!("LISTEN connect error: {:?}", e), - } - } - }); - - { - let mut handles = app_state.notify_handles.lock().await; - handles.insert(listen_key, handle); - } - - Ok(true) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_listen_stop(project_id: &str, channel: &str, app: AppHandle) -> Result { - let app_state = app.state::(); - let listen_key = format!("{}:{}", project_id, channel); - - let mut handles = app_state.notify_handles.lock().await; - if let Some(handle) = handles.remove(&listen_key) { - handle.abort(); - } - - Ok(true) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_notify_send( - project_id: &str, - channel: &str, - payload: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - let sql = format!( - "SELECT pg_notify('{}', '{}')", - channel.replace('\'', "''"), - payload.replace('\'', "''"), - ); - client - .batch_execute(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(true) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_discover_channels( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - discover_notify_channels(&client).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_roles( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_roles(&client).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_table_grants( - project_id: &str, - role_name: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_table_grants(&client, role_name) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_database_grants( - project_id: &str, - role_name: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_database_grants(&client, role_name) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_extract_schema_objects( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - extract_schema_objects(&client, schema) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_locks( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_active_locks(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_index_usage( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_index_usage(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_table_bloat( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_table_bloat(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_extensions( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_extensions(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_available_extensions( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_available_extensions(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_enum_types( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_enum_types(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_table_action( - project_id: &str, - action: &str, - schema: &str, - table: &str, - object_type: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - - // Quote identifiers safely - fn qi(name: &str) -> String { - format!("\"{}\"", name.replace('"', "\"\"")) - } - - let qualified = format!("{}.{}", qi(schema), qi(table)); - - let sql = match (object_type, action) { - // Table actions - ("table", "ANALYZE") => format!("ANALYZE {qualified}"), - ("table", "VACUUM") => format!("VACUUM {qualified}"), - ("table", "VACUUM FULL") => format!("VACUUM FULL {qualified}"), - ("table", "REINDEX") => format!("REINDEX TABLE {qualified}"), - ("table", "TRUNCATE") => format!("TRUNCATE TABLE {qualified}"), - ("table", "DROP TABLE") => format!("DROP TABLE {qualified}"), - // View actions - ("view", "DROP VIEW") => format!("DROP VIEW {qualified}"), - ("view", "DROP VIEW CASCADE") => format!("DROP VIEW {qualified} CASCADE"), - // Materialized view actions - ("matview", "REFRESH") => format!("REFRESH MATERIALIZED VIEW {qualified}"), - ("matview", "REFRESH CONCURRENTLY") => { - format!("REFRESH MATERIALIZED VIEW CONCURRENTLY {qualified}") - } - ("matview", "DROP MATERIALIZED VIEW") => format!("DROP MATERIALIZED VIEW {qualified}"), - // Function actions - ("function" | "trigger-function", "DROP FUNCTION") => format!("DROP FUNCTION {qualified}"), - ("function" | "trigger-function", "DROP FUNCTION CASCADE") => { - format!("DROP FUNCTION {qualified} CASCADE") - } - _ => { - return Err(AppError::QueryFailed(format!( - "Unknown action '{}' for object type '{}'", - action, object_type - )) - .into()); - } - }; - - execute_query(&client, &sql).await.map_err(|e| e)?; - - Ok(format!("{action} completed successfully.")) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_pg_settings( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_pg_settings(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} diff --git a/src-tauri/src/drivers/pgsql/commands/admin_commands.rs b/src-tauri/src/drivers/pgsql/commands/admin_commands.rs new file mode 100644 index 0000000..60889df --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/admin_commands.rs @@ -0,0 +1,170 @@ +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::{ + DbGrant, PgRole, SchemaObject, TableGrant, execute_query, extract_schema_objects, + import_csv_to_table, load_available_extensions, load_database_grants, load_enum_types, + load_extensions, load_pg_settings, load_roles, load_table_grants, parse_csv_preview, +}; + +use tauri::ipc::Response; +use tauri::{Result, State}; + +use super::pool_connection::acquire_client; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_csv_preview(file_path: &str) -> Result<(Vec, Vec>)> { + parse_csv_preview(file_path, 5).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_csv_import( + project_id: &str, + file_path: &str, + schema: &str, + table: &str, + column_mapping: Vec<(usize, String)>, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + import_csv_to_table(&client, file_path, schema, table, &column_mapping) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_roles( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_roles(&client).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_table_grants( + project_id: &str, + role_name: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_table_grants(&client, role_name) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_database_grants( + project_id: &str, + role_name: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_database_grants(&client, role_name) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_extract_schema_objects( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + extract_schema_objects(&client, schema) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_extensions( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_extensions(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_available_extensions( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_available_extensions(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_enum_types( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_enum_types(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_table_action( + project_id: &str, + action: &str, + schema: &str, + table: &str, + object_type: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + + fn qi(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) + } + + let qualified = format!("{}.{}", qi(schema), qi(table)); + + let sql = match (object_type, action) { + ("table", "ANALYZE") => format!("ANALYZE {qualified}"), + ("table", "VACUUM") => format!("VACUUM {qualified}"), + ("table", "VACUUM FULL") => format!("VACUUM FULL {qualified}"), + ("table", "REINDEX") => format!("REINDEX TABLE {qualified}"), + ("table", "TRUNCATE") => format!("TRUNCATE TABLE {qualified}"), + ("table", "DROP TABLE") => format!("DROP TABLE {qualified}"), + ("view", "DROP VIEW") => format!("DROP VIEW {qualified}"), + ("view", "DROP VIEW CASCADE") => format!("DROP VIEW {qualified} CASCADE"), + ("matview", "REFRESH") => format!("REFRESH MATERIALIZED VIEW {qualified}"), + ("matview", "REFRESH CONCURRENTLY") => { + format!("REFRESH MATERIALIZED VIEW CONCURRENTLY {qualified}") + } + ("matview", "DROP MATERIALIZED VIEW") => format!("DROP MATERIALIZED VIEW {qualified}"), + ("function" | "trigger-function", "DROP FUNCTION") => format!("DROP FUNCTION {qualified}"), + ("function" | "trigger-function", "DROP FUNCTION CASCADE") => { + format!("DROP FUNCTION {qualified} CASCADE") + } + _ => { + return Err(AppError::QueryFailed(format!( + "Unknown action '{}' for object type '{}'", + action, object_type + )) + .into()); + } + }; + + execute_query(&client, &sql).await.map_err(|e| e)?; + + Ok(format!("{action} completed successfully.")) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_pg_settings( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_pg_settings(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} diff --git a/src-tauri/src/drivers/pgsql/commands/metadata_commands.rs b/src-tauri/src/drivers/pgsql/commands/metadata_commands.rs new file mode 100644 index 0000000..343861e --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/metadata_commands.rs @@ -0,0 +1,219 @@ +use crate::AppState; +use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; +use crate::drivers::pgsql::{ + ColumnDetail, ConstraintDetail, FunctionInfo, IndexDetail, PolicyDetail, RuleDetail, + TriggerDetail, get_pool, load_column_details, load_columns, load_constraints, load_databases, + load_functions, load_indexes, load_materialized_views, load_policies, load_rules, load_schemas, + load_tables, load_tablespaces, load_trigger_functions, load_triggers, load_views, +}; + +use tauri::{AppHandle, Manager, Result, State}; + +use super::pool_connection::acquire_client; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_databases(project_id: &str, app: AppHandle) -> Result> { + let app_state = app.state::(); + let pool = { + let pools = app_state.meta_clients.lock().await; + get_pool(&pools, project_id)? + }; + + load_databases(&pool).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_tablespaces( + project_id: &str, + app: AppHandle, +) -> Result> { + let app_state = app.state::(); + let pool = { + let pools = app_state.meta_clients.lock().await; + get_pool(&pools, project_id)? + }; + + load_tablespaces(&pool).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_schemas( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_schemas( + &client, + r#"SELECT schema_name FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema') + ORDER BY schema_name"#, + ) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_tables( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_tables( + &client, + r#"SELECT table_name, + pg_size_pretty(pg_total_relation_size('"' || table_schema || '"."' || table_name || '"')) AS size + FROM information_schema.tables + WHERE table_schema = $1 + ORDER BY table_name"#, + schema, + ) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_columns( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_columns(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_column_details( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_column_details(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_indexes( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_indexes(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_constraints( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_constraints(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_triggers( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_triggers(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_rules( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_rules(&client, schema, table).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_policies( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_policies(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_views( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_views(&client, schema).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_materialized_views( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_materialized_views(&client, schema) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_functions( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_functions(&client, schema).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_trigger_functions( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_trigger_functions(&client, schema) + .await + .map_err(Into::into) +} diff --git a/src-tauri/src/drivers/pgsql/commands/mod.rs b/src-tauri/src/drivers/pgsql/commands/mod.rs new file mode 100644 index 0000000..a417f43 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/mod.rs @@ -0,0 +1,19 @@ +pub(crate) const CELL_SEP: char = '\x1F'; +pub(crate) const SNAPSHOT_PAGE_WRITE_RETRIES: usize = 3; + +pub mod admin_commands; +pub mod metadata_commands; +pub mod object_info_commands; +pub mod pool_connection; +pub mod pubsub_commands; +pub mod query_commands; +pub mod snapshot_persistence; +pub mod statistics_commands; + +pub use admin_commands::*; +pub use metadata_commands::*; +pub use object_info_commands::*; +pub use pool_connection::*; +pub use pubsub_commands::*; +pub use query_commands::*; +pub use statistics_commands::*; diff --git a/src-tauri/src/drivers/pgsql/commands/object_info_commands.rs b/src-tauri/src/drivers/pgsql/commands/object_info_commands.rs new file mode 100644 index 0000000..97b2a08 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/object_info_commands.rs @@ -0,0 +1,61 @@ +use crate::AppState; +use crate::drivers::pgsql::{ + ObjectStats, generate_full_ddl, load_function_info, load_matview_info, load_view_info, +}; + +use tauri::{Result, State}; + +use super::pool_connection::acquire_client; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_view_info( + project_id: &str, + schema: &str, + view: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_view_info(&client, schema, view) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_matview_info( + project_id: &str, + schema: &str, + matview: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_matview_info(&client, schema, matview) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_function_info( + project_id: &str, + schema: &str, + func_name: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_function_info(&client, schema, func_name) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_generate_ddl( + project_id: &str, + schema: &str, + name: &str, + object_type: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + generate_full_ddl(&client, schema, name, object_type) + .await + .map_err(Into::into) +} diff --git a/src-tauri/src/drivers/pgsql/commands/pool_connection.rs b/src-tauri/src/drivers/pgsql/commands/pool_connection.rs new file mode 100644 index 0000000..6d7e355 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/pool_connection.rs @@ -0,0 +1,278 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use deadpool_postgres::{Manager as PgManager, ManagerConfig, Pool, RecyclingMethod}; + +use crate::AppState; +use crate::common::enums::{AppError, ProjectConnectionStatus}; +use crate::drivers::pgsql::get_pool; + +use native_tls::TlsConnector; +use postgres_native_tls::MakeTlsConnector; +use tauri::{AppHandle, Manager, Result}; +use tokio_postgres::{CancelToken, Config, NoTls}; + +pub(crate) fn is_sqlite_lock_error(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("database is locked") || lower.contains("database busy") +} + +pub(crate) fn full_error_chain(e: &dyn std::error::Error) -> String { + let mut msg = e.to_string(); + let mut src = e.source(); + while let Some(cause) = src { + msg.push_str(": "); + msg.push_str(&cause.to_string()); + src = cause.source(); + } + msg +} + +pub(crate) fn create_pg_pool( + cfg: &Config, + use_ssl: bool, + max_size: usize, +) -> std::result::Result { + let manager_config = ManagerConfig { + recycling_method: RecyclingMethod::Custom("ROLLBACK".into()), + }; + + if use_ssl { + let tls_connector = TlsConnector::builder() + .build() + .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; + let tls = MakeTlsConnector::new(tls_connector); + let manager = PgManager::from_config(cfg.clone(), tls, manager_config); + Pool::builder(manager) + .max_size(max_size) + .build() + .map_err(|e| AppError::ConnectionFailed(e.to_string())) + } else { + let manager = PgManager::from_config(cfg.clone(), NoTls, manager_config); + Pool::builder(manager) + .max_size(max_size) + .build() + .map_err(|e| AppError::ConnectionFailed(e.to_string())) + } +} + +pub(crate) async fn acquire_client( + pools_mutex: &tokio::sync::Mutex>>, + project_id: &str, +) -> std::result::Result { + let pool = { + let pools = pools_mutex.lock().await; + get_pool(&pools, project_id)? + }; + + pool.get() + .await + .map_err(|e| AppError::ConnectionFailed(e.to_string())) +} + +pub(crate) async fn apply_statement_timeout(client: &deadpool_postgres::Client, timeout_ms: u32) { + if timeout_ms > 0 { + client + .simple_query(&format!("SET statement_timeout = {}", timeout_ms)) + .await + .ok(); + } +} + +pub(crate) async fn reset_statement_timeout(client: &deadpool_postgres::Client, timeout_ms: u32) { + if timeout_ms > 0 { + client.simple_query("RESET statement_timeout").await.ok(); + } +} + +pub(crate) async fn set_cancel_token( + app_state: &AppState, + project_id: &str, + token: CancelToken, +) -> std::result::Result<(), AppError> { + let mut cancel_tokens = app_state.cancel_tokens.lock().await; + cancel_tokens.insert(project_id.to_string(), token); + Ok(()) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_test_connection(key: [&str; 6]) -> Result { + let user = key[0]; + let password = key[1]; + let database = key[2]; + let host = key[3]; + let port: u16 = key[4].parse().unwrap_or(5432); + let use_ssl = key[5] == "true"; + + let mut cfg = Config::new(); + cfg.user(user) + .password(password) + .dbname(database) + .host(host) + .port(port); + + let pool = create_pg_pool(&cfg, use_ssl, 1)?; + let client = pool + .get() + .await + .map_err(|e| AppError::ConnectionFailed(full_error_chain(&e)))?; + + let row = client + .query_one("SELECT version()", &[]) + .await + .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; + + let version: String = row.get(0); + Ok(version) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_connector( + project_id: &str, + key: Option<[&str; 6]>, + ssh: Option>, + app: AppHandle, +) -> Result { + let app_state = app.state::(); + { + let clients = app_state.clients.lock().await; + if clients.contains_key(project_id) { + return Ok(ProjectConnectionStatus::Connected); + } + } + + let (user, password, database, host, port_str, use_ssl) = match key { + Some(key) => ( + key[0].to_string(), + key[1].to_string(), + key[2].to_string(), + key[3].to_string(), + key[4].to_string(), + key[5] == "true", + ), + None => { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let mut rows = conn + .query( + "SELECT username, password, database, host, port, ssl FROM projects WHERE id = ?1", + libsql::params![project_id], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let row = rows + .next() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))? + .ok_or_else(|| AppError::ProjectNotFound(project_id.to_string()))?; + ( + row.get::(0).unwrap_or_default(), + row.get::(1).unwrap_or_default(), + row.get::(2).unwrap_or_default(), + row.get::(3).unwrap_or_default(), + row.get::(4).unwrap_or_default(), + row.get::(5).map(|s| s == "true").unwrap_or(false), + ) + } + }; + + let (effective_host, effective_port_str) = if let Some(ref ssh_params) = ssh { + // ssh_params: [ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_path] + if ssh_params.len() >= 3 && !ssh_params[0].is_empty() { + let ssh_host = &ssh_params[0]; + let ssh_port: u16 = ssh_params[1].parse().unwrap_or(22); + let ssh_user = &ssh_params[2]; + let ssh_password = ssh_params + .get(3) + .filter(|s| !s.is_empty()) + .map(|s| s.as_str()); + let ssh_key_path = ssh_params + .get(4) + .filter(|s| !s.is_empty()) + .map(|s| s.as_str()); + + app_state.ssh_tunnels.lock().await.remove(project_id); + + let tunnel = crate::ssh::start_tunnel( + ssh_host, + ssh_port, + ssh_user, + ssh_password, + ssh_key_path, + &host, + port_str.parse().unwrap_or(5432), + ) + .await + .map_err(|e| AppError::ConnectionFailed(e))?; + + let local_port = tunnel.local_port; + app_state + .ssh_tunnels + .lock() + .await + .insert(project_id.to_string(), tunnel); + + ("127.0.0.1".to_string(), local_port.to_string()) + } else { + (host.clone(), port_str.clone()) + } + } else { + (host.clone(), port_str.clone()) + }; + + let port: u16 = effective_port_str.parse().unwrap_or(5432); + let mut cfg = Config::new(); + cfg.user(&user) + .password(&password) + .dbname(&database) + .host(&effective_host) + .port(port); + + let query_pool = match create_pg_pool(&cfg, use_ssl, 16) { + Ok(p) => Arc::new(p), + Err(e) => { + tracing::error!("Query pool creation failed: {:?}", e); + return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); + } + }; + let meta_pool = match create_pg_pool(&cfg, use_ssl, 8) { + Ok(p) => Arc::new(p), + Err(e) => { + tracing::error!("Meta pool creation failed: {:?}", e); + return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); + } + }; + + // Validate connectivity eagerly so connector keeps previous fail/connected behavior. + let query_client = match query_pool.get().await { + Ok(c) => c, + Err(e) => { + tracing::error!("Query pool initial connection failed: {:?}", e); + return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); + } + }; + if let Err(e) = meta_pool.get().await { + tracing::error!("Meta pool initial connection failed: {:?}", e); + return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); + } + + { + let mut clients = app_state.clients.lock().await; + clients.insert(project_id.to_string(), Arc::clone(&query_pool)); + } + { + let mut meta_clients = app_state.meta_clients.lock().await; + meta_clients.insert(project_id.to_string(), Arc::clone(&meta_pool)); + } + { + let mut cancel_tokens = app_state.cancel_tokens.lock().await; + cancel_tokens.insert(project_id.to_string(), query_client.cancel_token()); + } + { + let mut client_ssl = app_state.client_ssl.lock().await; + client_ssl.insert(project_id.to_string(), use_ssl); + } + + Ok(ProjectConnectionStatus::Connected) +} diff --git a/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs b/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs new file mode 100644 index 0000000..064b20d --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs @@ -0,0 +1,176 @@ +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::discover_notify_channels; + +use futures_util::StreamExt; +use native_tls::TlsConnector; +use postgres_native_tls::MakeTlsConnector; +use tauri::{AppHandle, Emitter, Manager, Result, State}; +use tokio_postgres::{AsyncMessage, Config, NoTls}; + +use super::pool_connection::acquire_client; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_listen_start(project_id: &str, channel: &str, app: AppHandle) -> Result { + let app_handle = app.clone(); + let app_state = app_handle.state::(); + let listen_key = format!("{}:{}", project_id, channel); + + { + let handles = app_state.notify_handles.lock().await; + if handles.contains_key(&listen_key) { + return Ok(true); // Already listening + } + } + + let (cfg, use_ssl) = { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let mut rows = conn + .query( + "SELECT username, password, database, host, port, ssl FROM projects WHERE id = ?1", + libsql::params![project_id], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let row = rows + .next() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))? + .ok_or_else(|| AppError::ProjectNotFound(project_id.to_string()))?; + + let mut cfg = Config::new(); + cfg.user(&row.get::(0).unwrap_or_default()) + .password(&row.get::(1).unwrap_or_default()) + .dbname(&row.get::(2).unwrap_or_default()) + .host(&row.get::(3).unwrap_or_default()) + .port( + row.get::(4) + .unwrap_or_default() + .parse() + .unwrap_or(5432), + ); + let ssl = row.get::(5).map(|s| s == "true").unwrap_or(false); + (cfg, ssl) + }; + + let channel_owned = channel.to_string(); + let event_name = format!("pg-notify-{}", project_id); + + let handle = tokio::spawn(async move { + async fn listen_loop( + client: tokio_postgres::Client, + mut connection: tokio_postgres::Connection, + channel: &str, + event_name: &str, + app: &AppHandle, + ) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + let listen_sql = format!("LISTEN \"{}\"", channel.replace('"', "\"\"")); + if let Err(e) = client.batch_execute(&listen_sql).await { + tracing::error!("LISTEN command failed: {:?}", e); + return; + } + tracing::info!("LISTEN started on channel: {}", channel); + + let mut stream = futures_util::stream::poll_fn(move |cx| connection.poll_message(cx)); + + while let Some(msg) = stream.next().await { + match msg { + Ok(AsyncMessage::Notification(n)) => { + let payload = serde_json::json!({ + "channel": n.channel(), + "payload": n.payload(), + }); + let _ = app.emit(event_name, payload); + } + Ok(_) => {} + Err(e) => { + tracing::error!("LISTEN stream error: {:?}", e); + break; + } + } + } + tracing::info!("LISTEN ended on channel: {}", channel); + drop(client); + } + + if use_ssl { + let tls_connector = match TlsConnector::builder().build() { + Ok(c) => c, + Err(e) => { + tracing::error!("LISTEN TLS error: {:?}", e); + return; + } + }; + let tls = MakeTlsConnector::new(tls_connector); + match cfg.connect(tls).await { + Ok((client, connection)) => { + listen_loop(client, connection, &channel_owned, &event_name, &app).await; + } + Err(e) => tracing::error!("LISTEN connect error: {:?}", e), + } + } else { + match cfg.connect(NoTls).await { + Ok((client, connection)) => { + listen_loop(client, connection, &channel_owned, &event_name, &app).await; + } + Err(e) => tracing::error!("LISTEN connect error: {:?}", e), + } + } + }); + + { + let mut handles = app_state.notify_handles.lock().await; + handles.insert(listen_key, handle); + } + + Ok(true) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_listen_stop(project_id: &str, channel: &str, app: AppHandle) -> Result { + let app_state = app.state::(); + let listen_key = format!("{}:{}", project_id, channel); + + let mut handles = app_state.notify_handles.lock().await; + if let Some(handle) = handles.remove(&listen_key) { + handle.abort(); + } + + Ok(true) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_notify_send( + project_id: &str, + channel: &str, + payload: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + let sql = format!( + "SELECT pg_notify('{}', '{}')", + channel.replace('\'', "''"), + payload.replace('\'', "''"), + ); + client + .batch_execute(&sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(true) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_discover_channels( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + discover_notify_channels(&client).await.map_err(Into::into) +} diff --git a/src-tauri/src/drivers/pgsql/commands/query_commands.rs b/src-tauri/src/drivers/pgsql/commands/query_commands.rs new file mode 100644 index 0000000..20b019b --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/query_commands.rs @@ -0,0 +1,222 @@ +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::{ + close_virtual, execute_query, execute_query_packed, execute_query_streamed, execute_virtual, + fetch_virtual_page, +}; + +use native_tls::TlsConnector; +use postgres_native_tls::MakeTlsConnector; +use tauri::ipc::Response; +use tauri::{AppHandle, Manager, Result, State}; +use tokio_postgres::NoTls; + +use super::CELL_SEP; +use super::pool_connection::{ + acquire_client, apply_statement_timeout, reset_statement_timeout, set_cancel_token, +}; +use super::snapshot_persistence::{ + restore_virtual_from_snapshot, snapshot_cleanup_query, snapshot_load_page, snapshot_store_page, + snapshot_upsert_metadata, +}; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_run_query( + project_id: &str, + sql: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + + let result = execute_query(&client, sql).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_cancel_query(project_id: &str, app_state: State<'_, AppState>) -> Result { + let cancel_token = { + let cancel_tokens = app_state.cancel_tokens.lock().await; + cancel_tokens + .get(project_id) + .cloned() + .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string()))? + }; + + let use_ssl = { + let client_ssl = app_state.client_ssl.lock().await; + *client_ssl.get(project_id).unwrap_or(&false) + }; + + if use_ssl { + let tls_connector = TlsConnector::builder() + .build() + .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; + let tls = MakeTlsConnector::new(tls_connector); + cancel_token + .cancel_query(tls) + .await + .map_err(|e| AppError::QueryFailed(format!("Failed to cancel query: {e}")))?; + } else { + cancel_token + .cancel_query(NoTls) + .await + .map_err(|e| AppError::QueryFailed(format!("Failed to cancel query: {e}")))?; + } + + Ok(true) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_run_query_packed( + project_id: &str, + sql: &str, + timeout_ms: Option, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + + let timeout = timeout_ms.unwrap_or(0); + apply_statement_timeout(&client, timeout).await; + let result = execute_query_packed(&client, sql).await; + reset_statement_timeout(&client, timeout).await; + + let result = result?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_run_query_streamed( + project_id: &str, + sql: &str, + stream_id: &str, + app: AppHandle, +) -> Result<()> { + let app_state = app.state::(); + let client = acquire_client(&app_state.clients, project_id).await?; + set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + + execute_query_streamed(&client, sql, stream_id, &app) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_execute_virtual( + project_id: &str, + sql: &str, + query_id: &str, + page_size: usize, + timeout_ms: Option, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + + let timeout = timeout_ms.unwrap_or(0); + apply_statement_timeout(&client, timeout).await; + let result = execute_virtual(&client, &app_state.virtual_cache, sql, query_id, page_size).await; + reset_statement_timeout(&client, timeout).await; + let result = result?; + + let col_count = if result.0.is_empty() { + 0 + } else { + result.0.split(CELL_SEP).count() + }; + if let Err(e) = snapshot_upsert_metadata( + &app_state, project_id, query_id, sql, &result.0, result.1, page_size, col_count, + ) + .await + { + tracing::warn!( + "Failed to persist virtual snapshot metadata for {}: {:?}", + query_id, + e + ); + } + if let Err(e) = snapshot_store_page(&app_state, query_id, 0, &result.2).await { + tracing::warn!( + "Failed to persist virtual snapshot first page for {}: {:?}", + query_id, + e + ); + } + + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_fetch_page( + query_id: &str, + col_count: usize, + offset: usize, + limit: usize, + app_state: State<'_, AppState>, +) -> Result { + let page_index = if limit == 0 { 0 } else { offset / limit }; + + match fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit).await { + Ok(packed) => { + if let Err(e) = snapshot_store_page(&app_state, query_id, page_index, &packed).await { + tracing::warn!("Failed to persist fetched page for {}: {:?}", query_id, e); + } + let json = + sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; + return Ok(Response::new(json)); + } + Err(err) => { + tracing::debug!( + "Virtual cache miss for query {}, trying snapshot fallback: {:?}", + query_id, + err + ); + } + } + + if let Some(packed) = snapshot_load_page(&app_state, query_id, page_index).await? { + let json = + sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; + return Ok(Response::new(json)); + } + + if restore_virtual_from_snapshot(&app_state, query_id).await? { + let packed = + fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit) + .await?; + if let Err(e) = snapshot_store_page(&app_state, query_id, page_index, &packed).await { + tracing::warn!( + "Failed to persist restored page for {} (page {}): {:?}", + query_id, + page_index, + e + ); + } + let json = + sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; + return Ok(Response::new(json)); + } + + Err(AppError::QueryFailed(format!( + "Virtual query {} not found in memory and no snapshot available", + query_id + )) + .into()) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_close_virtual(query_id: &str, app_state: State<'_, AppState>) -> Result<()> { + close_virtual(&app_state.virtual_cache, query_id).await?; + if let Err(e) = snapshot_cleanup_query(&app_state, query_id).await { + tracing::warn!( + "Failed to cleanup virtual snapshot for {}: {:?}", + query_id, + e + ); + } + Ok(()) +} diff --git a/src-tauri/src/drivers/pgsql/commands/snapshot_persistence.rs b/src-tauri/src/drivers/pgsql/commands/snapshot_persistence.rs new file mode 100644 index 0000000..3edccf5 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/snapshot_persistence.rs @@ -0,0 +1,283 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::execute_virtual; + +use tokio::time::{Duration, sleep}; + +use super::pool_connection::{acquire_client, is_sqlite_lock_error, set_cancel_token}; +use super::{CELL_SEP, SNAPSHOT_PAGE_WRITE_RETRIES}; + +#[derive(Clone)] +pub(crate) struct VirtualSnapshotMeta { + pub(crate) project_id: String, + pub(crate) sql: String, + pub(crate) page_size: usize, + pub(crate) col_count: usize, +} + +pub(crate) fn now_unix_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or_default() +} + +pub(crate) async fn snapshot_upsert_metadata( + app_state: &AppState, + project_id: &str, + query_id: &str, + sql: &str, + columns_packed: &str, + total_rows: usize, + page_size: usize, + col_count: usize, +) -> std::result::Result<(), AppError> { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + conn.execute( + "INSERT OR REPLACE INTO virtual_query_snapshots ( + query_id, project_id, sql, columns_packed, total_rows, page_size, col_count, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + libsql::params![ + query_id, + project_id, + sql, + columns_packed, + total_rows as i64, + page_size as i64, + col_count as i64, + now_unix_secs(), + ], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + Ok(()) +} + +pub(crate) async fn snapshot_store_page( + app_state: &AppState, + query_id: &str, + page_index: usize, + packed_page: &str, +) -> std::result::Result<(), AppError> { + if packed_page.is_empty() { + return Ok(()); + } + + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + for attempt in 0..SNAPSHOT_PAGE_WRITE_RETRIES { + match conn + .execute( + "INSERT OR IGNORE INTO virtual_query_pages (query_id, page_index, packed_page) + VALUES (?1, ?2, ?3)", + libsql::params![query_id, page_index as i64, packed_page], + ) + .await + { + Ok(_) => return Ok(()), + Err(e) => { + let msg = e.to_string(); + if is_sqlite_lock_error(&msg) { + if attempt + 1 < SNAPSHOT_PAGE_WRITE_RETRIES { + sleep(Duration::from_millis((attempt as u64 + 1) * 8)).await; + continue; + } + // Snapshot persistence is best-effort; skip noisy lock errors. + tracing::debug!( + "Skipping snapshot page persist for {} page {} due to SQLite lock", + query_id, + page_index + ); + return Ok(()); + } + return Err(AppError::DatabaseError(msg)); + } + } + } + + Ok(()) +} + +pub(crate) async fn snapshot_load_page( + app_state: &AppState, + query_id: &str, + page_index: usize, +) -> std::result::Result, AppError> { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let mut rows = conn + .query( + "SELECT packed_page + FROM virtual_query_pages + WHERE query_id = ?1 AND page_index = ?2 + LIMIT 1", + libsql::params![query_id, page_index as i64], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let maybe_row = rows + .next() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + if let Some(row) = maybe_row { + let packed: String = row + .get(0) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + Ok(Some(packed)) + } else { + Ok(None) + } +} + +pub(crate) async fn snapshot_load_metadata( + app_state: &AppState, + query_id: &str, +) -> std::result::Result, AppError> { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let mut rows = conn + .query( + "SELECT project_id, sql, page_size, col_count + FROM virtual_query_snapshots + WHERE query_id = ?1 + LIMIT 1", + libsql::params![query_id], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let maybe_row = rows + .next() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let Some(row) = maybe_row else { + return Ok(None); + }; + + let project_id: String = row + .get(0) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let sql: String = row + .get(1) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let page_size_i64: i64 = row + .get(2) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let col_count_i64: i64 = row + .get(3) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + if page_size_i64 <= 0 { + return Ok(None); + } + + Ok(Some(VirtualSnapshotMeta { + project_id, + sql, + page_size: page_size_i64 as usize, + col_count: col_count_i64.max(0) as usize, + })) +} + +pub(crate) async fn snapshot_cleanup_query( + app_state: &AppState, + query_id: &str, +) -> std::result::Result<(), AppError> { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + conn.execute( + "DELETE FROM virtual_query_pages WHERE query_id = ?1", + libsql::params![query_id], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + conn.execute( + "DELETE FROM virtual_query_snapshots WHERE query_id = ?1", + libsql::params![query_id], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + Ok(()) +} + +pub(crate) async fn restore_virtual_from_snapshot( + app_state: &AppState, + query_id: &str, +) -> std::result::Result { + let Some(meta) = snapshot_load_metadata(app_state, query_id).await? else { + return Ok(false); + }; + + let client = acquire_client(&app_state.clients, &meta.project_id).await?; + set_cancel_token(app_state, &meta.project_id, client.cancel_token()).await?; + + let (columns_packed, total_rows, first_page_packed, _) = execute_virtual( + &client, + &app_state.virtual_cache, + &meta.sql, + query_id, + meta.page_size, + ) + .await?; + + if columns_packed.is_empty() { + return Ok(false); + } + + let col_count = if meta.col_count > 0 { + meta.col_count + } else { + columns_packed.split(CELL_SEP).count() + }; + + if let Err(e) = snapshot_upsert_metadata( + app_state, + &meta.project_id, + query_id, + &meta.sql, + &columns_packed, + total_rows, + meta.page_size, + col_count, + ) + .await + { + tracing::warn!( + "Failed to refresh snapshot metadata for {}: {:?}", + query_id, + e + ); + } + if let Err(e) = snapshot_store_page(app_state, query_id, 0, &first_page_packed).await { + tracing::warn!( + "Failed to refresh snapshot first page for {}: {:?}", + query_id, + e + ); + } + + Ok(true) +} diff --git a/src-tauri/src/drivers/pgsql/commands/statistics_commands.rs b/src-tauri/src/drivers/pgsql/commands/statistics_commands.rs new file mode 100644 index 0000000..3c9485f --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/statistics_commands.rs @@ -0,0 +1,117 @@ +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::{ + DbStat, FKDetail, ForeignKeyInfo, ObjectStats, load_active_locks, load_activity, + load_database_stats, load_fk_details, load_foreign_keys, load_index_usage, load_table_bloat, + load_table_statistics, load_table_stats, +}; + +use tauri::ipc::Response; +use tauri::{Result, State}; + +use super::pool_connection::acquire_client; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_activity( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + let result = load_activity(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_database_stats( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_database_stats(&client).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_table_stats( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + let result = load_table_stats(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_foreign_keys( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_foreign_keys(&client, schema).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_table_statistics( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_table_statistics(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_fk_details( + project_id: &str, + schema: &str, + table: &str, + direction: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_fk_details(&client, schema, table, direction) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_locks( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_active_locks(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_index_usage( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_index_usage(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_table_bloat( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_table_bloat(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} diff --git a/src-tauri/src/drivers/pgsql/ddl_generation.rs b/src-tauri/src/drivers/pgsql/ddl_generation.rs new file mode 100644 index 0000000..841d8b5 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/ddl_generation.rs @@ -0,0 +1,288 @@ +use tokio_postgres::{Client, SimpleQueryMessage}; + +use crate::common::enums::AppError; + +pub async fn generate_full_ddl( + client: &Client, + schema: &str, + name: &str, + object_type: &str, // "table", "view", "matview", "function" +) -> Result { + match object_type { + "table" => generate_table_ddl(client, schema, name).await, + "view" => generate_view_ddl(client, schema, name).await, + "matview" => generate_matview_ddl(client, schema, name).await, + "function" | "trigger-function" => generate_function_ddl(client, schema, name).await, + _ => Err(AppError::QueryFailed(format!( + "Unknown object type: {}", + object_type + ))), + } +} + +async fn generate_table_ddl( + client: &Client, + schema: &str, + table: &str, +) -> Result { + // Use simple_query so we can handle the complex CTE in one shot + let sql = format!( + r#"WITH col_ddl AS ( + SELECT ordinal_position, + ' "' || column_name || '" ' || + CASE + WHEN udt_name = 'varchar' THEN 'character varying' || COALESCE('(' || character_maximum_length || ')', '') + WHEN udt_name = 'bpchar' THEN 'character' || COALESCE('(' || character_maximum_length || ')', '') + WHEN udt_name = 'numeric' AND numeric_precision IS NOT NULL THEN 'numeric(' || numeric_precision || COALESCE(',' || numeric_scale, '') || ')' + ELSE data_type + END || + CASE WHEN is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END || + CASE WHEN column_default IS NOT NULL THEN ' DEFAULT ' || column_default ELSE '' END AS col_def + FROM information_schema.columns + WHERE table_schema = '{schema}' AND table_name = '{table}' +) +SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# + ); + + let col_result = client + .simple_query(&sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let mut col_defs = String::new(); + for msg in &col_result { + if let SimpleQueryMessage::Row(row) = msg { + col_defs = row.get(0).unwrap_or("").to_string(); + } + } + + let mut ddl = format!("CREATE TABLE \"{schema}\".\"{table}\" (\n{col_defs}\n);\n"); + + fn collect_lines(messages: &[SimpleQueryMessage]) -> Vec { + let mut out = Vec::new(); + for msg in messages { + if let SimpleQueryMessage::Row(row) = msg { + if let Some(line) = row.get(0) { + if !line.is_empty() { + out.push(line.to_string()); + } + } + } + } + out + } + + let con_sql = format!( + r#"SELECT 'ALTER TABLE "{schema}"."{table}" ADD CONSTRAINT "' || con.conname || '" ' || pg_get_constraintdef(con.oid) || ';' + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{schema}' AND c.relname = '{table}' + ORDER BY CASE con.contype WHEN 'p' THEN 0 WHEN 'u' THEN 1 WHEN 'f' THEN 2 ELSE 3 END"# + ); + let con_result = client + .simple_query(&con_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&con_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + let idx_sql = format!( + r#"SELECT pg_get_indexdef(i.indexrelid) || ';' + FROM pg_index i + JOIN pg_class tbl ON tbl.oid = i.indrelid + JOIN pg_namespace n ON n.oid = tbl.relnamespace + WHERE n.nspname = '{schema}' AND tbl.relname = '{table}' + AND NOT i.indisprimary + AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid)"# + ); + let idx_result = client + .simple_query(&idx_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&idx_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + let trig_sql = format!( + r#"SELECT pg_get_triggerdef(t.oid) || ';' + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{schema}' AND c.relname = '{table}' + AND NOT t.tgisinternal"# + ); + let trig_result = client + .simple_query(&trig_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&trig_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + let rls_sql = format!( + r#"SELECT CASE WHEN c.relrowsecurity THEN 'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY;' ELSE '' END + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{schema}' AND c.relname = '{table}'"# + ); + let rls_result = client + .simple_query(&rls_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&rls_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + let pol_sql = format!( + r#"SELECT 'CREATE POLICY "' || pol.polname || '" ON "{schema}"."{table}"' || + CASE pol.polcmd WHEN 'r' THEN ' FOR SELECT' WHEN 'a' THEN ' FOR INSERT' WHEN 'w' THEN ' FOR UPDATE' WHEN 'd' THEN ' FOR DELETE' WHEN '*' THEN '' END || + CASE WHEN pol.polpermissive THEN ' AS PERMISSIVE' ELSE ' AS RESTRICTIVE' END || + COALESCE(E'\n USING (' || pg_get_expr(pol.polqual, pol.polrelid) || ')', '') || + COALESCE(E'\n WITH CHECK (' || pg_get_expr(pol.polwithcheck, pol.polrelid) || ')', '') || + ';' + FROM pg_policy pol + JOIN pg_class c ON c.oid = pol.polrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{schema}' AND c.relname = '{table}'"# + ); + let pol_result = client + .simple_query(&pol_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&pol_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + let cmt_sql = format!( + r#"SELECT 'COMMENT ON TABLE "{schema}"."{table}" IS ' || quote_literal(d.description) || ';' + FROM pg_description d + JOIN pg_class c ON c.oid = d.objoid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{schema}' AND c.relname = '{table}' AND d.objsubid = 0"# + ); + let cmt_result = client + .simple_query(&cmt_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&cmt_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + let col_cmt_sql = format!( + r#"SELECT 'COMMENT ON COLUMN "{schema}"."{table}"."' || a.attname || '" IS ' || quote_literal(d.description) || ';' + FROM pg_description d + JOIN pg_class c ON c.oid = d.objoid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid + WHERE n.nspname = '{schema}' AND c.relname = '{table}' AND d.objsubid > 0 + ORDER BY d.objsubid"# + ); + let col_cmt_result = client + .simple_query(&col_cmt_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&col_cmt_result) { + ddl.push_str(&line); + ddl.push('\n'); + } + + Ok(ddl.trim_end().to_string()) +} + +async fn generate_view_ddl(client: &Client, schema: &str, view: &str) -> Result { + let sql = format!( + r#"SELECT 'CREATE OR REPLACE VIEW "{schema}"."{view}" AS' || E'\n' || pg_get_viewdef('"{schema}"."{view}"'::regclass, true) || ';'"# + ); + let result = client + .simple_query(&sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for msg in &result { + if let SimpleQueryMessage::Row(row) = msg { + return Ok(row.get(0).unwrap_or("").to_string()); + } + } + Ok(String::new()) +} + +async fn generate_matview_ddl( + client: &Client, + schema: &str, + matview: &str, +) -> Result { + let sql = format!( + r#"SELECT 'CREATE MATERIALIZED VIEW "{schema}"."{matview}" AS' || E'\n' || definition + FROM pg_matviews + WHERE schemaname = '{schema}' AND matviewname = '{matview}'"# + ); + let result = client + .simple_query(&sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let mut ddl = String::new(); + for msg in &result { + if let SimpleQueryMessage::Row(row) = msg { + ddl = row.get(0).unwrap_or("").to_string(); + } + } + + let idx_sql = format!( + r#"SELECT pg_get_indexdef(i.indexrelid) || ';' + FROM pg_index i + JOIN pg_class tbl ON tbl.oid = i.indrelid + JOIN pg_namespace n ON n.oid = tbl.relnamespace + WHERE n.nspname = '{schema}' AND tbl.relname = '{matview}'"# + ); + let idx_result = client + .simple_query(&idx_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for msg in &idx_result { + if let SimpleQueryMessage::Row(row) = msg { + if let Some(line) = row.get(0) { + ddl.push('\n'); + ddl.push_str(line); + } + } + } + + Ok(ddl.trim_end().to_string()) +} + +async fn generate_function_ddl( + client: &Client, + schema: &str, + func_name: &str, +) -> Result { + let sql = format!( + r#"SELECT pg_get_functiondef(p.oid) + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = '{schema}' AND p.proname = '{func_name}' + LIMIT 1"# + ); + let result = client + .simple_query(&sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for msg in &result { + if let SimpleQueryMessage::Row(row) = msg { + return Ok(row.get(0).unwrap_or("").to_string()); + } + } + Ok(String::new()) +} diff --git a/src-tauri/src/drivers/pgsql/extensions.rs b/src-tauri/src/drivers/pgsql/extensions.rs new file mode 100644 index 0000000..e720263 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/extensions.rs @@ -0,0 +1,105 @@ +use crate::common::enums::AppError; + +pub async fn load_extensions( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + e.extname AS name, + e.extversion AS installed_version, + COALESCE(a.default_version, '') AS default_version, + COALESCE(a.comment, '') AS comment, + n.nspname AS schema + FROM pg_extension e + JOIN pg_namespace n ON n.oid = e.extnamespace + LEFT JOIN pg_available_extensions a ON a.name = e.extname + ORDER BY e.extname", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..5).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_available_extensions( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + a.name, + COALESCE(a.default_version, '') AS version, + COALESCE(a.comment, '') AS comment + FROM pg_available_extensions a + LEFT JOIN pg_extension e ON e.extname = a.name + WHERE e.oid IS NULL + ORDER BY a.name", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..3).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_enum_types( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + n.nspname AS schema, + t.typname AS name, + string_agg(e.enumlabel, ', ' ORDER BY e.enumsortorder) AS labels + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_enum e ON e.enumtypid = t.oid + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + GROUP BY n.nspname, t.typname + ORDER BY n.nspname, t.typname", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..3).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_pg_settings( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + name, + COALESCE(setting, '') AS setting, + COALESCE(unit, '') AS unit, + category, + COALESCE(short_desc, '') AS description, + context, + COALESCE(source, '') AS source, + COALESCE(boot_val, '') AS boot_val, + COALESCE(reset_val, '') AS reset_val + FROM pg_settings + ORDER BY category, name", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..9).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} diff --git a/src-tauri/src/drivers/pgsql/metadata_schema.rs b/src-tauri/src/drivers/pgsql/metadata_schema.rs new file mode 100644 index 0000000..f48a373 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/metadata_schema.rs @@ -0,0 +1,282 @@ +use deadpool_postgres::Pool; +use tokio::time as tokio_time; +use tokio_postgres::Client; + +use crate::common::enums::AppError; +use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; + +use super::{ColumnDetail, ConstraintDetail, IndexDetail, PolicyDetail, RuleDetail, TriggerDetail}; + +pub async fn load_schemas(client: &Client, query_sql: &str) -> Result { + let rows = tokio_time::timeout( + tokio_time::Duration::from_secs(10), + client.query(query_sql, &[]), + ) + .await + .map_err(|_| AppError::QueryTimeout)? + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| r.get(0)).collect()) +} + +pub async fn load_databases(pool: &Pool) -> Result, AppError> { + let client = pool + .get() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let rows = client + .query( + "SELECT datname FROM pg_database WHERE datallowconn = true AND datistemplate = false ORDER BY datname", + &[], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) +} + +pub async fn load_tablespaces(pool: &Pool) -> Result, AppError> { + let client = pool + .get() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let rows = client + .query( + "SELECT spcname, pg_catalog.pg_get_userbyid(spcowner) AS owner, \ + COALESCE(pg_catalog.pg_tablespace_location(oid), '') AS location \ + FROM pg_catalog.pg_tablespace ORDER BY spcname", + &[], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + Ok(rows + .iter() + .map(|r| { + ( + r.get::<_, String>(0), + r.get::<_, String>(1), + r.get::<_, String>(2), + ) + }) + .collect()) +} + +pub async fn load_tables( + client: &Client, + query_sql: &str, + schema: &str, +) -> Result { + let rows = client + .query(query_sql, &[&schema]) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| (r.get(0), r.get(1))).collect()) +} + +pub async fn load_columns( + client: &Client, + schema: &str, + table: &str, +) -> Result { + let rows = client + .query( + r#"SELECT column_name + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + ORDER BY ordinal_position"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) +} + +pub async fn load_column_details( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + ORDER BY ordinal_position"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let data_type: String = r.get(1); + let nullable_str: String = r.get(2); + let default_val: Option = r.get(3); + (name, data_type, nullable_str == "YES", default_val) + }) + .collect()) +} + +pub async fn load_indexes( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT + i.relname AS index_name, + a.attname AS column_name, + ix.indisunique AS is_unique, + ix.indisprimary AS is_primary + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) + WHERE n.nspname = $1 AND t.relname = $2 + ORDER BY i.relname, a.attnum"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let index_name: String = r.get(0); + let column_name: String = r.get(1); + let is_unique: bool = r.get(2); + let is_primary: bool = r.get(3); + (index_name, column_name, is_unique, is_primary) + }) + .collect()) +} + +pub async fn load_triggers( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT DISTINCT trigger_name, event_manipulation, action_timing + FROM information_schema.triggers + WHERE trigger_schema = $1 AND event_object_table = $2 + ORDER BY trigger_name"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let event: String = r.get(1); + let timing: String = r.get(2); + (name, event, timing) + }) + .collect()) +} + +pub async fn load_rules( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT rulename, ev_type + FROM pg_rules + WHERE schemaname = $1 AND tablename = $2 + ORDER BY rulename"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let event: String = r.get(1); + (name, event) + }) + .collect()) +} + +pub async fn load_policies( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT pol.polname, + CASE WHEN pol.polpermissive THEN 'PERMISSIVE' ELSE 'RESTRICTIVE' END, + CASE pol.polcmd + WHEN 'r' THEN 'SELECT' + WHEN 'a' THEN 'INSERT' + WHEN 'w' THEN 'UPDATE' + WHEN 'd' THEN 'DELETE' + WHEN '*' THEN 'ALL' + ELSE pol.polcmd::text + END + FROM pg_policy pol + JOIN pg_class c ON c.oid = pol.polrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 + ORDER BY pol.polname"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let perm: String = r.get(1); + let cmd: String = r.get(2); + (name, perm, cmd) + }) + .collect()) +} + +pub async fn load_constraints( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT + tc.constraint_name, + tc.constraint_type, + COALESCE(kcu.column_name, '') + FROM information_schema.table_constraints tc + LEFT JOIN information_schema.key_column_usage kcu + ON kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = $1 AND tc.table_name = $2 + ORDER BY tc.constraint_name, kcu.ordinal_position"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let ctype: String = r.get(1); + let col: String = r.get(2); + (name, ctype, col) + }) + .collect()) +} diff --git a/src-tauri/src/drivers/pgsql/metadata_views_functions.rs b/src-tauri/src/drivers/pgsql/metadata_views_functions.rs new file mode 100644 index 0000000..e3f6a88 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/metadata_views_functions.rs @@ -0,0 +1,212 @@ +use tokio_postgres::Client; + +use crate::common::enums::AppError; + +use super::{FunctionInfo, ObjectStats}; + +pub async fn load_views(client: &Client, schema: &str) -> Result, AppError> { + let rows = client + .query( + r#"SELECT table_name + FROM information_schema.views + WHERE table_schema = $1 + ORDER BY table_name"#, + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) +} + +pub async fn load_materialized_views( + client: &Client, + schema: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT matviewname + FROM pg_matviews + WHERE schemaname = $1 + ORDER BY matviewname"#, + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) +} + +pub async fn load_functions(client: &Client, schema: &str) -> Result, AppError> { + let rows = client + .query( + r#"SELECT p.proname, + pg_get_function_result(p.oid), + pg_get_function_arguments(p.oid) + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 + AND p.prokind IN ('f', 'p') + AND pg_get_function_result(p.oid) != 'trigger' + ORDER BY p.proname"#, + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let ret: String = r.get(1); + let args: String = r.get(2); + (name, ret, args) + }) + .collect()) +} + +pub async fn load_trigger_functions( + client: &Client, + schema: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT p.proname, + pg_get_function_arguments(p.oid) + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 + AND pg_get_function_result(p.oid) = 'trigger' + ORDER BY p.proname"#, + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let args: String = r.get(1); + (name, args) + }) + .collect()) +} + +pub async fn load_view_info( + client: &Client, + schema: &str, + view: &str, +) -> Result { + let rows = client + .query( + r#"SELECT + COALESCE(v.is_updatable, 'NO'), + COALESCE(v.check_option, 'NONE'), + pg_get_viewdef(c.oid, true) + FROM information_schema.views v + JOIN pg_class c ON c.relname = v.table_name + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = v.table_schema + WHERE v.table_schema = $1 AND v.table_name = $2 + LIMIT 1"#, + &[&schema, &view], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + if let Some(row) = rows.first() { + Ok(vec![ + ("is_updatable".into(), row.get::<_, String>(0)), + ("check_option".into(), row.get::<_, String>(1)), + ("definition".into(), row.get::<_, String>(2)), + ]) + } else { + Ok(Vec::new()) + } +} + +pub async fn load_matview_info( + client: &Client, + schema: &str, + matview: &str, +) -> Result { + let sql = r#"SELECT + c.reltuples::bigint::text, + pg_size_pretty(pg_total_relation_size(c.oid)), + CASE WHEN m.ispopulated THEN 'YES' ELSE 'NO' END, + m.definition + FROM pg_matviews m + JOIN pg_class c ON c.relname = m.matviewname + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = m.schemaname + WHERE m.schemaname = $1 AND m.matviewname = $2 + LIMIT 1"#; + + let rows = client + .query(sql, &[&schema, &matview]) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + if let Some(row) = rows.first() { + Ok(vec![ + ("row_estimate".into(), row.get::<_, String>(0)), + ("total_size".into(), row.get::<_, String>(1)), + ("is_populated".into(), row.get::<_, String>(2)), + ("definition".into(), row.get::<_, String>(3)), + ]) + } else { + Ok(Vec::new()) + } +} + +pub async fn load_function_info( + client: &Client, + schema: &str, + func_name: &str, +) -> Result { + let rows = client + .query( + r#"SELECT + l.lanname, + CASE p.provolatile WHEN 'i' THEN 'IMMUTABLE' WHEN 's' THEN 'STABLE' WHEN 'v' THEN 'VOLATILE' ELSE '' END, + p.proisstrict::text, + p.prosecdef::text, + p.procost::text, + p.prorows::text, + pg_get_function_result(p.oid), + pg_get_function_arguments(p.oid), + p.prosrc + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + JOIN pg_language l ON l.oid = p.prolang + WHERE n.nspname = $1 AND p.proname = $2 + LIMIT 1"#, + &[&schema, &func_name], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let keys = [ + "language", + "volatility", + "is_strict", + "security_definer", + "estimated_cost", + "estimated_rows", + "return_type", + "arguments", + "source", + ]; + + if let Some(row) = rows.first() { + Ok(keys + .iter() + .enumerate() + .map(|(i, k)| { + let val: Option = row.try_get(i).ok(); + (k.to_string(), val.unwrap_or_default()) + }) + .collect()) + } else { + Ok(Vec::new()) + } +} diff --git a/src-tauri/src/drivers/pgsql/mod.rs b/src-tauri/src/drivers/pgsql/mod.rs new file mode 100644 index 0000000..7dcff7d --- /dev/null +++ b/src-tauri/src/drivers/pgsql/mod.rs @@ -0,0 +1,91 @@ +use deadpool_postgres::Pool; +use std::sync::Arc; + +use crate::common::enums::AppError; + +pub mod commands; +pub mod ddl_generation; +pub mod extensions; +pub mod metadata_schema; +pub mod metadata_views_functions; +pub mod query_execution; +pub mod roles_schema_objects; +pub mod statistics_activity; + +pub use commands::*; +pub use ddl_generation::*; +pub use extensions::*; +pub use metadata_schema::*; +pub use metadata_views_functions::*; +pub use query_execution::*; +pub use roles_schema_objects::*; +pub use statistics_activity::*; + +/// Safely get a pool Arc from the AppState client map. +/// Returns a cloned Arc so the caller can drop the MutexGuard immediately. +pub fn get_pool( + clients_guard: &std::collections::BTreeMap>, + project_id: &str, +) -> Result, AppError> { + clients_guard + .get(project_id) + .cloned() + .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string())) +} + +/// Cell separator for packed format (Unit Separator, ASCII 0x1F) +pub(crate) const CELL_SEP: char = '\x1F'; +/// Row separator for packed format (Record Separator, ASCII 0x1E) +pub(crate) const ROW_SEP: char = '\x1E'; + +/// A cached query: pre-packed page strings for zero-copy serving. +/// Each page is a single large String (~1-2 MB) so the OS reclaims RSS on drop. +pub struct CachedQuery { + pub(crate) pages: Vec, + pub(crate) page_size: usize, +} + +pub type VirtualCache = std::collections::BTreeMap; + +/// Column detail info: (name, data_type, nullable, default_value) +pub type ColumnDetail = (String, String, bool, Option); + +/// Index info: (index_name, column_name, is_unique, is_primary) +pub type IndexDetail = (String, String, bool, bool); + +/// Trigger info: (trigger_name, event, timing) +pub type TriggerDetail = (String, String, String); + +/// Rule info: (rule_name, event) +pub type RuleDetail = (String, String); + +/// Policy info: (policy_name, permissive, command) +pub type PolicyDetail = (String, String, String); + +/// Function info: (name, return_type, arguments) +pub type FunctionInfo = (String, String, String); + +/// Database stats: (stat_name, stat_value) +pub type DbStat = (String, String); + +/// Constraint info: (constraint_name, constraint_type, column_name) +pub type ConstraintDetail = (String, String, String); + +/// FK relation: (source_table, source_column, target_table, target_column) +pub type ForeignKeyInfo = (String, String, String, String); + +/// Table statistics: Vec of (key, value) pairs +pub type ObjectStats = Vec<(String, String)>; + +/// FK detail: (constraint_name, source_schema, source_table, source_column, target_schema, target_table, target_column, on_update, on_delete) +pub type FKDetail = ( + String, + String, + String, + String, + String, + String, + String, + String, + String, +); diff --git a/src-tauri/src/drivers/pgsql/query_execution/helpers.rs b/src-tauri/src/drivers/pgsql/query_execution/helpers.rs new file mode 100644 index 0000000..35898f6 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/query_execution/helpers.rs @@ -0,0 +1,109 @@ +use tokio_postgres::SimpleQueryMessage; + +use super::super::{CELL_SEP, ROW_SEP}; + +/// Process simple_query messages, returning the last result set that had rows. +/// If no result set had rows but commands ran, returns synthetic "N rows affected". +/// If nothing at all, returns empty vecs. +pub(crate) fn process_simple_messages( + messages: Vec, +) -> (Vec, Vec>) { + let mut cur_columns: Vec = Vec::new(); + let mut cur_rows: Vec> = Vec::new(); + let mut last_columns: Vec = Vec::new(); + let mut last_rows: Vec> = Vec::new(); + let mut has_row_result = false; + let mut total_affected: u64 = 0; + + for msg in messages { + match msg { + SimpleQueryMessage::Row(row) => { + let col_count = row.columns().len(); + if cur_columns.is_empty() { + cur_columns = Vec::with_capacity(col_count); + for c in row.columns() { + cur_columns.push(c.name().to_owned()); + } + } + let mut cells = Vec::with_capacity(col_count); + for i in 0..col_count { + cells.push(row.get(i).unwrap_or("null").to_owned()); + } + cur_rows.push(cells); + } + SimpleQueryMessage::CommandComplete(n) => { + if !cur_rows.is_empty() { + last_columns = std::mem::take(&mut cur_columns); + last_rows = std::mem::take(&mut cur_rows); + has_row_result = true; + } else { + cur_columns.clear(); + cur_rows.clear(); + } + total_affected += n; + } + _ => {} + } + } + + // Handle trailing rows (shouldn't happen but be safe) + if !cur_rows.is_empty() { + return (cur_columns, cur_rows); + } + + if has_row_result { + (last_columns, last_rows) + } else if total_affected > 0 { + ( + vec!["Result".into()], + vec![vec![format!("{} rows affected", total_affected)]], + ) + } else { + (Vec::new(), Vec::new()) + } +} + +/// Join string slices with a char separator — avoids .to_string() on the separator. +#[inline] +pub(crate) fn join_sep(items: &[String], sep: char) -> String { + let total: usize = items.iter().map(|s| s.len()).sum::() + items.len(); + let mut out = String::with_capacity(total); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(sep); + } + out.push_str(item); + } + out +} + +/// Pack a slice of rows (each row = Vec) into wire format. +/// Pre-allocates capacity and writes directly — zero intermediate allocations. +pub(crate) fn pack_rows_vec(rows: &[Vec]) -> String { + if rows.is_empty() { + return String::new(); + } + // Estimate capacity: avg ~20 chars per cell + let est = rows.len() * rows.first().map_or(10, |r| r.len()) * 20; + let mut out = String::with_capacity(est); + + for (ri, row) in rows.iter().enumerate() { + if ri > 0 { + out.push(ROW_SEP); + } + for (ci, cell) in row.iter().enumerate() { + if ci > 0 { + out.push(CELL_SEP); + } + // Inline separator sanitization — avoids .replace() allocations + for ch in cell.chars() { + if ch == CELL_SEP || ch == ROW_SEP { + out.push(' '); + } else { + out.push(ch); + } + } + } + } + out +} diff --git a/src-tauri/src/drivers/pgsql/query_execution/mod.rs b/src-tauri/src/drivers/pgsql/query_execution/mod.rs new file mode 100644 index 0000000..94f3a0c --- /dev/null +++ b/src-tauri/src/drivers/pgsql/query_execution/mod.rs @@ -0,0 +1,8 @@ +mod helpers; +mod simple; +mod streaming; +mod virtual_cache; + +pub use simple::*; +pub use streaming::*; +pub use virtual_cache::*; diff --git a/src-tauri/src/drivers/pgsql/query_execution/simple.rs b/src-tauri/src/drivers/pgsql/query_execution/simple.rs new file mode 100644 index 0000000..28cac88 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/query_execution/simple.rs @@ -0,0 +1,57 @@ +use std::time::Instant; +use tokio_postgres::Client; + +use crate::common::enums::AppError; + +use super::super::{CELL_SEP, ROW_SEP}; +use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; + +/// Execute a timed query and return (columns, rows_as_strings, elapsed_ms). +/// Uses simple_query protocol — PG returns all values as text, no type conversion needed. +/// Supports multi-statement: returns the last result set that had rows. +pub async fn execute_query( + client: &Client, + sql: &str, +) -> Result<(Vec, Vec>, f32), AppError> { + let start = Instant::now(); + let messages = client + .simple_query(sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let (columns, rows) = process_simple_messages(messages); + let elapsed = start.elapsed().as_millis() as f32; + Ok((columns, rows, elapsed)) +} + +/// Execute a timed query and return results in compact packed string format. +/// Format: "col1\x1Fcol2\x1E row1val1\x1Frow1val2\x1E row2val1\x1Frow2val2" +/// Uses simple_query protocol with multi-statement support. +pub async fn execute_query_packed(client: &Client, sql: &str) -> Result<(String, f32), AppError> { + let start = Instant::now(); + let messages = client + .simple_query(sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let (columns, rows) = process_simple_messages(messages); + + if columns.is_empty() { + return Ok((String::new(), start.elapsed().as_millis() as f32)); + } + + let header = join_sep(&columns, CELL_SEP); + let body = pack_rows_vec(&rows); + + let packed = if body.is_empty() { + header + } else { + let mut s = String::with_capacity(header.len() + 1 + body.len()); + s.push_str(&header); + s.push(ROW_SEP); + s.push_str(&body); + s + }; + let elapsed = start.elapsed().as_millis() as f32; + Ok((packed, elapsed)) +} diff --git a/src-tauri/src/drivers/pgsql/query_execution/streaming.rs b/src-tauri/src/drivers/pgsql/query_execution/streaming.rs new file mode 100644 index 0000000..9e362fb --- /dev/null +++ b/src-tauri/src/drivers/pgsql/query_execution/streaming.rs @@ -0,0 +1,172 @@ +use std::time::Instant; +use tokio_postgres::{Client, SimpleQueryMessage}; + +use crate::common::enums::AppError; + +use super::super::CELL_SEP; +use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; + +/// Events emitted during streamed query execution. +#[derive(serde::Serialize, Clone)] +#[serde(tag = "type")] +pub enum QueryStreamEvent { + #[serde(rename = "columns")] + Columns { columns: String, total_rows: usize }, + #[serde(rename = "chunk")] + Chunk { data: String }, + #[serde(rename = "done")] + Done { elapsed: f32, capped: bool }, +} + +/// Maximum rows to send to the frontend to prevent OOM in the webview. +const MAX_STREAM_ROWS: usize = 500_000; +const CURSOR_FETCH_SIZE: usize = 10_000; + +/// Stream query results using a PostgreSQL cursor. +/// Fetches rows in batches from the server — never loads the full result into Rust memory. +/// Caps at MAX_STREAM_ROWS to protect the webview from OOM. +pub async fn execute_query_streamed( + client: &Client, + sql: &str, + stream_id: &str, + app: &tauri::AppHandle, +) -> Result<(), AppError> { + use tauri::Emitter; + + let start = Instant::now(); + let event_name = format!("query-stream-{}", stream_id); + + // Begin transaction + declare cursor for memory-efficient streaming + client + .batch_execute("BEGIN") + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let cursor_sql = format!("DECLARE _rsql_cur NO SCROLL CURSOR FOR {}", sql); + match client.batch_execute(&cursor_sql).await { + Ok(_) => { + // Cursor-based fetch loop using simple_query for zero type conversion + let fetch_sql = format!("FETCH {} FROM _rsql_cur", CURSOR_FETCH_SIZE); + let mut total_sent: usize = 0; + let mut columns_sent = false; + let mut capped = false; + + loop { + let messages = match client.simple_query(&fetch_sql).await { + Ok(msgs) => msgs, + Err(e) => { + let _ = client.batch_execute("CLOSE _rsql_cur; ROLLBACK").await; + return Err(AppError::QueryFailed(e.to_string())); + } + }; + + let mut batch_rows: Vec> = Vec::new(); + let mut batch_columns: Option> = None; + + for msg in messages { + if let SimpleQueryMessage::Row(row) = msg { + let col_count = row.columns().len(); + if batch_columns.is_none() { + let mut cols = Vec::with_capacity(col_count); + for c in row.columns() { + cols.push(c.name().to_owned()); + } + batch_columns = Some(cols); + } + let mut cells = Vec::with_capacity(col_count); + for i in 0..col_count { + cells.push(row.get(i).unwrap_or("null").to_owned()); + } + batch_rows.push(cells); + } + } + + if batch_rows.is_empty() { + break; + } + + if !columns_sent && let Some(cols) = batch_columns { + let header = join_sep(&cols, CELL_SEP); + let _ = app.emit( + &event_name, + QueryStreamEvent::Columns { + columns: header, + total_rows: 0, + }, + ); + columns_sent = true; + } + + let packed = pack_rows_vec(&batch_rows); + let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); + + total_sent += batch_rows.len(); + if total_sent >= MAX_STREAM_ROWS { + capped = true; + break; + } + } + + if !columns_sent { + let _ = app.emit( + &event_name, + QueryStreamEvent::Columns { + columns: String::new(), + total_rows: 0, + }, + ); + } + + client.batch_execute("CLOSE _rsql_cur").await.ok(); + client.batch_execute("COMMIT").await.ok(); + + let elapsed = start.elapsed().as_millis() as f32; + let _ = app.emit(&event_name, QueryStreamEvent::Done { elapsed, capped }); + } + Err(_cursor_err) => { + // DECLARE CURSOR failed (non-SELECT query like INSERT/UPDATE/DDL) + client.batch_execute("ROLLBACK").await.ok(); + + // Re-execute with simple_query for multi-statement support + let messages = client + .simple_query(sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let (columns, rows) = process_simple_messages(messages); + + if columns.is_empty() { + let _ = app.emit( + &event_name, + QueryStreamEvent::Columns { + columns: String::new(), + total_rows: 0, + }, + ); + } else { + let header = join_sep(&columns, CELL_SEP); + let _ = app.emit( + &event_name, + QueryStreamEvent::Columns { + columns: header, + total_rows: rows.len(), + }, + ); + + let packed = pack_rows_vec(&rows); + let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); + } + + let elapsed = start.elapsed().as_millis() as f32; + let _ = app.emit( + &event_name, + QueryStreamEvent::Done { + elapsed, + capped: false, + }, + ); + } + } + + Ok(()) +} diff --git a/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs b/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs new file mode 100644 index 0000000..83caeec --- /dev/null +++ b/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs @@ -0,0 +1,98 @@ +use rayon::prelude::*; +use std::time::Instant; +use tokio_postgres::Client; + +use crate::common::enums::AppError; + +use super::super::{CELL_SEP, CachedQuery, ROW_SEP, VirtualCache}; +use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; + +/// Execute a query in one shot using simple_query protocol. +/// Pre-packs results into page-sized strings cached in-memory. +/// Returns (columns_packed, total_rows, first_page_packed, elapsed_ms). +/// If the SQL is non-SELECT / returns 0 rows, returns empty columns_packed signal +/// with a synthetic affected-rows message in first_page_packed when applicable. +pub async fn execute_virtual( + client: &Client, + cache: &tokio::sync::Mutex, + sql: &str, + query_id: &str, + page_size: usize, +) -> Result<(String, usize, String, f32), AppError> { + let start = Instant::now(); + + let messages = client + .simple_query(sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let (columns, all_rows) = process_simple_messages(messages); + + if columns.is_empty() { + let elapsed = start.elapsed().as_millis() as f32; + return Ok((String::new(), 0, String::new(), elapsed)); + } + + // Synthetic "N rows affected" result — pass through as fallback format + if columns.len() == 1 && columns[0] == "Result" { + let mut fallback = String::with_capacity(64); + fallback.push_str(&columns[0]); + fallback.push(ROW_SEP); + if let Some(r) = all_rows.first() { + fallback.push_str(&join_sep(r, CELL_SEP)); + } + let elapsed = start.elapsed().as_millis() as f32; + return Ok((String::new(), 0, fallback, elapsed)); + } + + let total_rows = all_rows.len(); + + // Pre-pack into pages — use rayon only for large results (>50K rows) + let chunks: Vec<&[Vec]> = all_rows.chunks(page_size).collect(); + let pages: Vec = if total_rows > 50_000 { + chunks + .par_iter() + .map(|chunk| pack_rows_vec(chunk)) + .collect() + } else { + chunks.iter().map(|chunk| pack_rows_vec(chunk)).collect() + }; + + let columns_packed = join_sep(&columns, CELL_SEP); + let first_page_packed = pages.first().cloned().unwrap_or_default(); + + { + let mut c = cache.lock().await; + c.insert(query_id.to_string(), CachedQuery { pages, page_size }); + } + + let elapsed = start.elapsed().as_millis() as f32; + Ok((columns_packed, total_rows, first_page_packed, elapsed)) +} + +/// Fetch a pre-packed page from the in-memory cache. O(1) — no packing at serve time. +pub async fn fetch_virtual_page( + cache: &tokio::sync::Mutex, + query_id: &str, + _col_count: usize, + offset: usize, + _limit: usize, +) -> Result { + let c = cache.lock().await; + let entry = c + .get(query_id) + .ok_or_else(|| AppError::QueryFailed(format!("Virtual query {} not found", query_id)))?; + + let page_index = offset / entry.page_size; + Ok(entry.pages.get(page_index).cloned().unwrap_or_default()) +} + +/// Remove a query from the in-memory cache. Large page strings are freed → OS reclaims RSS. +pub async fn close_virtual( + cache: &tokio::sync::Mutex, + query_id: &str, +) -> Result<(), AppError> { + let mut c = cache.lock().await; + c.remove(query_id); + Ok(()) +} diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs new file mode 100644 index 0000000..deac3d5 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs @@ -0,0 +1,108 @@ +use crate::common::enums::AppError; + +pub async fn parse_csv_preview( + file_path: &str, + max_rows: usize, +) -> Result<(Vec, Vec>), AppError> { + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .from_path(file_path) + .map_err(|e| AppError::QueryFailed(format!("Failed to read CSV: {}", e)))?; + + let headers: Vec = rdr + .headers() + .map_err(|e| AppError::QueryFailed(format!("Failed to parse CSV headers: {}", e)))? + .iter() + .map(|h| h.to_string()) + .collect(); + + let mut rows = Vec::new(); + for result in rdr.records().take(max_rows) { + let record = + result.map_err(|e| AppError::QueryFailed(format!("CSV parse error: {}", e)))?; + rows.push(record.iter().map(|f| f.to_string()).collect()); + } + + Ok((headers, rows)) +} + +pub async fn import_csv_to_table( + client: &deadpool_postgres::Client, + file_path: &str, + schema: &str, + table: &str, + column_mapping: &[(usize, String)], +) -> Result { + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .from_path(file_path) + .map_err(|e| AppError::QueryFailed(format!("Failed to read CSV: {}", e)))?; + + if column_mapping.is_empty() { + return Err(AppError::QueryFailed( + "No column mapping provided".to_string(), + )); + } + + let col_names: Vec = column_mapping + .iter() + .map(|(_, name)| format!("\"{}\"", name)) + .collect(); + let placeholders: Vec = (1..=column_mapping.len()) + .map(|i| format!("${}", i)) + .collect(); + + let insert_sql = format!( + "INSERT INTO \"{}\".\"{}\" ({}) VALUES ({})", + schema, + table, + col_names.join(", "), + placeholders.join(", "), + ); + + let statement = client + .prepare(&insert_sql) + .await + .map_err(|e| AppError::QueryFailed(format!("Failed to prepare statement: {}", e)))?; + + client + .execute("BEGIN", &[]) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let mut imported = 0usize; + for result in rdr.records() { + let record = result.map_err(|e| { + AppError::QueryFailed(format!("CSV parse error at row {}: {}", imported + 1, e)) + })?; + + let values: Vec = column_mapping + .iter() + .map(|(idx, _)| record.get(*idx).unwrap_or("").to_string()) + .collect(); + + let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = values + .iter() + .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync)) + .collect(); + + match client.execute(&statement, ¶ms).await { + Ok(_) => imported += 1, + Err(e) => { + client.execute("ROLLBACK", &[]).await.ok(); + return Err(AppError::QueryFailed(format!( + "Import failed at row {}: {}", + imported + 1, + e + ))); + } + } + } + + client + .execute("COMMIT", &[]) + .await + .map_err(|e| AppError::QueryFailed(format!("Failed to commit: {}", e)))?; + + Ok(imported) +} diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/mod.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/mod.rs new file mode 100644 index 0000000..f154bfe --- /dev/null +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/mod.rs @@ -0,0 +1,7 @@ +mod csv_import; +mod roles_grants; +mod schema_objects; + +pub use csv_import::*; +pub use roles_grants::*; +pub use schema_objects::*; diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs new file mode 100644 index 0000000..b58265d --- /dev/null +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs @@ -0,0 +1,127 @@ +use crate::common::enums::AppError; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PgRole { + pub name: String, + pub superuser: bool, + pub create_db: bool, + pub create_role: bool, + pub login: bool, + pub replication: bool, + pub bypass_rls: bool, + pub conn_limit: i32, + pub valid_until: String, + pub member_of: Vec, +} + +pub async fn load_roles(client: &deadpool_postgres::Client) -> Result, AppError> { + let rows = client + .query( + "SELECT r.rolname, r.rolsuper, r.rolcreatedb, r.rolcreaterole, + r.rolcanlogin, r.rolreplication, r.rolbypassrls, r.rolconnlimit, + COALESCE(r.rolvaliduntil::text, ''), + COALESCE(array_agg(m.rolname) FILTER (WHERE m.rolname IS NOT NULL), '{}')::text[] + FROM pg_roles r + LEFT JOIN pg_auth_members am ON am.member = r.oid + LEFT JOIN pg_roles m ON m.oid = am.roleid + GROUP BY r.oid, r.rolname, r.rolsuper, r.rolcreatedb, r.rolcreaterole, + r.rolcanlogin, r.rolreplication, r.rolbypassrls, r.rolconnlimit, r.rolvaliduntil + ORDER BY r.rolname", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let mut roles = Vec::new(); + for row in rows { + roles.push(PgRole { + name: row.get::<_, String>(0), + superuser: row.get::<_, bool>(1), + create_db: row.get::<_, bool>(2), + create_role: row.get::<_, bool>(3), + login: row.get::<_, bool>(4), + replication: row.get::<_, bool>(5), + bypass_rls: row.get::<_, bool>(6), + conn_limit: row.get::<_, i32>(7), + valid_until: row.get::<_, String>(8), + member_of: row.get::<_, Vec>(9), + }); + } + + Ok(roles) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct TableGrant { + pub schema: String, + pub table: String, + pub grantee: String, + pub privileges: Vec, +} + +pub async fn load_table_grants( + client: &deadpool_postgres::Client, + role_name: &str, +) -> Result, AppError> { + let rows = client + .query( + "SELECT table_schema, table_name, grantee, + array_agg(privilege_type ORDER BY privilege_type)::text[] + FROM information_schema.table_privileges + WHERE grantee = $1 + GROUP BY table_schema, table_name, grantee + ORDER BY table_schema, table_name", + &[&role_name], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let mut grants = Vec::new(); + for row in rows { + grants.push(TableGrant { + schema: row.get(0), + table: row.get(1), + grantee: row.get(2), + privileges: row.get::<_, Vec>(3), + }); + } + + Ok(grants) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct DbGrant { + pub database: String, + pub privilege: String, +} + +pub async fn load_database_grants( + client: &deadpool_postgres::Client, + role_name: &str, +) -> Result, AppError> { + let rows = client + .query( + "SELECT datname, privilege_type + FROM pg_database + CROSS JOIN LATERAL ( + SELECT privilege_type + FROM (VALUES ('CONNECT'), ('CREATE'), ('TEMPORARY')) AS privs(privilege_type) + WHERE has_database_privilege($1, datname, privilege_type) + ) t + WHERE NOT datistemplate + ORDER BY datname, privilege_type", + &[&role_name], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let mut grants = Vec::new(); + for row in rows { + grants.push(DbGrant { + database: row.get(0), + privilege: row.get(1), + }); + } + + Ok(grants) +} diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs new file mode 100644 index 0000000..0a3da18 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs @@ -0,0 +1,143 @@ +use crate::common::enums::AppError; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SchemaObject { + pub object_type: String, + pub name: String, + pub definition: String, +} + +pub async fn extract_schema_objects( + client: &deadpool_postgres::Client, + schema: &str, +) -> Result, AppError> { + let mut objects = Vec::new(); + + let rows = client + .query( + "SELECT c.relname, + string_agg( + a.attname || ' ' || pg_catalog.format_type(a.atttypid, a.atttypmod) || + CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END || + COALESCE(' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid), ''), + ', ' ORDER BY a.attnum + ) + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped + LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum + WHERE n.nspname = $1 AND c.relkind = 'r' + GROUP BY c.relname ORDER BY c.relname", + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + for row in &rows { + objects.push(SchemaObject { + object_type: "table".to_string(), + name: row.get(0), + definition: row.get::<_, Option>(1).unwrap_or_default(), + }); + } + + let rows = client + .query( + "SELECT viewname, definition FROM pg_views WHERE schemaname = $1 ORDER BY viewname", + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + for row in &rows { + objects.push(SchemaObject { + object_type: "view".to_string(), + name: row.get(0), + definition: row.get::<_, Option>(1).unwrap_or_default(), + }); + } + + let rows = client + .query( + "SELECT matviewname, definition FROM pg_matviews WHERE schemaname = $1 ORDER BY matviewname", + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + for row in &rows { + objects.push(SchemaObject { + object_type: "matview".to_string(), + name: row.get(0), + definition: row.get::<_, Option>(1).unwrap_or_default(), + }); + } + + let rows = client + .query( + "SELECT p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', + COALESCE(pg_get_functiondef(p.oid), '') + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 ORDER BY p.proname", + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + for row in &rows { + objects.push(SchemaObject { + object_type: "function".to_string(), + name: row.get(0), + definition: row.get::<_, Option>(1).unwrap_or_default(), + }); + } + + let rows = client + .query( + "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY indexname", + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + for row in &rows { + objects.push(SchemaObject { + object_type: "index".to_string(), + name: row.get(0), + definition: row.get::<_, Option>(1).unwrap_or_default(), + }); + } + + Ok(objects) +} + +pub async fn discover_notify_channels( + client: &deadpool_postgres::Client, +) -> Result, AppError> { + // Extract channel names from: + // 1. pg_notify() calls in trigger function bodies + // 2. NOTIFY statements in trigger function bodies + // 3. Currently active listeners from pg_stat_activity + let rows = client + .query( + r#"SELECT DISTINCT channel FROM ( + SELECT (regexp_matches(prosrc, 'pg_notify\s*\(\s*''([^'']+)''', 'gi'))[1] AS channel + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + UNION + SELECT (regexp_matches(prosrc, '\mNOTIFY\s+([a-zA-Z_][a-zA-Z0-9_]*)', 'gi'))[1] AS channel + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + ) sub + WHERE channel IS NOT NULL + ORDER BY channel"#, + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) +} diff --git a/src-tauri/src/drivers/pgsql/statistics_activity/database.rs b/src-tauri/src/drivers/pgsql/statistics_activity/database.rs new file mode 100644 index 0000000..577b5f8 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/statistics_activity/database.rs @@ -0,0 +1,185 @@ +use tokio_postgres::Client; + +use crate::common::enums::AppError; + +use super::super::DbStat; + +pub async fn load_activity(client: &Client) -> Result>, AppError> { + let rows = client + .query( + r#"SELECT + pid::text, + COALESCE(usename, '') AS usename, + COALESCE(datname, '') AS datname, + COALESCE(state, 'unknown') AS state, + COALESCE(wait_event_type, '') AS wait_event_type, + COALESCE(wait_event, '') AS wait_event, + COALESCE(LEFT(query, 500), '') AS query, + COALESCE(EXTRACT(EPOCH FROM (now() - query_start))::text, '0') AS duration_sec, + COALESCE(backend_type, '') AS backend_type, + COALESCE(client_addr::text, 'local') AS client_addr + FROM pg_stat_activity + WHERE datname = current_database() + ORDER BY state, query_start NULLS LAST"#, + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_database_stats(client: &Client) -> Result, AppError> { + let rows = client + .query( + r#"SELECT + 'Active Connections' AS stat, numbackends::text AS val FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Transactions Committed', xact_commit::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Transactions Rolled Back', xact_rollback::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Blocks Read (disk)', blks_read::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Blocks Hit (cache)', blks_hit::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Cache Hit Ratio', + CASE WHEN (blks_hit + blks_read) > 0 + THEN ROUND(blks_hit::numeric / (blks_hit + blks_read) * 100, 2)::text || '%' + ELSE 'N/A' + END + FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Rows Returned', tup_returned::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Rows Fetched', tup_fetched::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Rows Inserted', tup_inserted::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Rows Updated', tup_updated::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Rows Deleted', tup_deleted::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Temp Files', temp_files::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Temp Bytes', pg_size_pretty(temp_bytes) FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Deadlocks', deadlocks::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Database Size', pg_size_pretty(pg_database_size(current_database()))"#, + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let val: String = r.get(1); + (name, val) + }) + .collect()) +} + +pub async fn load_table_stats(client: &Client) -> Result>, AppError> { + let rows = client + .query( + r#"SELECT + schemaname, + relname, + COALESCE(seq_scan, 0)::text AS seq_scan, + COALESCE(seq_tup_read, 0)::text AS seq_tup_read, + COALESCE(idx_scan, 0)::text AS idx_scan, + COALESCE(idx_tup_fetch, 0)::text AS idx_tup_fetch, + COALESCE(n_tup_ins, 0)::text AS inserts, + COALESCE(n_tup_upd, 0)::text AS updates, + COALESCE(n_tup_del, 0)::text AS deletes, + COALESCE(n_live_tup, 0)::text AS live_tuples, + COALESCE(n_dead_tup, 0)::text AS dead_tuples, + COALESCE(last_vacuum::text, 'never') AS last_vacuum, + COALESCE(last_autovacuum::text, 'never') AS last_autovacuum, + COALESCE(last_analyze::text, 'never') AS last_analyze + FROM pg_stat_user_tables + ORDER BY seq_scan + COALESCE(idx_scan, 0) DESC + LIMIT 100"#, + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..14).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_active_locks( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + l.pid::text, + COALESCE(a.usename, '') AS user, + COALESCE(l.mode, '') AS mode, + COALESCE(l.locktype, '') AS locktype, + CASE WHEN l.granted THEN 'granted' ELSE 'waiting' END AS status, + COALESCE(c.relname, '') AS relation, + COALESCE(n.nspname, '') AS schema, + COALESCE(left(a.query, 200), '') AS query, + COALESCE(extract(epoch from now() - a.query_start)::text, '0') AS duration, + COALESCE(a.wait_event_type || ':' || a.wait_event, '') AS wait_event + FROM pg_locks l + JOIN pg_stat_activity a ON a.pid = l.pid + LEFT JOIN pg_class c ON c.oid = l.relation + LEFT JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE a.pid != pg_backend_pid() + ORDER BY NOT l.granted, l.pid", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_index_usage( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + s.schemaname, + s.relname AS table, + s.indexrelname AS index, + pg_size_pretty(pg_relation_size(i.indexrelid)) AS size, + COALESCE(s.idx_scan, 0)::text AS scans, + COALESCE(s.idx_tup_read, 0)::text AS tuples_read, + COALESCE(s.idx_tup_fetch, 0)::text AS tuples_fetched, + CASE + WHEN s.idx_scan = 0 THEN 'unused' + WHEN s.idx_scan < 10 THEN 'rarely_used' + ELSE 'active' + END AS status, + COALESCE(pg_get_indexdef(i.indexrelid), '') AS definition + FROM pg_stat_user_indexes s + JOIN pg_index i ON i.indexrelid = s.indexrelid + WHERE NOT i.indisprimary + ORDER BY s.idx_scan ASC, pg_relation_size(i.indexrelid) DESC", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..9).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} diff --git a/src-tauri/src/drivers/pgsql/statistics_activity/mod.rs b/src-tauri/src/drivers/pgsql/statistics_activity/mod.rs new file mode 100644 index 0000000..0e2208f --- /dev/null +++ b/src-tauri/src/drivers/pgsql/statistics_activity/mod.rs @@ -0,0 +1,5 @@ +mod database; +mod objects; + +pub use database::*; +pub use objects::*; diff --git a/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs b/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs new file mode 100644 index 0000000..8f2606d --- /dev/null +++ b/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs @@ -0,0 +1,197 @@ +use tokio_postgres::Client; + +use crate::common::enums::AppError; + +use super::super::{FKDetail, ForeignKeyInfo, ObjectStats}; + +pub async fn load_table_statistics( + client: &Client, + schema: &str, + table: &str, +) -> Result { + let rows = client + .query( + r#"SELECT + c.reltuples::bigint::text, + pg_size_pretty(pg_table_size(c.oid)), + pg_size_pretty(pg_indexes_size(c.oid)), + pg_size_pretty(pg_total_relation_size(c.oid)), + COALESCE(s.last_vacuum::text, 'never'), + COALESCE(s.last_analyze::text, 'never'), + COALESCE(s.last_autovacuum::text, 'never'), + COALESCE(s.last_autoanalyze::text, 'never'), + COALESCE(s.n_dead_tup, 0)::text, + COALESCE(s.n_live_tup, 0)::text, + COALESCE(s.seq_scan, 0)::text, + COALESCE(s.idx_scan, 0)::text + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid + WHERE n.nspname = $1 AND c.relname = $2 + LIMIT 1"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let keys = [ + "row_estimate", + "table_size", + "index_size", + "total_size", + "last_vacuum", + "last_analyze", + "last_autovacuum", + "last_autoanalyze", + "dead_tuples", + "live_tuples", + "seq_scan", + "idx_scan", + ]; + + if let Some(row) = rows.first() { + Ok(keys + .iter() + .enumerate() + .map(|(i, k)| { + let val: Option = row.try_get(i).ok(); + (k.to_string(), val.unwrap_or_else(|| "-".into())) + }) + .collect()) + } else { + Ok(Vec::new()) + } +} + +pub async fn load_fk_details( + client: &Client, + schema: &str, + table: &str, + direction: &str, // "outgoing" or "incoming" +) -> Result, AppError> { + let where_clause = if direction == "incoming" { + "nsp_tgt.nspname = $1 AND tgt.relname = $2" + } else { + "nsp.nspname = $1 AND src.relname = $2" + }; + + let sql = format!( + r#"SELECT + con.conname, + nsp.nspname, + src.relname, + a_src.attname, + nsp_tgt.nspname, + tgt.relname, + a_tgt.attname, + CASE con.confupdtype + WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' + WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' ELSE '' END, + CASE con.confdeltype + WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' + WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' ELSE '' END + FROM pg_constraint con + JOIN pg_class src ON src.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = src.relnamespace + JOIN pg_class tgt ON tgt.oid = con.confrelid + JOIN pg_namespace nsp_tgt ON nsp_tgt.oid = tgt.relnamespace + JOIN pg_attribute a_src ON a_src.attrelid = con.conrelid AND a_src.attnum = ANY(con.conkey) + JOIN pg_attribute a_tgt ON a_tgt.attrelid = con.confrelid AND a_tgt.attnum = ANY(con.confkey) + WHERE con.contype = 'f' AND {where_clause} + ORDER BY con.conname"# + ); + + let rows = client + .query(&sql, &[&schema, &table]) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + ( + r.get(0), + r.get(1), + r.get(2), + r.get(3), + r.get(4), + r.get(5), + r.get(6), + r.get(7), + r.get(8), + ) + }) + .collect()) +} + +pub async fn load_foreign_keys( + client: &Client, + schema: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT + kcu.table_name AS source_table, + kcu.column_name AS source_column, + ccu.table_name AS target_table, + ccu.column_name AS target_column + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = $1 + ORDER BY kcu.table_name, kcu.column_name"#, + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let src_table: String = r.get(0); + let src_col: String = r.get(1); + let tgt_table: String = r.get(2); + let tgt_col: String = r.get(3); + (src_table, src_col, tgt_table, tgt_col) + }) + .collect()) +} + +pub async fn load_table_bloat( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + schemaname, + relname AS table, + n_live_tup::text AS live_tuples, + n_dead_tup::text AS dead_tuples, + CASE WHEN n_live_tup > 0 + THEN round(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 1)::text + ELSE '0' + END AS bloat_pct, + pg_size_pretty(pg_total_relation_size(relid)) AS total_size, + COALESCE(last_vacuum::text, 'never') AS last_vacuum, + COALESCE(last_autovacuum::text, 'never') AS last_autovacuum, + COALESCE(last_analyze::text, 'never') AS last_analyze, + COALESCE(last_autoanalyze::text, 'never') AS last_autoanalyze + FROM pg_stat_user_tables + ORDER BY n_dead_tup DESC", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a64155a..0e6e1a3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,7 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod app_setup; mod common; mod dbs; mod drivers; @@ -12,8 +13,6 @@ const LOCAL_DB_NAME: &str = "rsql.db"; use deadpool_postgres::Pool; use std::{collections::BTreeMap, sync::Arc}; -use tauri::Manager; -use tauri::menu::{AboutMetadata, MenuBuilder, SubmenuBuilder}; use tokio::sync::Mutex; use tokio_postgres::CancelToken; use tracing::Level; @@ -25,7 +24,7 @@ pub struct AppState { pub client_ssl: Arc>>, pub local_db: libsql::Database, pub resource_monitor: Arc>, - pub virtual_cache: Arc>, + pub virtual_cache: Arc>, pub notify_handles: Arc>>>, pub ssh_tunnels: Arc>>, } @@ -43,212 +42,7 @@ fn main() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) - .setup(|app| { - #[cfg(desktop)] - if let Some(pubkey) = option_env!("TAURI_UPDATER_PUBLIC_KEY") { - app.handle() - .plugin(tauri_plugin_updater::Builder::new().pubkey(pubkey).build())?; - } else { - tracing::info!( - "Updater disabled because TAURI_UPDATER_PUBLIC_KEY was not set at build time" - ); - } - - let app_handle = app.handle().clone(); - - tauri::async_runtime::block_on(async move { - let db_path = if cfg!(debug_assertions) { - LOCAL_DB_NAME.to_string() - } else { - let app_dir = app_handle - .path() - .app_data_dir() - .expect("Failed to resolve app data directory"); - std::fs::create_dir_all(&app_dir).ok(); - app_dir.join(LOCAL_DB_NAME).to_string_lossy().to_string() - }; - - let db = libsql::Builder::new_local(&db_path) - .build() - .await - .expect("Failed to open local database"); - - // Create tables - let conn = db.connect().expect("Failed to create connection"); - conn.execute( - "CREATE TABLE IF NOT EXISTS projects ( - id TEXT PRIMARY KEY, - driver TEXT NOT NULL DEFAULT 'PGSQL', - username TEXT NOT NULL DEFAULT '', - password TEXT NOT NULL DEFAULT '', - database TEXT NOT NULL DEFAULT '', - host TEXT NOT NULL DEFAULT '', - port TEXT NOT NULL DEFAULT '', - ssl TEXT NOT NULL DEFAULT 'false' - )", - (), - ) - .await - .expect("Failed to create projects table"); - - conn.execute( - "CREATE TABLE IF NOT EXISTS queries ( - id TEXT PRIMARY KEY, - sql TEXT NOT NULL DEFAULT '' - )", - (), - ) - .await - .expect("Failed to create queries table"); - - conn.execute( - "CREATE TABLE IF NOT EXISTS workspaces ( - name TEXT PRIMARY KEY, - tabs TEXT NOT NULL DEFAULT '[]' - )", - (), - ) - .await - .expect("Failed to create workspaces table"); - - conn.execute( - "CREATE TABLE IF NOT EXISTS virtual_query_snapshots ( - query_id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - sql TEXT NOT NULL, - columns_packed TEXT NOT NULL DEFAULT '', - total_rows INTEGER NOT NULL DEFAULT 0, - page_size INTEGER NOT NULL DEFAULT 0, - col_count INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL - )", - (), - ) - .await - .expect("Failed to create virtual_query_snapshots table"); - - conn.execute( - "CREATE TABLE IF NOT EXISTS virtual_query_pages ( - query_id TEXT NOT NULL, - page_index INTEGER NOT NULL, - packed_page TEXT NOT NULL DEFAULT '', - PRIMARY KEY (query_id, page_index) - )", - (), - ) - .await - .expect("Failed to create virtual_query_pages table"); - - // Best-effort orphan cleanup in case app exited before tab-close cleanup. - conn.execute( - "DELETE FROM virtual_query_pages - WHERE query_id NOT IN (SELECT query_id FROM virtual_query_snapshots)", - (), - ) - .await - .ok(); - - // SSH tunnel columns migration - for col in [ - "ssh_enabled", - "ssh_host", - "ssh_port", - "ssh_user", - "ssh_password", - "ssh_key_path", - ] { - conn.execute( - &format!( - "ALTER TABLE projects ADD COLUMN {} TEXT NOT NULL DEFAULT ''", - col - ), - (), - ) - .await - .ok(); // Ignore "column already exists" errors - } - - let state = AppState { - clients: Arc::new(Mutex::new(BTreeMap::new())), - meta_clients: Arc::new(Mutex::new(BTreeMap::new())), - cancel_tokens: Arc::new(Mutex::new(BTreeMap::new())), - client_ssl: Arc::new(Mutex::new(BTreeMap::new())), - local_db: db, - resource_monitor: Arc::new(Mutex::new(utils::ResourceMonitor::new())), - virtual_cache: Arc::new(Mutex::new(BTreeMap::new())), - notify_handles: Arc::new(Mutex::new(BTreeMap::new())), - ssh_tunnels: Arc::new(Mutex::new(BTreeMap::new())), - }; - app_handle.manage(state); - - let terminal_state = terminal::TerminalState { - sessions: Arc::new(Mutex::new(std::collections::HashMap::new())), - }; - app_handle.manage(terminal_state); - }); - - // Native menu - let handle = app.handle(); - - let app_menu = SubmenuBuilder::new(handle, "RSQL") - .about(Some(AboutMetadata { - name: Some("RSQL".into()), - version: Some(env!("CARGO_PKG_VERSION").into()), - copyright: Some("\u{00a9} 2025 rust-dd".into()), - comments: Some( - "Modern SQL client for PostgreSQL.\nBuilt with Tauri, React, and Rust." - .into(), - ), - website: Some("https://github.com/rust-dd/rust-sql".into()), - website_label: Some("GitHub".into()), - ..Default::default() - })) - .separator() - .services() - .separator() - .hide() - .hide_others() - .show_all() - .separator() - .quit() - .build()?; - - let edit_menu = SubmenuBuilder::new(handle, "Edit") - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .select_all() - .build()?; - - let view_menu = SubmenuBuilder::new(handle, "View").fullscreen().build()?; - - let window_menu = SubmenuBuilder::new(handle, "Window") - .minimize() - .maximize() - .separator() - .close_window() - .build()?; - - let menu = MenuBuilder::new(handle) - .items(&[&app_menu, &edit_menu, &view_menu, &window_menu]) - .build()?; - - handle.set_menu(menu)?; - - #[cfg(debug_assertions)] - { - let window = app - .get_webview_window("main") - .expect("main window not found"); - window.open_devtools(); - window.close_devtools(); - } - - Ok(()) - }) + .setup(app_setup::setup_app) .invoke_handler(tauri::generate_handler![ dbs::project::project_db_select, dbs::project::project_db_insert, diff --git a/src-tauri/src/ssh.rs b/src-tauri/src/ssh.rs index b45d4e8..0e55135 100644 --- a/src-tauri/src/ssh.rs +++ b/src-tauri/src/ssh.rs @@ -47,7 +47,6 @@ async fn connect_ssh( .await .map_err(|e| format!("SSH connection to {}:{} failed: {}", ssh_host, ssh_port, e))?; - // Try key file first if let Some(key_path) = ssh_key_path { if !key_path.is_empty() { match keys::load_secret_key(key_path, ssh_password) { @@ -68,7 +67,6 @@ async fn connect_ssh( } } - // Then password if let Some(password) = ssh_password { if !password.is_empty() { let result = handle diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index b6ee19f..e5e1d4f 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -1,4 +1,4 @@ -use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; use std::collections::HashMap; use std::io::{Read, Write}; use std::sync::Arc; @@ -54,7 +54,6 @@ pub async fn terminal_spawn( .await .insert(id.clone(), session); - // Spawn reader thread to emit events let terminal_id = id.clone(); std::thread::spawn(move || { let mut buf = [0u8; 4096]; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8246286..e5350aa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -40,9 +40,7 @@ }, "plugins": { "updater": { - "endpoints": [ - "https://github.com/rust-dd/rust-sql/releases/latest/download/latest.json" - ], + "endpoints": ["https://github.com/rust-dd/rust-sql/releases/latest/download/latest.json"], "windows": { "installMode": "passive" } diff --git a/src/App.tsx b/src/App.tsx index 757c9db..e371e15 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,64 +1,34 @@ -import { useState, useEffect, useCallback } from "react"; +import { useCallback, useState } from "react"; import { Toaster } from "sonner"; +import { CommandPalette } from "@/components/command-palette"; import { ConnectionModal } from "@/components/connection-modal"; -import { ResizeHandle } from "@/components/resize-handle"; -import { ServerSidebar } from "@/components/server-sidebar"; -import { QueryEditor } from "@/components/query-editor"; -import { ResultsPanel } from "@/components/results-panel"; -import { PerformanceMonitor } from "@/components/performance-monitor"; +import { EditorToolbar } from "@/components/editor-toolbar"; +import { EnumsPanel } from "@/components/enums-panel"; import { ERDDiagram } from "@/components/erd-diagram"; -import { TerminalPanel } from "@/components/terminal-panel"; +import { ExtensionsPanel } from "@/components/extensions-panel"; import { NotifyPanel } from "@/components/notify-panel"; +import { PerformanceMonitor } from "@/components/performance-monitor"; +import { PgSettingsPanel } from "@/components/pg-settings-panel"; +import { QueryEditor } from "@/components/query-editor"; +import { ResizeHandle } from "@/components/resize-handle"; +import { ResultsGrid } from "@/components/results-grid"; +import { ResultsPanel } from "@/components/results-panel"; import { RolesPanel } from "@/components/roles-panel"; import { SchemaDiffPanel } from "@/components/schema-diff-panel"; -import { ExtensionsPanel } from "@/components/extensions-panel"; -import { EnumsPanel } from "@/components/enums-panel"; -import { PgSettingsPanel } from "@/components/pg-settings-panel"; +import { ServerSidebar } from "@/components/server-sidebar"; +import { StatusBar } from "@/components/status-bar"; import { TabBar } from "@/components/tab-bar"; +import { TerminalPanel } from "@/components/terminal-panel"; import { TopBar } from "@/components/top-bar"; -import { EditorToolbar } from "@/components/editor-toolbar"; -import { StatusBar } from "@/components/status-bar"; -import { CommandPalette } from "@/components/command-palette"; -import { DriverFactory } from "@/lib/database-driver"; -import { checkForUpdates, startBackgroundUpdateCheck } from "@/lib/updater"; -import * as virtualCache from "@/lib/virtual-cache"; +import { useAppStartup } from "@/hooks/use-app-startup"; +import { useQueryLifecycle } from "@/hooks/use-query-lifecycle"; +import { checkForUpdates } from "@/lib/updater"; import { useProjectStore } from "@/stores/project-store"; -import { useTabStore, useActiveTab } from "@/stores/tab-store"; +import { useActiveTab, useTabStore } from "@/stores/tab-store"; import { useUIStore } from "@/stores/ui-store"; -import { useHistoryStore } from "@/stores/history-store"; -import { ResultsGrid } from "@/components/results-grid"; import type { ProjectDetails } from "@/types"; import "@/monaco/setup"; -const NOTIFY_THRESHOLD_MS = 5000; -const DEFAULT_PAGE_SIZE = 2_000; -const PAGE_SIZE_RAW = Number(import.meta.env.VITE_PAGE_SIZE ?? DEFAULT_PAGE_SIZE); -const PAGE_SIZE = Number.isFinite(PAGE_SIZE_RAW) && PAGE_SIZE_RAW >= 100 - ? Math.floor(PAGE_SIZE_RAW) - : DEFAULT_PAGE_SIZE; -const CELL_SEP = "\x1F"; -const ROW_SEP = "\x1E"; - -function isQueryCancelledError(message: string): boolean { - const lower = message.toLowerCase(); - return lower.includes("canceling statement due to user request") - || lower.includes("cancelling statement due to user request") - || lower.includes("query canceled") - || lower.includes("query cancelled") - || lower.includes("statement timeout"); -} - -function notifyQueryComplete(sql: string, time: number, success: boolean, rowCount?: number) { - if (document.hasFocus() || time < NOTIFY_THRESHOLD_MS) return; - if (!("Notification" in window)) return; - if (Notification.permission !== "granted") return; - const preview = sql.slice(0, 60).replace(/\n/g, " "); - const body = success - ? `${rowCount?.toLocaleString() ?? 0} rows in ${(time / 1000).toFixed(1)}s` - : `Query failed after ${(time / 1000).toFixed(1)}s`; - new Notification(success ? "Query Complete" : "Query Failed", { body: `${preview}\n${body}` }); -} - export default function App() { const sidebarWidth = useUIStore((s) => s.sidebarWidth); const editorHeight = useUIStore((s) => s.editorHeight); @@ -67,7 +37,6 @@ export default function App() { const setSidebarWidth = useUIStore((s) => s.setSidebarWidth); const setEditorHeight = useUIStore((s) => s.setEditorHeight); - const loadProjects = useProjectStore((s) => s.loadProjects); const projects = useProjectStore((s) => s.projects); const saveConnection = useProjectStore((s) => s.saveConnection); const updateConnection = useProjectStore((s) => s.updateConnection); @@ -75,293 +44,35 @@ export default function App() { const selectedTabIndex = useTabStore((s) => s.selectedTabIndex); const activeTab = useActiveTab(); const updateContent = useTabStore((s) => s.updateContent); - const updateResult = useTabStore((s) => s.updateResult); - const setExecuting = useTabStore((s) => s.setExecuting); - const closeTab = useTabStore((s) => s.closeTab); - const setExplainResult = useTabStore((s) => s.setExplainResult); - const setVirtualQuery = useTabStore((s) => s.setVirtualQuery); - const setSplitResult = useTabStore((s) => s.setSplitResult); - const setSplitExecuting = useTabStore((s) => s.setSplitExecuting); - const addHistoryEntry = useHistoryStore((s) => s.addEntry); - // Edit connection state - const [editingConnection, setEditingConnection] = useState<{ name: string; details: ProjectDetails } | null>(null); + const [editingConnection, setEditingConnection] = useState<{ + name: string; + details: ProjectDetails; + } | null>(null); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); - useEffect(() => { - void loadProjects(); - }, [loadProjects]); - - useEffect(() => { - startBackgroundUpdateCheck(); - }, []); - - const connectProject = useProjectStore((s) => s.connect); - - const runQuery = useCallback(async () => { - const { tabs, selectedTabIndex: idx } = useTabStore.getState(); - const tab = tabs[idx]; - if (!tab?.projectId || !tab.editorValue.trim()) return; - - const d = useProjectStore.getState().projects[tab.projectId]; - if (!d) return; - - // Auto-connect if not connected - const connStatus = useProjectStore.getState().status[tab.projectId]; - if (connStatus !== "Connected") { - await connectProject(tab.projectId); - const newStatus = useProjectStore.getState().status[tab.projectId]; - if (newStatus !== "Connected") return; - } - - setExecuting(idx, true); - const startTime = Date.now(); - try { - const driver = DriverFactory.getDriver(d.driver); - - // Clean up previous virtual query - const prevVQ = tab.virtualQuery; - if (prevVQ?.queryId) { - await driver.closeVirtual?.(tab.projectId, prevVQ.queryId).catch(() => {}); - virtualCache.clearQuery(prevVQ.queryId); - setVirtualQuery(idx, undefined); - } - - const timeoutMs = tab.queryTimeout || undefined; - - if (driver.executeVirtual) { - const sql = tab.editorValue; - const queryId = crypto.randomUUID().replace(/-/g, "").slice(0, 12); - const [colsPacked, totalRows, pagePacked, elapsed] = - await driver.executeVirtual(tab.projectId, sql, queryId, PAGE_SIZE, timeoutMs); - - if (!colsPacked) { - // Fallback format from backend: header + rows in one packed string. - const parts = pagePacked ? pagePacked.split(ROW_SEP) : []; - const columns = parts[0] ? parts[0].split(CELL_SEP) : []; - const rows = parts.slice(1).map((r) => r.split(CELL_SEP)); - - await driver.closeVirtual?.(tab.projectId, queryId).catch(() => {}); - updateResult(idx, { columns, rows, time: elapsed }); - notifyQueryComplete(tab.editorValue, elapsed, true, rows.length); - - addHistoryEntry({ - projectId: tab.projectId, - database: d.database, - sql: tab.editorValue.trim(), - executionTime: elapsed, - rowCount: rows.length, - success: true, - timestamp: startTime, - }); - } else { - const columns = colsPacked.split(CELL_SEP); - const firstPage = pagePacked - ? pagePacked.split(ROW_SEP).map((r) => r.split(CELL_SEP)) - : []; - - if (totalRows <= PAGE_SIZE) { - await driver.closeVirtual?.(tab.projectId, queryId).catch(() => {}); - updateResult(idx, { columns, rows: firstPage, time: elapsed }); - notifyQueryComplete(tab.editorValue, elapsed, true, firstPage.length); - } else { - virtualCache.setPage(queryId, 0, firstPage); - setVirtualQuery(idx, { queryId, columns, totalRows, pageSize: PAGE_SIZE, colCount: columns.length, time: elapsed }); - updateResult(idx, { columns, rows: firstPage, time: elapsed }); - notifyQueryComplete(tab.editorValue, elapsed, true, totalRows); - } - - addHistoryEntry({ - projectId: tab.projectId, - database: d.database, - sql: tab.editorValue.trim(), - executionTime: elapsed, - rowCount: totalRows > PAGE_SIZE ? totalRows : firstPage.length, - success: true, - timestamp: startTime, - }); - } - } else { - // One-shot fallback - const [cols, rows, time] = await driver.runQuery(tab.projectId, tab.editorValue, timeoutMs); - updateResult(idx, { columns: cols, rows, time }); - notifyQueryComplete(tab.editorValue, time, true, rows.length); - addHistoryEntry({ - projectId: tab.projectId, - database: d.database, - sql: tab.editorValue.trim(), - executionTime: time, - rowCount: rows.length, - success: true, - timestamp: startTime, - }); - } - } catch (err: any) { - const elapsed = Date.now() - startTime; - const errorMsg = err?.message ?? String(err); - const cancelled = isQueryCancelledError(errorMsg); - updateResult(idx, { - columns: [cancelled ? "Info" : "Error"], - rows: [[cancelled ? "Query cancelled" : errorMsg]], - time: 0, - }); - if (!cancelled) { - notifyQueryComplete(tab.editorValue, elapsed, false); - } - addHistoryEntry({ - projectId: tab.projectId, - database: d.database, - sql: tab.editorValue.trim(), - executionTime: elapsed, - rowCount: 0, - success: false, - error: cancelled ? "Query cancelled" : errorMsg, - timestamp: startTime, - }); - } - useUIStore.getState().setSelectedRow(0); - }, [setExecuting, updateResult, setVirtualQuery, addHistoryEntry, connectProject]); - - const runExplain = useCallback(async () => { - const { tabs, selectedTabIndex: idx } = useTabStore.getState(); - const tab = tabs[idx]; - if (!tab?.projectId || !tab.editorValue.trim()) return; - - const d = useProjectStore.getState().projects[tab.projectId]; - if (!d) return; - - // Auto-connect if not connected - const connStatus = useProjectStore.getState().status[tab.projectId]; - if (connStatus !== "Connected") { - await connectProject(tab.projectId); - const newStatus = useProjectStore.getState().status[tab.projectId]; - if (newStatus !== "Connected") return; - } - - setExecuting(idx, true); - try { - const driver = DriverFactory.getDriver(d.driver); - // Strip trailing semicolons from user's query to avoid syntax errors - const userSql = tab.editorValue.replace(/;\s*$/, ""); - const sql = `EXPLAIN (ANALYZE, FORMAT JSON) ${userSql}`; - const [, rows] = await driver.runQuery(tab.projectId, sql); - // PG returns the JSON plan as a single text cell; join all rows - const jsonText = rows.map((r) => r[0]).join("\n"); - let plans: unknown; - try { - plans = JSON.parse(jsonText); - } catch { - // Some drivers return each row separately or wrap in brackets - // Try finding valid JSON within the text - const match = jsonText.match(/\[[\s\S]*\]/); - if (match) { - plans = JSON.parse(match[0]); - } else { - throw new Error(`Could not parse EXPLAIN output:\n${jsonText.slice(0, 500)}`); - } - } - if (Array.isArray(plans) && plans.length > 0) { - setExplainResult(idx, plans[0]); - } - } catch (err: any) { - const errorMsg = err?.message ?? String(err); - const cancelled = isQueryCancelledError(errorMsg); - updateResult(idx, { - columns: [cancelled ? "Info" : "Explain Error"], - rows: [[cancelled ? "Explain cancelled" : errorMsg]], - time: 0, - }); - setExplainResult(idx, undefined); - } - setExecuting(idx, false); - }, [setExecuting, updateResult, setExplainResult, connectProject]); - - const cancelQuery = useCallback(async () => { - const { tabs, selectedTabIndex: idx } = useTabStore.getState(); - const tab = tabs[idx]; - if (!tab?.projectId || !tab.isExecuting) return; - - const d = useProjectStore.getState().projects[tab.projectId]; - if (!d) return; - - try { - const driver = DriverFactory.getDriver(d.driver); - await driver.cancelQuery?.(tab.projectId); - } catch (err) { - console.error("Failed to cancel query:", err); - } - }, []); - - const runSplitQuery = useCallback(async () => { - const { tabs, selectedTabIndex: idx } = useTabStore.getState(); - const tab = tabs[idx]; - if (!tab?.projectId || !tab.splitEditorValue?.trim()) return; - - const d = useProjectStore.getState().projects[tab.projectId]; - if (!d) return; - - const connStatus = useProjectStore.getState().status[tab.projectId]; - if (connStatus !== "Connected") { - await connectProject(tab.projectId); - const newStatus = useProjectStore.getState().status[tab.projectId]; - if (newStatus !== "Connected") return; - } - - setSplitExecuting(idx, true); - try { - const driver = DriverFactory.getDriver(d.driver); - const [cols, rows, time] = await driver.runQuery(tab.projectId, tab.splitEditorValue); - setSplitResult(idx, { columns: cols, rows, time }); - } catch (err: any) { - const errorMsg = err?.message ?? String(err); - const cancelled = isQueryCancelledError(errorMsg); - setSplitResult(idx, { - columns: [cancelled ? "Info" : "Error"], - rows: [[cancelled ? "Query cancelled" : errorMsg]], - time: 0, - }); - } - }, [setSplitExecuting, setSplitResult, connectProject]); - - // Keyboard shortcuts - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "w") { - e.preventDefault(); - const { tabs: t, selectedTabIndex: idx } = useTabStore.getState(); - if (t.length > 0) { - const closingTab = t[idx]; - if (closingTab?.virtualQuery?.queryId && closingTab.projectId) { - const dd = useProjectStore.getState().projects[closingTab.projectId]; - if (dd) DriverFactory.getDriver(dd.driver).closeVirtual?.(closingTab.projectId, closingTab.virtualQuery.queryId).catch(() => {}); - virtualCache.clearQuery(closingTab.virtualQuery.queryId); - } - closeTab(idx); - } - } - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "Enter") { - e.preventDefault(); - void runExplain(); - } - if ((e.metaKey || e.ctrlKey) && (e.key === "p" || e.key === "k")) { - e.preventDefault(); - setCommandPaletteOpen((v) => !v); - } - if ((e.metaKey || e.ctrlKey) && e.key === "`") { - e.preventDefault(); - useTabStore.getState().openTerminalTab(); - } - if ((e.metaKey || e.ctrlKey) && e.key === ".") { - e.preventDefault(); - void cancelQuery(); - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [cancelQuery, closeTab, runExplain]); + useAppStartup(); + const { runQuery, runExplain, cancelQuery, runSplitQuery } = useQueryLifecycle({ + setCommandPaletteOpen, + }); const handleSaveConnection = useCallback( - async (connection: { name: string; driver: string; username: string; password: string; database: string; host: string; port: string; ssl: boolean; sshEnabled?: boolean; sshHost?: string; sshPort?: string; sshUser?: string; sshPassword?: string; sshKeyPath?: string }) => { + async (connection: { + name: string; + driver: string; + username: string; + password: string; + database: string; + host: string; + port: string; + ssl: boolean; + sshEnabled?: boolean; + sshHost?: string; + sshPort?: string; + sshUser?: string; + sshPassword?: string; + sshKeyPath?: string; + }) => { const details = { driver: connection.driver as "PGSQL", username: connection.username, @@ -407,14 +118,20 @@ export default function App() { ); return ( -
e.preventDefault()}> +
e.preventDefault()} + > void checkForUpdates()} onOpenCommandPalette={() => setCommandPaletteOpen(true)} />
-
+
@@ -424,9 +141,12 @@ export default function App() { {!activeTab ? (
-
RSQL
+
+ RSQL +

No tabs open

-
- ); - } - - if (!ddl) { - return ( -
- - No DDL available -
- ); - } - - return ( -
- {/* Code editor style container */} -
- {/* Title bar */} -
-
- - - DDL - -
-
- - -
-
-
-          {ddl}
-        
-
-
- ); -} - -function ActionsContent({ - objectType, - schema, - name, - actionResult, - actionLoading, - confirmAction, - setConfirmAction, - confirmInput, - setConfirmInput, - runAction, - openTab, - projectId, - onOpenChange, -}: { - objectType: ObjectType; - schema: string; - name: string; - actionResult: { type: "success" | "error"; message: string } | null; - actionLoading: boolean; - confirmAction: string | null; - setConfirmAction: (action: string | null) => void; - confirmInput: string; - setConfirmInput: (value: string) => void; - runAction: (actionLabel: string) => Promise; - openTab: (projectId?: string, sql?: string) => void; - projectId: string; - onOpenChange: (open: boolean) => void; -}) { - const qualified = `"${schema}"."${name}"`; - - const actions: { - label: string; - icon: React.ReactNode; - destructive?: boolean; - confirm?: boolean; - description: string; - }[] = []; - - if (objectType === "table") { - actions.push( - { label: "ANALYZE", icon: , confirm: true, description: "Update table statistics for the query planner." }, - { label: "VACUUM", icon: , confirm: true, description: "Reclaim storage occupied by dead tuples." }, - { label: "VACUUM FULL", icon: , confirm: true, description: "Rewrite table to reclaim max space. Locks table exclusively." }, - { label: "REINDEX", icon: , confirm: true, description: "Rebuild all indexes on this table." }, - { label: "TRUNCATE", icon: , destructive: true, confirm: true, description: "Remove all rows. Cannot be rolled back." }, - { label: "DROP TABLE", icon: , destructive: true, confirm: true, description: "Permanently delete this table and all its data." }, - ); - } else if (objectType === "view") { - actions.push( - { label: "DROP VIEW", icon: , destructive: true, confirm: true, description: "Permanently delete this view." }, - { label: "DROP VIEW CASCADE", icon: , destructive: true, confirm: true, description: "Drop view and all dependent objects." }, - ); - } else if (objectType === "matview") { - actions.push( - { label: "REFRESH", icon: , confirm: true, description: "Refresh data by re-executing the query." }, - { label: "REFRESH CONCURRENTLY", icon: , confirm: true, description: "Refresh without locking reads. Requires a unique index." }, - { label: "DROP MATERIALIZED VIEW", icon: , destructive: true, confirm: true, description: "Permanently delete this materialized view." }, - ); - } else if (objectType === "function" || objectType === "trigger-function") { - actions.push( - { label: "DROP FUNCTION", icon: , destructive: true, confirm: true, description: "Permanently delete this function." }, - { label: "DROP FUNCTION CASCADE", icon: , destructive: true, confirm: true, description: "Drop function and all dependent objects (triggers, etc.)." }, - ); - } - - return ( -
- {/* Quick open in tab */} - {objectType === "table" && ( -
-
- Quick Queries -
-
- - - -
-
- )} - - {/* Action result */} - {actionResult && ( -
- {actionResult.type === "success" ? ( - - ) : ( - - )} - {actionResult.message} -
- )} - - {/* Action buttons */} -
- Maintenance & Operations -
-
- {actions.map((action) => ( -
-
- - {action.icon} - -
-
- {action.label} -
-
- {action.description} -
-
- {confirmAction !== action.label && ( - - )} -
- {confirmAction === action.label && ( -
-
- - Type{" "} - - {name} - {" "} - to confirm - - setConfirmInput(e.target.value)} - placeholder={name} - autoFocus - className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/40 rounded-md outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 placeholder:text-muted-foreground/30" - /> -
- - -
- )} -
- ))} -
-
- ); -} - -type StructureSubTab = "columns" | "pk" | "fkeys" | "unique" | "indexes"; - -function uid() { - return crypto.randomUUID(); -} - -function initStructureState( - cols: import("@/types").ColumnDetail[] | undefined, - idxs: import("@/types").IndexDetail[] | undefined, - cons: import("@/types").ConstraintDetail[] | undefined, - outgoingFKs: FKInfo[], -): StructureEditorState { - const columns: DraftColumn[] = (cols ?? []).map((c) => ({ - _id: uid(), - _status: "existing" as const, - name: c.name, - dataType: c.dataType, - nullable: c.nullable, - defaultValue: c.defaultValue, - originalName: c.name, - originalDataType: c.dataType, - originalNullable: c.nullable, - originalDefault: c.defaultValue, - })); - - // Primary key from indexes - const pkEntries = (idxs ?? []).filter((i) => i.isPrimary); - const pkName = pkEntries[0]?.indexName ?? ""; - const primaryKey: DraftPrimaryKey | null = - pkEntries.length > 0 - ? { - constraintName: pkName, - columns: pkEntries.map((e) => e.columnName), - _status: "existing", - originalColumns: pkEntries.map((e) => e.columnName), - } - : null; - - // Unique constraints from constraints - const uniqueMap = new Map(); - for (const c of cons ?? []) { - if (c.constraintType === "UNIQUE") { - const existing = uniqueMap.get(c.constraintName) ?? []; - existing.push(c.columnName); - uniqueMap.set(c.constraintName, existing); - } - } - const uniqueConstraints: DraftUniqueConstraint[] = [ - ...uniqueMap.entries(), - ].map(([name, ucCols]) => ({ - _id: uid(), - _status: "existing" as const, - constraintName: name, - columns: ucCols, - })); - - // Non-primary, non-unique indexes - const idxMap = new Map(); - for (const i of idxs ?? []) { - if (i.isPrimary) continue; - // Skip indexes that back a unique constraint - if (uniqueMap.has(i.indexName)) continue; - const existing = idxMap.get(i.indexName) ?? { - columns: [], - isUnique: i.isUnique, - }; - existing.columns.push(i.columnName); - idxMap.set(i.indexName, existing); - } - const indexes: DraftIndex[] = [...idxMap.entries()].map(([name, info]) => ({ - _id: uid(), - _status: "existing" as const, - indexName: name, - columns: info.columns, - isUnique: info.isUnique, - })); - - // Foreign keys: group by constraintName - const fkMap = new Map(); - for (const fk of outgoingFKs) { - const existing = fkMap.get(fk.constraintName) ?? []; - existing.push(fk); - fkMap.set(fk.constraintName, existing); - } - const foreignKeys: DraftForeignKey[] = [...fkMap.entries()].map( - ([name, fks]) => ({ - _id: uid(), - _status: "existing" as const, - constraintName: name, - sourceColumns: fks.map((f) => f.sourceColumn), - targetSchema: fks[0].targetSchema, - targetTable: fks[0].targetTable, - targetColumns: fks.map((f) => f.targetColumn), - onUpdate: fks[0].onUpdate, - onDelete: fks[0].onDelete, - }), - ); - - return { columns, primaryKey, foreignKeys, uniqueConstraints, indexes }; -} - -function StructureEditorContent({ - projectId, - schema, - tableName, - cols, - idxs, - cons, - outgoingFKs, - getDriver, - onApplied, - openTab, - onOpenChange, -}: { - projectId: string; - schema: string; - tableName: string; - cols: import("@/types").ColumnDetail[] | undefined; - idxs: import("@/types").IndexDetail[] | undefined; - cons: import("@/types").ConstraintDetail[] | undefined; - outgoingFKs: FKInfo[]; - getDriver: () => ReturnType | null; - onApplied: () => void; - openTab: (projectId?: string, sql?: string) => void; - onOpenChange: (open: boolean) => void; -}) { - const [subTab, setSubTab] = useState("columns"); - const [applying, setApplying] = useState(false); - const [error, setError] = useState(null); - const [showSql, setShowSql] = useState(false); - - // Build initial state - const initialState = useMemo( - () => initStructureState(cols, idxs, cons, outgoingFKs), - [cols, idxs, cons, outgoingFKs], - ); - const [draft, setDraft] = useState(initialState); - - // Reset when initial state changes (e.g. modal re-opened) - useEffect(() => { - setDraft(initialState); - setError(null); - setShowSql(false); - }, [initialState]); - - const changes = countChanges(draft); - const activeColNames = draft.columns - .filter((c) => c._status !== "removed") - .map((c) => c.name); - - // Tables for FK target (from store) - const tables = useProjectStore((s) => s.tables); - const schemas = useProjectStore((s) => s.schemas); - const loadTables = useProjectStore((s) => s.loadTables); - const availableSchemas = schemas[projectId] ?? []; - const getTablesForSchema = (s: string) => - (tables[`${projectId}::${s}`] ?? []).map((t) => t.name); - - // SQL preview - const sqlStatements = useMemo( - () => generateAlterTableSQL(schema, tableName, initialState, draft), - [schema, tableName, initialState, draft], - ); - const sqlPreview = sqlStatements.join("\n"); - - // Apply changes - const applyChanges = useCallback(async () => { - const driver = getDriver(); - if (!driver || sqlStatements.length === 0) return; - setApplying(true); - setError(null); - try { - await driver.runQuery(projectId, "BEGIN"); - try { - for (const stmt of sqlStatements) { - await driver.runQuery(projectId, stmt); - } - await driver.runQuery(projectId, "COMMIT"); - } catch (err) { - await driver.runQuery(projectId, "ROLLBACK").catch(() => {}); - throw err; - } - toast.success("Table structure updated"); - onApplied(); - } catch (err: any) { - setError(err?.message ?? "Failed to apply changes"); - } finally { - setApplying(false); - } - }, [getDriver, projectId, sqlStatements, onApplied]); - - // Column helpers - const updateColumn = (id: string, updates: Partial) => { - setDraft((prev) => ({ - ...prev, - columns: prev.columns.map((c) => { - if (c._id !== id) return c; - const updated = { ...c, ...updates }; - // Mark as modified if it was existing and something changed - if (c._status === "existing") { - const changed = - updated.name !== c.originalName || - updated.dataType !== c.originalDataType || - updated.nullable !== c.originalNullable || - updated.defaultValue !== c.originalDefault; - updated._status = changed ? "modified" : "existing"; - } - return updated; - }), - })); - }; - - const addColumn = () => { - setDraft((prev) => ({ - ...prev, - columns: [ - ...prev.columns, - { - _id: uid(), - _status: "added", - name: `new_column_${prev.columns.length + 1}`, - dataType: "text", - nullable: true, - defaultValue: null, - }, - ], - })); - }; - - const removeColumn = (id: string) => { - setDraft((prev) => ({ - ...prev, - columns: prev.columns - .map((c) => - c._id === id - ? c._status === "added" - ? null - : { ...c, _status: "removed" as const } - : c, - ) - .filter(Boolean) as DraftColumn[], - })); - }; - - const restoreColumn = (id: string) => { - setDraft((prev) => ({ - ...prev, - columns: prev.columns.map((c) => - c._id === id ? { ...c, _status: "existing" as const } : c, - ), - })); - }; - - // Sub-tab list - const subTabs: { key: StructureSubTab; label: string }[] = [ - { key: "columns", label: "Columns" }, - { key: "pk", label: "Primary Key" }, - { key: "fkeys", label: "Foreign Keys" }, - { key: "unique", label: "Unique" }, - { key: "indexes", label: "Indexes" }, - ]; - - if (!cols) return ; - - return ( -
- {/* Sub-tab nav */} -
- {subTabs.map((t) => ( - - ))} -
- - {/* Sub-tab content */} -
- {subTab === "columns" && ( -
- {/* Header */} -
- Name - Type - Nullable - Default - -
- {draft.columns.map((col) => ( -
- - updateColumn(col._id, { name: e.target.value }) - } - className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 disabled:opacity-40" - /> - - updateColumn(col._id, { dataType: e.target.value }) - } - list="pg-types" - className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 disabled:opacity-40" - /> -
- - updateColumn(col._id, { nullable: e.target.checked }) - } - className="h-3.5 w-3.5 rounded border-border accent-primary" - /> -
- - updateColumn(col._id, { - defaultValue: e.target.value || null, - }) - } - placeholder="NULL" - className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 placeholder:text-muted-foreground/30 disabled:opacity-40" - /> -
- {col._status === "removed" ? ( - - ) : ( - - )} -
-
- ))} - - {/* HTML datalist for type suggestions */} - - {PG_COMMON_TYPES.map((t) => ( - -
- )} - - {subTab === "pk" && ( -
-
- Select columns for primary key -
-
- {activeColNames.map((colName) => { - const isInPK = - draft.primaryKey?.columns.includes(colName) && - draft.primaryKey._status !== "removed"; - return ( - - ); - })} -
- {draft.primaryKey && - draft.primaryKey._status !== "removed" && - draft.primaryKey.columns.length > 0 && ( -
- Constraint:{" "} - - {draft.primaryKey.constraintName} - - {" — "}({draft.primaryKey.columns.join(", ")}) -
- )} -
- )} - - {subTab === "fkeys" && ( -
- {draft.foreignKeys - .filter((fk) => fk._status !== "removed") - .map((fk) => ( - { - setDraft((prev) => ({ - ...prev, - foreignKeys: prev.foreignKeys.map((f) => - f._id === fk._id - ? { - ...f, - ...updates, - _status: - f._status === "existing" - ? "existing" - : f._status, - } - : f, - ), - })); - }} - onRemove={() => { - setDraft((prev) => ({ - ...prev, - foreignKeys: prev.foreignKeys - .map((f) => - f._id === fk._id - ? f._status === "added" - ? null - : { ...f, _status: "removed" as const } - : f, - ) - .filter(Boolean) as DraftForeignKey[], - })); - }} - /> - ))} - {draft.foreignKeys - .filter((fk) => fk._status === "removed") - .map((fk) => ( -
- - {fk.constraintName} - - -
- ))} - -
- )} - - {subTab === "unique" && ( -
- {draft.uniqueConstraints - .filter((uc) => uc._status !== "removed") - .map((uc) => ( -
-
- { - setDraft((prev) => ({ - ...prev, - uniqueConstraints: prev.uniqueConstraints.map((u) => - u._id === uc._id - ? { ...u, constraintName: e.target.value } - : u, - ), - })); - }} - className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" - placeholder="Constraint name" - /> - -
-
- Columns -
-
- {activeColNames.map((colName) => { - const selected = uc.columns.includes(colName); - return ( - - ); - })} -
-
- ))} - {draft.uniqueConstraints - .filter((uc) => uc._status === "removed") - .map((uc) => ( -
- - {uc.constraintName} - - -
- ))} - -
- )} - - {subTab === "indexes" && ( -
- {draft.indexes - .filter((idx) => idx._status !== "removed") - .map((idx) => ( -
-
- { - setDraft((prev) => ({ - ...prev, - indexes: prev.indexes.map((i) => - i._id === idx._id - ? { ...i, indexName: e.target.value } - : i, - ), - })); - }} - className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" - placeholder="Index name" - /> - - -
-
- Columns -
-
- {activeColNames.map((colName) => { - const selected = idx.columns.includes(colName); - return ( - - ); - })} -
-
- ))} - {draft.indexes - .filter((idx) => idx._status === "removed") - .map((idx) => ( -
- - {idx.indexName} - - -
- ))} - -
- )} -
- - {/* SQL preview panel */} - {showSql && sqlStatements.length > 0 && ( -
-
- - SQL Preview - -
- - -
-
-
-            {sqlPreview}
-          
-
- )} - - {/* Error */} - {error && ( -
- - {error} -
- )} - - {/* Bottom action bar */} - {changes > 0 && ( -
- - {changes} change{changes !== 1 ? "s" : ""} - -
- - - -
- )} -
- ); -} - -function FKCard({ - fk, - activeColNames, - availableSchemas, - getTablesForSchema, - loadTables, - projectId, - getDriver, - onChange, - onRemove, -}: { - fk: DraftForeignKey; - activeColNames: string[]; - availableSchemas: string[]; - getTablesForSchema: (schema: string) => string[]; - loadTables: (projectId: string, schema: string) => Promise; - projectId: string; - getDriver: () => ReturnType | null; - onChange: (updates: Partial) => void; - onRemove: () => void; -}) { - const [targetCols, setTargetCols] = useState([]); - - // Load target table columns when target changes - useEffect(() => { - if (!fk.targetTable || !fk.targetSchema) { - setTargetCols([]); - return; - } - const driver = getDriver(); - if (!driver) return; - driver - .loadColumns(projectId, fk.targetSchema, fk.targetTable) - .then(setTargetCols) - .catch(() => setTargetCols([])); - }, [fk.targetSchema, fk.targetTable, projectId, getDriver]); - - // Ensure tables are loaded for the selected schema - useEffect(() => { - if (fk.targetSchema) { - loadTables(projectId, fk.targetSchema).catch(() => {}); - } - }, [fk.targetSchema, projectId, loadTables]); - - const targetTableNames = getTablesForSchema(fk.targetSchema); - - return ( -
- {/* Name + delete */} -
- onChange({ constraintName: e.target.value })} - className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" - placeholder="Constraint name" - /> - -
- - {/* Target: schema + table */} -
-
-
- Target Schema -
- -
-
-
- Target Table -
- -
-
- - {/* Column mapping: source → target (paired rows) */} -
-
-
Column Mapping
-
-
-
- Source - - Target - -
- {fk.sourceColumns.map((srcCol, idx) => ( -
- - - - -
- ))} - -
-
- - {/* ON UPDATE / ON DELETE */} -
-
-
- On Update -
- -
-
-
- On Delete -
- -
-
-
- ); -} - -function StatCard({ - label, - value, - icon, - accent, -}: { - label: string; - value: string; - icon: React.ReactNode; - accent?: string; -}) { - return ( -
-
-
-
- {icon} -
-
-
- {label} -
-
- {value} -
-
-
-
- ); -} - -function InfoRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - - {value} - -
- ); -} - -function PropertySection({ - title, - icon, - children, -}: { - title: string; - icon: React.ReactNode; - children: React.ReactNode; -}) { - return ( -
-
-
- {icon} -
- - {title} - -
-
- {children} -
- ); -} - -function ConstraintIcon({ type }: { type: string }) { - if (type === "PRIMARY KEY") - return ; - if (type === "FOREIGN KEY") - return ; - if (type === "UNIQUE") - return ; - if (type === "CHECK") - return ; - return ; -} - -function LoadingPlaceholder() { - return ( -
-
-
- -
- Loading... -
- ); -} - -function formatTimestamp(ts: string): string { - if (ts === "never") return "never"; - try { - const d = new Date(ts); - if (isNaN(d.getTime())) return ts; - const now = Date.now(); - const diff = now - d.getTime(); - if (diff < 60000) return "just now"; - if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; - if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; - if (diff < 2592000000) return `${Math.floor(diff / 86400000)}d ago`; - return d.toLocaleDateString(); - } catch { - return ts; - } -} diff --git a/src/components/object-properties-modal/actions-tab.tsx b/src/components/object-properties-modal/actions-tab.tsx new file mode 100644 index 0000000..7e6a606 --- /dev/null +++ b/src/components/object-properties-modal/actions-tab.tsx @@ -0,0 +1,309 @@ +import { AlertTriangle, Check, Key, Loader2, Play, RefreshCw, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ObjectType } from "./types"; + +export function ActionsContent({ + objectType, + schema, + name, + actionResult, + actionLoading, + confirmAction, + setConfirmAction, + confirmInput, + setConfirmInput, + runAction, + openTab, + projectId, + onOpenChange, +}: { + objectType: ObjectType; + schema: string; + name: string; + actionResult: { type: "success" | "error"; message: string } | null; + actionLoading: boolean; + confirmAction: string | null; + setConfirmAction: (action: string | null) => void; + confirmInput: string; + setConfirmInput: (value: string) => void; + runAction: (actionLabel: string) => Promise; + openTab: (projectId?: string, sql?: string) => void; + projectId: string; + onOpenChange: (open: boolean) => void; +}) { + const qualified = `"${schema}"."${name}"`; + + const actions: { + label: string; + icon: React.ReactNode; + destructive?: boolean; + confirm?: boolean; + description: string; + }[] = []; + + if (objectType === "table") { + actions.push( + { + label: "ANALYZE", + icon: , + confirm: true, + description: "Update table statistics for the query planner.", + }, + { + label: "VACUUM", + icon: , + confirm: true, + description: "Reclaim storage occupied by dead tuples.", + }, + { + label: "VACUUM FULL", + icon: , + confirm: true, + description: "Rewrite table to reclaim max space. Locks table exclusively.", + }, + { + label: "REINDEX", + icon: , + confirm: true, + description: "Rebuild all indexes on this table.", + }, + { + label: "TRUNCATE", + icon: , + destructive: true, + confirm: true, + description: "Remove all rows. Cannot be rolled back.", + }, + { + label: "DROP TABLE", + icon: , + destructive: true, + confirm: true, + description: "Permanently delete this table and all its data.", + }, + ); + } else if (objectType === "view") { + actions.push( + { + label: "DROP VIEW", + icon: , + destructive: true, + confirm: true, + description: "Permanently delete this view.", + }, + { + label: "DROP VIEW CASCADE", + icon: , + destructive: true, + confirm: true, + description: "Drop view and all dependent objects.", + }, + ); + } else if (objectType === "matview") { + actions.push( + { + label: "REFRESH", + icon: , + confirm: true, + description: "Refresh data by re-executing the query.", + }, + { + label: "REFRESH CONCURRENTLY", + icon: , + confirm: true, + description: "Refresh without locking reads. Requires a unique index.", + }, + { + label: "DROP MATERIALIZED VIEW", + icon: , + destructive: true, + confirm: true, + description: "Permanently delete this materialized view.", + }, + ); + } else if (objectType === "function" || objectType === "trigger-function") { + actions.push( + { + label: "DROP FUNCTION", + icon: , + destructive: true, + confirm: true, + description: "Permanently delete this function.", + }, + { + label: "DROP FUNCTION CASCADE", + icon: , + destructive: true, + confirm: true, + description: "Drop function and all dependent objects (triggers, etc.).", + }, + ); + } + + return ( +
+ {/* Quick open in tab */} + {objectType === "table" && ( +
+
+ Quick Queries +
+
+ + + +
+
+ )} + + {/* Action result */} + {actionResult && ( +
+ {actionResult.type === "success" ? ( + + ) : ( + + )} + {actionResult.message} +
+ )} + + {/* Action buttons */} +
+ Maintenance & Operations +
+
+ {actions.map((action) => ( +
+
+ + {action.icon} + +
+
+ {action.label} +
+
{action.description}
+
+ {confirmAction !== action.label && ( + + )} +
+ {confirmAction === action.label && ( +
+
+ + Type {name} to + confirm + + setConfirmInput(e.target.value)} + placeholder={name} + className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/40 rounded-md outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 placeholder:text-muted-foreground/30" + /> +
+ + +
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/object-properties-modal/columns-tab.tsx b/src/components/object-properties-modal/columns-tab.tsx new file mode 100644 index 0000000..b10c8e4 --- /dev/null +++ b/src/components/object-properties-modal/columns-tab.tsx @@ -0,0 +1,79 @@ +import { Columns3, Key } from "lucide-react"; +import { LoadingPlaceholder } from "./shared"; + +export function ColumnsContent({ + cols, + pkCols, +}: { + cols?: import("@/types").ColumnDetail[]; + pkCols: Set; +}) { + if (!cols) { + return ; + } + + return ( +
+
+ + + + + + + + + + + + + {cols.map((c, i) => ( + + + + + + + + + ))} + +
+ # + + Name + + Type + + Nullable + + Default +
{i + 1} + {pkCols.has(c.name) ? ( + + ) : ( + + )} + {c.name} + + {c.dataType} + + + {c.nullable ? ( + YES + ) : ( + NOT NULL + )} + + {c.defaultValue ?? -} +
+
+
+ ); +} diff --git a/src/components/object-properties-modal/ddl-tab.tsx b/src/components/object-properties-modal/ddl-tab.tsx new file mode 100644 index 0000000..64c9c55 --- /dev/null +++ b/src/components/object-properties-modal/ddl-tab.tsx @@ -0,0 +1,96 @@ +import { AlertTriangle, Check, Copy, FileCode, Play, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { LoadingPlaceholder } from "./shared"; + +export function DDLContent({ + ddl, + ddlLoading, + ddlError, + copied, + onCopy, + onRetry, + onOpenInTab, +}: { + ddl: string | null; + ddlLoading: boolean; + ddlError: string | null; + copied: string | null; + onCopy: () => void; + onRetry: () => void; + onOpenInTab: () => void; +}) { + if (ddlLoading) { + return ; + } + + if (ddlError) { + return ( +
+
+ +
+

{ddlError}

+ +
+ ); + } + + if (!ddl) { + return ( +
+ + No DDL available +
+ ); + } + + return ( +
+ {/* Code editor style container */} +
+ {/* Title bar */} +
+
+ + DDL +
+
+ + +
+
+
+          {ddl}
+        
+
+
+ ); +} diff --git a/src/components/object-properties-modal/foreign-keys-tab.tsx b/src/components/object-properties-modal/foreign-keys-tab.tsx new file mode 100644 index 0000000..c0a505f --- /dev/null +++ b/src/components/object-properties-modal/foreign-keys-tab.tsx @@ -0,0 +1,145 @@ +import { ArrowRight } from "lucide-react"; +import { PropertySection } from "./shared"; +import type { FKInfo } from "./types"; + +export function ForeignKeysContent({ + outgoingFKs, + incomingFKs, + openTab, + projectId, + onOpenChange, +}: { + outgoingFKs: FKInfo[]; + incomingFKs: FKInfo[]; + openTab: (projectId?: string, sql?: string) => void; + projectId: string; + onOpenChange: (open: boolean) => void; +}) { + if (outgoingFKs.length === 0 && incomingFKs.length === 0) { + return ( +
+ No foreign key relationships found. +
+ ); + } + + return ( +
+ {outgoingFKs.length > 0 && ( + } + > +
+ + + + + + + + + + + + {outgoingFKs.map((fk, i) => ( + + + + + + + + ))} + +
+ Constraint + + Column + + References + + ON DELETE + + ON UPDATE +
{fk.constraintName}{fk.sourceColumn} + + {fk.onDelete}{fk.onUpdate}
+
+
+ )} + + {incomingFKs.length > 0 && ( + } + > +
+ + + + + + + + + + + {incomingFKs.map((fk, i) => ( + + + + + + + ))} + +
+ Constraint + + From Table + + Column + + ON DELETE +
{fk.constraintName} + + + {fk.sourceColumn} → {fk.targetColumn} + {fk.onDelete}
+
+
+ )} +
+ ); +} diff --git a/src/components/object-properties-modal/index.tsx b/src/components/object-properties-modal/index.tsx new file mode 100644 index 0000000..e57b36d --- /dev/null +++ b/src/components/object-properties-modal/index.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { useProjectStore } from "@/stores/project-store"; +import { useTabStore } from "@/stores/tab-store"; +import { ActionsContent } from "./actions-tab"; +import { ColumnsContent } from "./columns-tab"; +import { DDLContent } from "./ddl-tab"; +import { ForeignKeysContent } from "./foreign-keys-tab"; +import { IndexesContent } from "./indexes-tab"; +import { ModalHeader } from "./modal-header"; +import { OverviewContent } from "./overview-tab"; +import { StructureEditorContent } from "./structure-editor"; +import type { ObjectPropertiesModalProps, Tab } from "./types"; +import { useObjectData } from "./use-object-data"; + +export function ObjectPropertiesModal({ + open, + onOpenChange, + objectType, + projectId, + schema, + name, +}: ObjectPropertiesModalProps) { + const defaultTab: Tab = + objectType === "table" + ? "overview" + : objectType === "function" || objectType === "trigger-function" + ? "overview" + : "overview"; + const [activeTab, setActiveTab] = useState(defaultTab); + const [copied, setCopied] = useState(null); + const [actionResult, setActionResult] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + const [actionLoading, setActionLoading] = useState(false); + const [confirmAction, setConfirmAction] = useState(null); + const [confirmInput, setConfirmInput] = useState(""); + + const { + ddl, + ddlLoading, + ddlError, + loading, + tableStats, + outgoingFKs, + incomingFKs, + viewInfo, + functionMeta, + matViewStats, + metaKey, + getDriver, + fetchLiveData, + fetchDDL, + } = useObjectData(projectId, schema, name, objectType, open); + + const columnDetails = useProjectStore((s) => s.columnDetails); + const indexes = useProjectStore((s) => s.indexes); + const constraints = useProjectStore((s) => s.constraints); + const triggers = useProjectStore((s) => s.triggers); + const rules = useProjectStore((s) => s.rules); + const policies = useProjectStore((s) => s.policies); + const openTab = useTabStore((s) => s.openTab); + + const cols = columnDetails[metaKey]; + const idxs = indexes[metaKey]; + const cons = constraints[metaKey]; + const trigs = triggers[metaKey]; + const rls = rules[metaKey]; + const pols = policies[metaKey]; + const pkCols = new Set((idxs ?? []).filter((i) => i.isPrimary).map((i) => i.columnName)); + + useEffect(() => { + if (!open) return; + setActiveTab(objectType === "table" ? "overview" : "overview"); + setCopied(null); + setActionResult(null); + setConfirmAction(null); + }, [open, objectType]); + + useEffect(() => { + if (open && activeTab === "ddl" && !ddl && !ddlLoading) { + void fetchDDL(); + } + }, [open, activeTab, ddl, ddlLoading, fetchDDL]); + + const copyText = (text: string, label: string) => { + navigator.clipboard.writeText(text); + setCopied(label); + setTimeout(() => setCopied(null), 2000); + }; + + const runAction = useCallback( + async (actionLabel: string) => { + const driver = getDriver(); + if (!driver?.tableAction) return; + setActionLoading(true); + setActionResult(null); + try { + const msg = await driver.tableAction(projectId, actionLabel, schema, name, objectType); + setActionResult({ type: "success", message: msg }); + void fetchLiveData(); + } catch (err: any) { + setActionResult({ + type: "error", + message: err?.message ?? "Action failed", + }); + } finally { + setActionLoading(false); + setConfirmAction(null); + } + }, + [getDriver, projectId, schema, name, objectType, fetchLiveData], + ); + + const availableTabs: { key: Tab; label: string }[] = []; + availableTabs.push({ key: "overview", label: "Overview" }); + if (objectType === "table") { + availableTabs.push({ key: "structure", label: "Structure" }); + availableTabs.push({ + key: "columns", + label: `Columns${cols ? ` (${cols.length})` : ""}`, + }); + availableTabs.push({ + key: "indexes", + label: `Indexes${idxs ? ` (${new Set(idxs.map((i) => i.indexName)).size})` : ""}`, + }); + availableTabs.push({ key: "fkeys", label: `Foreign Keys` }); + } + availableTabs.push({ key: "ddl", label: "DDL" }); + availableTabs.push({ key: "actions", label: "Actions" }); + + return ( + + + + +
+ {activeTab === "structure" && objectType === "table" && ( + { + void fetchLiveData(); + useProjectStore.setState((s) => { + delete s.columnDetails[metaKey]; + delete s.indexes[metaKey]; + delete s.constraints[metaKey]; + }); + }} + openTab={openTab} + onOpenChange={onOpenChange} + /> + )} + {activeTab === "overview" && ( + + )} + {activeTab === "columns" && } + {activeTab === "indexes" && } + {activeTab === "fkeys" && ( + + )} + {activeTab === "ddl" && ( + ddl && copyText(ddl, "ddl")} + onRetry={fetchDDL} + onOpenInTab={() => { + if (ddl) { + openTab(projectId, ddl); + onOpenChange(false); + } + }} + /> + )} + {activeTab === "actions" && ( + { + setConfirmAction(v); + setConfirmInput(""); + }} + confirmInput={confirmInput} + setConfirmInput={setConfirmInput} + runAction={runAction} + openTab={openTab} + projectId={projectId} + onOpenChange={onOpenChange} + /> + )} +
+
+
+ ); +} diff --git a/src/components/object-properties-modal/indexes-tab.tsx b/src/components/object-properties-modal/indexes-tab.tsx new file mode 100644 index 0000000..7aa1a18 --- /dev/null +++ b/src/components/object-properties-modal/indexes-tab.tsx @@ -0,0 +1,86 @@ +import { Key, Shield } from "lucide-react"; +import { LoadingPlaceholder } from "./shared"; + +export function IndexesContent({ idxs }: { idxs?: import("@/types").IndexDetail[] }) { + if (!idxs) { + return ; + } + + if (idxs.length === 0) { + return ( +
+ + No indexes found +
+ ); + } + + const grouped = new Map(); + for (const idx of idxs) { + if (!grouped.has(idx.indexName)) grouped.set(idx.indexName, []); + grouped.get(idx.indexName)?.push(idx); + } + + return ( +
+
+ + + + + + + + + + + {Array.from(grouped.entries()).map(([idxName, entries]) => { + const f = entries[0]; + return ( + + + + + + + ); + })} + +
+ Index Name + + Columns + + Type +
+ {f.isPrimary ? ( + + ) : f.isUnique ? ( + + ) : ( + + )} + {idxName} + {entries.map((e) => e.columnName).join(", ")} + + {f.isPrimary ? ( + + PRIMARY KEY + + ) : f.isUnique ? ( + + UNIQUE + + ) : ( + + INDEX + + )} +
+
+
+ ); +} diff --git a/src/components/object-properties-modal/modal-header.tsx b/src/components/object-properties-modal/modal-header.tsx new file mode 100644 index 0000000..ece8284 --- /dev/null +++ b/src/components/object-properties-modal/modal-header.tsx @@ -0,0 +1,135 @@ +import { + Check, + Columns3, + Copy, + Database, + Eye, + FileCode, + Key, + Layers, + Link2, + Loader2, + Pencil, + Table, + Zap, +} from "lucide-react"; +import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { ObjectType, Tab } from "./types"; + +const objectIcon: Record = { + table: , + view: , + matview: , + function: , + "trigger-function": , +}; + +const objectLabel: Record = { + table: "Table", + view: "View", + matview: "Materialized View", + function: "Function", + "trigger-function": "Trigger Function", +}; + +export const typeColor: Record = { + table: "from-primary/20 to-primary/5", + view: "from-blue-500/20 to-blue-500/5", + matview: "from-purple-500/20 to-purple-500/5", + function: "from-amber-500/20 to-amber-500/5", + "trigger-function": "from-orange-500/20 to-orange-500/5", +}; + +const tabIcons: Partial> = { + overview: , + columns: , + indexes: , + fkeys: , + structure: , + ddl: , + actions: , +}; + +export function ModalHeader({ + objectType, + schema, + name, + projectId, + loading, + copied, + copyText, + availableTabs, + activeTab, + setActiveTab, +}: { + objectType: ObjectType; + schema: string; + name: string; + projectId: string; + loading: boolean; + copied: string | null; + copyText: (text: string, label: string) => void; + availableTabs: { key: Tab; label: string }[]; + activeTab: Tab; + setActiveTab: (tab: Tab) => void; +}) { + return ( +
+
+ +
+
+ {objectIcon[objectType]} +
+
+ + {name} + + + + + {objectLabel[objectType]} + + {schema} + | + {projectId} + {loading && } + +
+
+
+ + {/* Tab switcher - pill style */} +
+ {availableTabs.map((tab) => ( + + ))} +
+
+ ); +} diff --git a/src/components/object-properties-modal/overview-function.tsx b/src/components/object-properties-modal/overview-function.tsx new file mode 100644 index 0000000..3d4d280 --- /dev/null +++ b/src/components/object-properties-modal/overview-function.tsx @@ -0,0 +1,74 @@ +import { ArrowRight, Check, Columns3, Copy, FileCode, RefreshCw } from "lucide-react"; +import { InfoRow, LoadingPlaceholder, PropertySection, StatCard } from "./shared"; +import type { FunctionMeta } from "./types"; + +export function FunctionOverview({ + functionMeta, + copyText, + copied, +}: { + functionMeta: FunctionMeta | null; + copyText: (text: string, label: string) => void; + copied: string | null; +}) { + if (!functionMeta) return ; + return ( +
+
+ } + /> + } + /> + } + /> +
+
+ + + + +
+ {functionMeta.arguments && ( + }> +
+ {functionMeta.arguments} +
+
+ )} + }> +
+ +
+            {functionMeta.source}
+          
+
+
+
+ ); +} diff --git a/src/components/object-properties-modal/overview-tab.tsx b/src/components/object-properties-modal/overview-tab.tsx new file mode 100644 index 0000000..6ceabd2 --- /dev/null +++ b/src/components/object-properties-modal/overview-tab.tsx @@ -0,0 +1,220 @@ +import { + AlertTriangle, + Check, + Database, + HardDrive, + Key, + Link2, + Lock, + RefreshCw, + ScrollText, + Zap, +} from "lucide-react"; +import { FunctionOverview } from "./overview-function"; +import { ViewOverview } from "./overview-view"; +import { + ConstraintIcon, + formatTimestamp, + InfoRow, + LoadingPlaceholder, + PropertySection, + StatCard, +} from "./shared"; +import type { FunctionMeta, MatViewStats, ObjectType, TableStats, ViewInfo } from "./types"; + +export function OverviewContent({ + objectType, + tableStats, + viewInfo, + matViewStats, + functionMeta, + cons, + trigs, + rls, + pols, + copyText, + copied, +}: { + objectType: ObjectType; + tableStats: TableStats | null; + viewInfo: ViewInfo | null; + matViewStats: MatViewStats | null; + functionMeta: FunctionMeta | null; + cons?: import("@/types").ConstraintDetail[]; + trigs?: import("@/types").TriggerDetail[]; + rls?: import("@/types").RuleDetail[]; + pols?: import("@/types").PolicyDetail[]; + copyText: (text: string, label: string) => void; + copied: string | null; +}) { + if (objectType === "table") { + if (!tableStats) { + return ; + } + return ( +
+ {/* Stats grid */} +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+ + {/* Scan stats */} + }> +
+
+
+ Sequential Scans +
+
{Number(tableStats.seqScan).toLocaleString()}
+
+
+
Index Scans
+
{Number(tableStats.idxScan).toLocaleString()}
+
+
+
+ + {/* Maintenance */} + }> +
+ + + + +
+
+ + {/* Constraints summary */} + {cons && cons.length > 0 && ( + }> +
+ {Array.from(new Set(cons.map((c) => c.constraintName))).map((cName) => { + const f = cons.find((c) => c.constraintName === cName)!; + const entries = cons.filter((c) => c.constraintName === cName); + return ( +
+ + {cName} + + {f.constraintType} + + + ({entries.map((e) => e.columnName).join(", ")}) + +
+ ); + })} +
+
+ )} + + {/* Triggers */} + {trigs && trigs.length > 0 && ( + }> +
+ {trigs.map((t) => ( +
+ + {t.triggerName} + + {t.timing} {t.event} + +
+ ))} +
+
+ )} + + {/* RLS */} + {pols && pols.length > 0 && ( + }> +
+ {pols.map((p) => ( +
+ + {p.policyName} + + {p.permissive} {p.command} + +
+ ))} +
+
+ )} + + {/* Rules */} + {rls && rls.length > 0 && ( + }> +
+ {rls.map((r) => ( +
+ + {r.ruleName} + {r.event} +
+ ))} +
+
+ )} +
+ ); + } + + if (objectType === "view") { + return ; + } + + if (objectType === "matview") { + return ; + } + + if (objectType === "function" || objectType === "trigger-function") { + return ; + } + + return ; +} diff --git a/src/components/object-properties-modal/overview-view.tsx b/src/components/object-properties-modal/overview-view.tsx new file mode 100644 index 0000000..febe4c2 --- /dev/null +++ b/src/components/object-properties-modal/overview-view.tsx @@ -0,0 +1,68 @@ +import { Check, Database, Eye, FileCode, HardDrive, Shield } from "lucide-react"; +import { LoadingPlaceholder, PropertySection, StatCard } from "./shared"; +import type { MatViewStats, ViewInfo } from "./types"; + +export function ViewOverview({ + viewInfo, + matViewStats, +}: { + viewInfo?: ViewInfo | null; + matViewStats?: MatViewStats | null; +}) { + if (viewInfo !== undefined) { + if (!viewInfo) return ; + return ( +
+
+ } + /> + } + /> +
+ }> +
+            {viewInfo.definition}
+          
+
+
+ ); + } + + if (matViewStats !== undefined) { + if (!matViewStats) return ; + return ( +
+
+ } + /> + } + /> + } + /> +
+ }> +
+            {matViewStats.definition}
+          
+
+
+ ); + } + + return ; +} diff --git a/src/components/object-properties-modal/shared.tsx b/src/components/object-properties-modal/shared.tsx new file mode 100644 index 0000000..72bf733 --- /dev/null +++ b/src/components/object-properties-modal/shared.tsx @@ -0,0 +1,109 @@ +import { Check, Key, Link2, Loader2, Shield } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export function StatCard({ + label, + value, + icon, + accent, +}: { + label: string; + value: string; + icon: React.ReactNode; + accent?: string; +}) { + return ( +
+
+
+
+ {icon} +
+
+
+ {label} +
+
+ {value} +
+
+
+
+ ); +} + +export function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +export function PropertySection({ + title, + icon, + children, +}: { + title: string; + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+
+ {icon} +
+ + {title} + +
+
+ {children} +
+ ); +} + +export function ConstraintIcon({ type }: { type: string }) { + if (type === "PRIMARY KEY") return ; + if (type === "FOREIGN KEY") return ; + if (type === "UNIQUE") return ; + if (type === "CHECK") return ; + return ; +} + +export function LoadingPlaceholder() { + return ( +
+
+
+ +
+ Loading... +
+ ); +} + +export function formatTimestamp(ts: string): string { + if (ts === "never") return "never"; + try { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ts; + const now = Date.now(); + const diff = now - d.getTime(); + if (diff < 60000) return "just now"; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + if (diff < 2592000000) return `${Math.floor(diff / 86400000)}d ago`; + return d.toLocaleDateString(); + } catch { + return ts; + } +} diff --git a/src/components/object-properties-modal/structure-editor/columns-section.tsx b/src/components/object-properties-modal/structure-editor/columns-section.tsx new file mode 100644 index 0000000..4ad1dff --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/columns-section.tsx @@ -0,0 +1,165 @@ +import { Plus, RefreshCw, Trash2 } from "lucide-react"; +import type { DraftColumn, StructureEditorState } from "@/lib/alter-table-sql"; +import { PG_COMMON_TYPES } from "@/lib/alter-table-sql"; +import { cn } from "@/lib/utils"; +import { uid } from "./initialization"; + +export function ColumnsSection({ + draft, + setDraft, +}: { + draft: StructureEditorState; + setDraft: React.Dispatch>; +}) { + const updateColumn = (id: string, updates: Partial) => { + setDraft((prev) => ({ + ...prev, + columns: prev.columns.map((c) => { + if (c._id !== id) return c; + const updated = { ...c, ...updates }; + // Mark as modified if it was existing and something changed + if (c._status === "existing") { + const changed = + updated.name !== c.originalName || + updated.dataType !== c.originalDataType || + updated.nullable !== c.originalNullable || + updated.defaultValue !== c.originalDefault; + updated._status = changed ? "modified" : "existing"; + } + return updated; + }), + })); + }; + + const addColumn = () => { + setDraft((prev) => ({ + ...prev, + columns: [ + ...prev.columns, + { + _id: uid(), + _status: "added", + name: `new_column_${prev.columns.length + 1}`, + dataType: "text", + nullable: true, + defaultValue: null, + }, + ], + })); + }; + + const removeColumn = (id: string) => { + setDraft((prev) => ({ + ...prev, + columns: prev.columns + .map((c) => + c._id === id ? (c._status === "added" ? null : { ...c, _status: "removed" as const }) : c, + ) + .filter(Boolean) as DraftColumn[], + })); + }; + + const restoreColumn = (id: string) => { + setDraft((prev) => ({ + ...prev, + columns: prev.columns.map((c) => (c._id === id ? { ...c, _status: "existing" as const } : c)), + })); + }; + + return ( +
+
+ Name + Type + Nullable + Default + +
+ {draft.columns.map((col) => ( +
+ updateColumn(col._id, { name: e.target.value })} + className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 disabled:opacity-40" + /> + updateColumn(col._id, { dataType: e.target.value })} + list="pg-types" + className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 disabled:opacity-40" + /> +
+ updateColumn(col._id, { nullable: e.target.checked })} + className="h-3.5 w-3.5 rounded border-border accent-primary" + /> +
+ + updateColumn(col._id, { + defaultValue: e.target.value || null, + }) + } + placeholder="NULL" + className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 placeholder:text-muted-foreground/30 disabled:opacity-40" + /> +
+ {col._status === "removed" ? ( + + ) : ( + + )} +
+
+ ))} + + + {PG_COMMON_TYPES.map((t) => ( + +
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/fk-card.tsx b/src/components/object-properties-modal/structure-editor/fk-card.tsx new file mode 100644 index 0000000..1c060d2 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/fk-card.tsx @@ -0,0 +1,233 @@ +import { ArrowRight, Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { DraftForeignKey } from "@/lib/alter-table-sql"; +import { FK_ACTIONS } from "@/lib/alter-table-sql"; +import type { DriverFactory } from "@/lib/database-driver"; +import { cn } from "@/lib/utils"; + +export function FKCard({ + fk, + activeColNames, + availableSchemas, + getTablesForSchema, + loadTables, + projectId, + getDriver, + onChange, + onRemove, +}: { + fk: DraftForeignKey; + activeColNames: string[]; + availableSchemas: string[]; + getTablesForSchema: (schema: string) => string[]; + loadTables: (projectId: string, schema: string) => Promise; + projectId: string; + getDriver: () => ReturnType | null; + onChange: (updates: Partial) => void; + onRemove: () => void; +}) { + const [targetCols, setTargetCols] = useState([]); + + useEffect(() => { + if (!fk.targetTable || !fk.targetSchema) { + setTargetCols([]); + return; + } + const driver = getDriver(); + if (!driver) return; + driver + .loadColumns(projectId, fk.targetSchema, fk.targetTable) + .then(setTargetCols) + .catch(() => setTargetCols([])); + }, [fk.targetSchema, fk.targetTable, projectId, getDriver]); + + useEffect(() => { + if (fk.targetSchema) { + loadTables(projectId, fk.targetSchema).catch(() => {}); + } + }, [fk.targetSchema, projectId, loadTables]); + + const targetTableNames = getTablesForSchema(fk.targetSchema); + + return ( +
+
+ onChange({ constraintName: e.target.value })} + className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" + placeholder="Constraint name" + /> + +
+ +
+
+
+ Target Schema +
+ +
+
+
+ Target Table +
+ +
+
+ +
+
+
+ Column Mapping +
+
+
+
+ Source + + Target + +
+ {fk.sourceColumns.map((srcCol, idx) => ( +
+ + + + +
+ ))} + +
+
+ +
+
+
+ On Update +
+ +
+
+
+ On Delete +
+ +
+
+
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/fkeys-section.tsx b/src/components/object-properties-modal/structure-editor/fkeys-section.tsx new file mode 100644 index 0000000..e5b562b --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/fkeys-section.tsx @@ -0,0 +1,125 @@ +import { Plus } from "lucide-react"; +import type { DraftForeignKey, StructureEditorState } from "@/lib/alter-table-sql"; +import type { DriverFactory } from "@/lib/database-driver"; +import { FKCard } from "./fk-card"; +import { uid } from "./initialization"; + +export function FkeysSection({ + draft, + setDraft, + activeColNames, + tableName, + schema, + availableSchemas, + getTablesForSchema, + loadTables, + projectId, + getDriver, +}: { + draft: StructureEditorState; + setDraft: React.Dispatch>; + activeColNames: string[]; + tableName: string; + schema: string; + availableSchemas: string[]; + getTablesForSchema: (schema: string) => string[]; + loadTables: (projectId: string, schema: string) => Promise; + projectId: string; + getDriver: () => ReturnType | null; +}) { + return ( +
+ {draft.foreignKeys + .filter((fk) => fk._status !== "removed") + .map((fk) => ( + { + setDraft((prev) => ({ + ...prev, + foreignKeys: prev.foreignKeys.map((f) => + f._id === fk._id + ? { + ...f, + ...updates, + _status: f._status === "existing" ? "existing" : f._status, + } + : f, + ), + })); + }} + onRemove={() => { + setDraft((prev) => ({ + ...prev, + foreignKeys: prev.foreignKeys + .map((f) => + f._id === fk._id + ? f._status === "added" + ? null + : { ...f, _status: "removed" as const } + : f, + ) + .filter(Boolean) as DraftForeignKey[], + })); + }} + /> + ))} + {draft.foreignKeys + .filter((fk) => fk._status === "removed") + .map((fk) => ( +
+ {fk.constraintName} + +
+ ))} + +
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/index.tsx b/src/components/object-properties-modal/structure-editor/index.tsx new file mode 100644 index 0000000..7f4c15d --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/index.tsx @@ -0,0 +1,257 @@ +import { AlertTriangle, Copy, FileCode, Loader2, Play } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import type { StructureEditorState } from "@/lib/alter-table-sql"; +import { countChanges, generateAlterTableSQL } from "@/lib/alter-table-sql"; +import type { DriverFactory } from "@/lib/database-driver"; +import { cn } from "@/lib/utils"; +import { useProjectStore } from "@/stores/project-store"; +import { LoadingPlaceholder } from "../shared"; +import type { FKInfo } from "../types"; +import { ColumnsSection } from "./columns-section"; +import { FkeysSection } from "./fkeys-section"; +import { IndexesSection } from "./indexes-section"; +import { initStructureState, type StructureSubTab } from "./initialization"; +import { PkSection } from "./pk-section"; +import { UniqueSection } from "./unique-section"; + +export function StructureEditorContent({ + projectId, + schema, + tableName, + cols, + idxs, + cons, + outgoingFKs, + getDriver, + onApplied, + openTab, + onOpenChange, +}: { + projectId: string; + schema: string; + tableName: string; + cols: import("@/types").ColumnDetail[] | undefined; + idxs: import("@/types").IndexDetail[] | undefined; + cons: import("@/types").ConstraintDetail[] | undefined; + outgoingFKs: FKInfo[]; + getDriver: () => ReturnType | null; + onApplied: () => void; + openTab: (projectId?: string, sql?: string) => void; + onOpenChange: (open: boolean) => void; +}) { + const [subTab, setSubTab] = useState("columns"); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(null); + const [showSql, setShowSql] = useState(false); + + const initialState = useMemo( + () => initStructureState(cols, idxs, cons, outgoingFKs), + [cols, idxs, cons, outgoingFKs], + ); + const [draft, setDraft] = useState(initialState); + + // Reset draft when source data refreshes (e.g. after re-opening the modal) + useEffect(() => { + setDraft(initialState); + setError(null); + setShowSql(false); + }, [initialState]); + + const changes = countChanges(draft); + const activeColNames = draft.columns.filter((c) => c._status !== "removed").map((c) => c.name); + + const tables = useProjectStore((s) => s.tables); + const schemas = useProjectStore((s) => s.schemas); + const loadTables = useProjectStore((s) => s.loadTables); + const availableSchemas = schemas[projectId] ?? []; + const getTablesForSchema = (s: string) => (tables[`${projectId}::${s}`] ?? []).map((t) => t.name); + + const sqlStatements = useMemo( + () => generateAlterTableSQL(schema, tableName, initialState, draft), + [schema, tableName, initialState, draft], + ); + const sqlPreview = sqlStatements.join("\n"); + + const applyChanges = useCallback(async () => { + const driver = getDriver(); + if (!driver || sqlStatements.length === 0) return; + setApplying(true); + setError(null); + try { + await driver.runQuery(projectId, "BEGIN"); + try { + for (const stmt of sqlStatements) { + await driver.runQuery(projectId, stmt); + } + await driver.runQuery(projectId, "COMMIT"); + } catch (err) { + await driver.runQuery(projectId, "ROLLBACK").catch(() => {}); + throw err; + } + toast.success("Table structure updated"); + onApplied(); + } catch (err: any) { + setError(err?.message ?? "Failed to apply changes"); + } finally { + setApplying(false); + } + }, [getDriver, projectId, sqlStatements, onApplied]); + + const subTabs: { key: StructureSubTab; label: string }[] = [ + { key: "columns", label: "Columns" }, + { key: "pk", label: "Primary Key" }, + { key: "fkeys", label: "Foreign Keys" }, + { key: "unique", label: "Unique" }, + { key: "indexes", label: "Indexes" }, + ]; + + if (!cols) return ; + + return ( +
+
+ {subTabs.map((t) => ( + + ))} +
+ +
+ {subTab === "columns" && } + + {subTab === "pk" && ( + + )} + + {subTab === "fkeys" && ( + + )} + + {subTab === "unique" && ( + + )} + + {subTab === "indexes" && ( + + )} +
+ + {showSql && sqlStatements.length > 0 && ( +
+
+ SQL Preview +
+ + +
+
+
+            {sqlPreview}
+          
+
+ )} + + {error && ( +
+ + {error} +
+ )} + + {changes > 0 && ( +
+ + {changes} change{changes !== 1 ? "s" : ""} + +
+ + + +
+ )} +
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/indexes-section.tsx b/src/components/object-properties-modal/structure-editor/indexes-section.tsx new file mode 100644 index 0000000..fefd923 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/indexes-section.tsx @@ -0,0 +1,166 @@ +import { Plus, Trash2 } from "lucide-react"; +import type { DraftIndex, StructureEditorState } from "@/lib/alter-table-sql"; +import { cn } from "@/lib/utils"; +import { uid } from "./initialization"; + +export function IndexesSection({ + draft, + setDraft, + activeColNames, + tableName, +}: { + draft: StructureEditorState; + setDraft: React.Dispatch>; + activeColNames: string[]; + tableName: string; +}) { + return ( +
+ {draft.indexes + .filter((idx) => idx._status !== "removed") + .map((idx) => ( +
+
+ { + setDraft((prev) => ({ + ...prev, + indexes: prev.indexes.map((i) => + i._id === idx._id ? { ...i, indexName: e.target.value } : i, + ), + })); + }} + className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" + placeholder="Index name" + /> + + +
+
+ Columns +
+
+ {activeColNames.map((colName) => { + const selected = idx.columns.includes(colName); + return ( + + ); + })} +
+
+ ))} + {draft.indexes + .filter((idx) => idx._status === "removed") + .map((idx) => ( +
+ {idx.indexName} + +
+ ))} + +
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/initialization.ts b/src/components/object-properties-modal/structure-editor/initialization.ts new file mode 100644 index 0000000..e471199 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/initialization.ts @@ -0,0 +1,104 @@ +import type { + DraftColumn, + DraftForeignKey, + DraftIndex, + DraftPrimaryKey, + DraftUniqueConstraint, + StructureEditorState, +} from "@/lib/alter-table-sql"; +import type { FKInfo } from "../types"; + +export type StructureSubTab = "columns" | "pk" | "fkeys" | "unique" | "indexes"; + +export function uid() { + return crypto.randomUUID(); +} + +export function initStructureState( + cols: import("@/types").ColumnDetail[] | undefined, + idxs: import("@/types").IndexDetail[] | undefined, + cons: import("@/types").ConstraintDetail[] | undefined, + outgoingFKs: FKInfo[], +): StructureEditorState { + const columns: DraftColumn[] = (cols ?? []).map((c) => ({ + _id: uid(), + _status: "existing" as const, + name: c.name, + dataType: c.dataType, + nullable: c.nullable, + defaultValue: c.defaultValue, + originalName: c.name, + originalDataType: c.dataType, + originalNullable: c.nullable, + originalDefault: c.defaultValue, + })); + + const pkEntries = (idxs ?? []).filter((i) => i.isPrimary); + const pkName = pkEntries[0]?.indexName ?? ""; + const primaryKey: DraftPrimaryKey | null = + pkEntries.length > 0 + ? { + constraintName: pkName, + columns: pkEntries.map((e) => e.columnName), + _status: "existing", + originalColumns: pkEntries.map((e) => e.columnName), + } + : null; + + const uniqueMap = new Map(); + for (const c of cons ?? []) { + if (c.constraintType === "UNIQUE") { + const existing = uniqueMap.get(c.constraintName) ?? []; + existing.push(c.columnName); + uniqueMap.set(c.constraintName, existing); + } + } + const uniqueConstraints: DraftUniqueConstraint[] = [...uniqueMap.entries()].map( + ([name, ucCols]) => ({ + _id: uid(), + _status: "existing" as const, + constraintName: name, + columns: ucCols, + }), + ); + + const idxMap = new Map(); + for (const i of idxs ?? []) { + if (i.isPrimary) continue; + // Skip indexes that back a unique constraint + if (uniqueMap.has(i.indexName)) continue; + const existing = idxMap.get(i.indexName) ?? { + columns: [], + isUnique: i.isUnique, + }; + existing.columns.push(i.columnName); + idxMap.set(i.indexName, existing); + } + const indexes: DraftIndex[] = [...idxMap.entries()].map(([name, info]) => ({ + _id: uid(), + _status: "existing" as const, + indexName: name, + columns: info.columns, + isUnique: info.isUnique, + })); + + const fkMap = new Map(); + for (const fk of outgoingFKs) { + const existing = fkMap.get(fk.constraintName) ?? []; + existing.push(fk); + fkMap.set(fk.constraintName, existing); + } + const foreignKeys: DraftForeignKey[] = [...fkMap.entries()].map(([name, fks]) => ({ + _id: uid(), + _status: "existing" as const, + constraintName: name, + sourceColumns: fks.map((f) => f.sourceColumn), + targetSchema: fks[0].targetSchema, + targetTable: fks[0].targetTable, + targetColumns: fks.map((f) => f.targetColumn), + onUpdate: fks[0].onUpdate, + onDelete: fks[0].onDelete, + })); + + return { columns, primaryKey, foreignKeys, uniqueConstraints, indexes }; +} diff --git a/src/components/object-properties-modal/structure-editor/pk-section.tsx b/src/components/object-properties-modal/structure-editor/pk-section.tsx new file mode 100644 index 0000000..286aa66 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/pk-section.tsx @@ -0,0 +1,97 @@ +import { Key } from "lucide-react"; +import type { StructureEditorState } from "@/lib/alter-table-sql"; +import { cn } from "@/lib/utils"; + +export function PkSection({ + draft, + setDraft, + activeColNames, + tableName, +}: { + draft: StructureEditorState; + setDraft: React.Dispatch>; + activeColNames: string[]; + tableName: string; +}) { + return ( +
+
+ Select columns for primary key +
+
+ {activeColNames.map((colName) => { + const isInPK = + draft.primaryKey?.columns.includes(colName) && draft.primaryKey._status !== "removed"; + return ( + + ); + })} +
+ {draft.primaryKey && + draft.primaryKey._status !== "removed" && + draft.primaryKey.columns.length > 0 && ( +
+ Constraint:{" "} + {draft.primaryKey.constraintName} + {" — "}({draft.primaryKey.columns.join(", ")}) +
+ )} +
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/unique-section.tsx b/src/components/object-properties-modal/structure-editor/unique-section.tsx new file mode 100644 index 0000000..647d451 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/unique-section.tsx @@ -0,0 +1,149 @@ +import { Plus, Trash2 } from "lucide-react"; +import type { DraftUniqueConstraint, StructureEditorState } from "@/lib/alter-table-sql"; +import { cn } from "@/lib/utils"; +import { uid } from "./initialization"; + +export function UniqueSection({ + draft, + setDraft, + activeColNames, + tableName, +}: { + draft: StructureEditorState; + setDraft: React.Dispatch>; + activeColNames: string[]; + tableName: string; +}) { + return ( +
+ {draft.uniqueConstraints + .filter((uc) => uc._status !== "removed") + .map((uc) => ( +
+
+ { + setDraft((prev) => ({ + ...prev, + uniqueConstraints: prev.uniqueConstraints.map((u) => + u._id === uc._id ? { ...u, constraintName: e.target.value } : u, + ), + })); + }} + className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" + placeholder="Constraint name" + /> + +
+
+ Columns +
+
+ {activeColNames.map((colName) => { + const selected = uc.columns.includes(colName); + return ( + + ); + })} +
+
+ ))} + {draft.uniqueConstraints + .filter((uc) => uc._status === "removed") + .map((uc) => ( +
+ {uc.constraintName} + +
+ ))} + +
+ ); +} diff --git a/src/components/object-properties-modal/types.ts b/src/components/object-properties-modal/types.ts new file mode 100644 index 0000000..1777114 --- /dev/null +++ b/src/components/object-properties-modal/types.ts @@ -0,0 +1,64 @@ +export type ObjectType = "table" | "view" | "matview" | "function" | "trigger-function"; + +export type Tab = "overview" | "columns" | "indexes" | "fkeys" | "ddl" | "actions" | "structure"; + +export interface ObjectPropertiesModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + objectType: ObjectType; + projectId: string; + schema: string; + name: string; +} + +export interface TableStats { + rowEstimate: string; + tableSize: string; + indexSize: string; + totalSize: string; + lastVacuum: string; + lastAnalyze: string; + lastAutoVacuum: string; + lastAutoAnalyze: string; + deadTuples: string; + liveTuples: string; + seqScan: string; + idxScan: string; +} + +export interface FKInfo { + constraintName: string; + sourceSchema: string; + sourceTable: string; + sourceColumn: string; + targetSchema: string; + targetTable: string; + targetColumn: string; + onUpdate: string; + onDelete: string; +} + +export interface ViewInfo { + isUpdatable: string; + checkOption: string; + definition: string; +} + +export interface FunctionMeta { + language: string; + volatility: string; + isStrict: boolean; + securityDefiner: boolean; + estimatedCost: string; + estimatedRows: string; + returnType: string; + arguments: string; + source: string; +} + +export interface MatViewStats { + rowEstimate: string; + totalSize: string; + isPopulated: string; + definition: string; +} diff --git a/src/components/object-properties-modal/use-object-data.ts b/src/components/object-properties-modal/use-object-data.ts new file mode 100644 index 0000000..377907d --- /dev/null +++ b/src/components/object-properties-modal/use-object-data.ts @@ -0,0 +1,203 @@ +import { useCallback, useEffect, useState } from "react"; +import { DriverFactory } from "@/lib/database-driver"; +import { useProjectStore } from "@/stores/project-store"; +import type { FKInfo, FunctionMeta, MatViewStats, ObjectType, TableStats, ViewInfo } from "./types"; + +export function useObjectData( + projectId: string, + schema: string, + name: string, + objectType: ObjectType, + open: boolean, +) { + const [ddl, setDdl] = useState(null); + const [ddlLoading, setDdlLoading] = useState(false); + const [ddlError, setDdlError] = useState(null); + const [loading, setLoading] = useState(false); + + const [tableStats, setTableStats] = useState(null); + const [outgoingFKs, setOutgoingFKs] = useState([]); + const [incomingFKs, setIncomingFKs] = useState([]); + const [viewInfo, setViewInfo] = useState(null); + const [functionMeta, setFunctionMeta] = useState(null); + const [matViewStats, setMatViewStats] = useState(null); + + const columnDetails = useProjectStore((s) => s.columnDetails); + const indexes = useProjectStore((s) => s.indexes); + const projects = useProjectStore((s) => s.projects); + const storeLoadColumnDetails = useProjectStore((s) => s.loadColumnDetails); + const storeLoadIndexes = useProjectStore((s) => s.loadIndexes); + + const metaKey = `${projectId}::${schema}::${name}`; + + const getDriver = useCallback(() => { + const d = projects[projectId]; + if (!d) return null; + return DriverFactory.getDriver(d.driver); + }, [projects, projectId]); + + const fetchLiveData = useCallback(async () => { + const driver = getDriver(); + if (!driver) return; + setLoading(true); + + try { + if (objectType === "table") { + const [statsResult, outFKResult, inFKResult] = await Promise.allSettled([ + driver.loadTableStatistics?.(projectId, schema, name), + driver.loadFKDetails?.(projectId, schema, name, "outgoing"), + driver.loadFKDetails?.(projectId, schema, name, "incoming"), + ]); + + if (!columnDetails[metaKey]) { + storeLoadColumnDetails(projectId, schema, name).catch(() => {}); + } + if (!indexes[metaKey]) { + storeLoadIndexes(projectId, schema, name).catch(() => {}); + } + + if (statsResult.status === "fulfilled" && statsResult.value) { + const statsMap = Object.fromEntries(statsResult.value); + setTableStats({ + rowEstimate: statsMap.row_estimate ?? "0", + tableSize: statsMap.table_size ?? "-", + indexSize: statsMap.index_size ?? "-", + totalSize: statsMap.total_size ?? "-", + lastVacuum: statsMap.last_vacuum ?? "never", + lastAnalyze: statsMap.last_analyze ?? "never", + lastAutoVacuum: statsMap.last_autovacuum ?? "never", + lastAutoAnalyze: statsMap.last_autoanalyze ?? "never", + deadTuples: statsMap.dead_tuples ?? "0", + liveTuples: statsMap.live_tuples ?? "0", + seqScan: statsMap.seq_scan ?? "0", + idxScan: statsMap.idx_scan ?? "0", + }); + } + + const parseFKs = ( + result: PromiseSettledResult< + [string, string, string, string, string, string, string, string, string][] | undefined + >, + ) => { + if (result.status !== "fulfilled" || !result.value) return []; + return result.value.map((r) => ({ + constraintName: r[0], + sourceSchema: r[1], + sourceTable: r[2], + sourceColumn: r[3], + targetSchema: r[4], + targetTable: r[5], + targetColumn: r[6], + onUpdate: r[7], + onDelete: r[8], + })); + }; + setOutgoingFKs(parseFKs(outFKResult)); + setIncomingFKs(parseFKs(inFKResult)); + } else if (objectType === "view") { + const info = await driver.loadViewInfo?.(projectId, schema, name); + if (info) { + const infoMap = Object.fromEntries(info); + setViewInfo({ + isUpdatable: infoMap.is_updatable ?? "NO", + checkOption: infoMap.check_option ?? "NONE", + definition: infoMap.definition ?? "", + }); + } + } else if (objectType === "matview") { + const info = await driver.loadMatviewInfo?.(projectId, schema, name); + if (info) { + const infoMap = Object.fromEntries(info); + setMatViewStats({ + rowEstimate: infoMap.row_estimate ?? "0", + totalSize: infoMap.total_size ?? "-", + isPopulated: infoMap.is_populated ?? "NO", + definition: infoMap.definition ?? "", + }); + } + } else if (objectType === "function" || objectType === "trigger-function") { + const info = await driver.loadFunctionInfo?.(projectId, schema, name); + if (info) { + const infoMap = Object.fromEntries(info); + setFunctionMeta({ + language: infoMap.language ?? "", + volatility: infoMap.volatility ?? "", + isStrict: infoMap.is_strict === "true", + securityDefiner: infoMap.security_definer === "true", + estimatedCost: infoMap.estimated_cost ?? "", + estimatedRows: infoMap.estimated_rows ?? "", + returnType: infoMap.return_type ?? "", + arguments: infoMap.arguments ?? "", + source: infoMap.source ?? "", + }); + } + } + } catch (err) { + console.error("Failed to fetch live data:", err); + } finally { + setLoading(false); + } + }, [ + getDriver, + objectType, + projectId, + schema, + name, + columnDetails, + indexes, + metaKey, + storeLoadColumnDetails, + storeLoadIndexes, + ]); + + useEffect(() => { + if (!open) return; + setDdl(null); + setDdlError(null); + setTableStats(null); + setOutgoingFKs([]); + setIncomingFKs([]); + setViewInfo(null); + setFunctionMeta(null); + setMatViewStats(null); + + void fetchLiveData(); + }, [open, fetchLiveData]); + + const fetchDDL = useCallback(async () => { + const driver = getDriver(); + if (!driver) return; + + setDdlLoading(true); + setDdlError(null); + setDdl(null); + + try { + if (driver.generateDDL) { + const result = await driver.generateDDL(projectId, schema, name, objectType); + setDdl(result || "No DDL available"); + } + } catch (err: any) { + setDdlError(err?.message ?? "Failed to fetch DDL"); + } finally { + setDdlLoading(false); + } + }, [getDriver, objectType, projectId, schema, name]); + + return { + ddl, + ddlLoading, + ddlError, + loading, + tableStats, + outgoingFKs, + incomingFKs, + viewInfo, + functionMeta, + matViewStats, + metaKey, + getDriver, + fetchLiveData, + fetchDDL, + }; +} diff --git a/src/components/performance-monitor.tsx b/src/components/performance-monitor.tsx deleted file mode 100644 index 4c05637..0000000 --- a/src/components/performance-monitor.tsx +++ /dev/null @@ -1,690 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { DriverFactory } from "@/lib/database-driver"; -import { useProjectStore } from "@/stores/project-store"; -import { useHistoryStore } from "@/stores/history-store"; -import { cn } from "@/lib/utils"; -import { - Activity, - BarChart3, - Clock, - Database, - Gauge, - HardDrive, - Loader2, - Lock, - Pause, - Play, - RefreshCw, - Search, - Table, - Users, - Zap, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; - -interface ActivityRow { - pid: string; - user: string; - database: string; - state: string; - waitEventType: string; - waitEvent: string; - query: string; - durationSec: string; - backendType: string; - clientAddr: string; -} - -interface TableStatRow { - schema: string; - table: string; - seqScan: string; - seqTupRead: string; - idxScan: string; - idxTupFetch: string; - inserts: string; - updates: string; - deletes: string; - liveTuples: string; - deadTuples: string; - lastVacuum: string; - lastAutovacuum: string; - lastAnalyze: string; -} - -interface LockRow { - pid: string; - user: string; - mode: string; - locktype: string; - status: string; - relation: string; - schema: string; - query: string; - duration: string; - waitEvent: string; -} - -interface IndexUsageRow { - schema: string; - table: string; - index: string; - size: string; - scans: string; - tuplesRead: string; - tuplesFetched: string; - status: string; - definition: string; -} - -interface BloatRow { - schema: string; - table: string; - liveTuples: string; - deadTuples: string; - bloatPct: string; - totalSize: string; - lastVacuum: string; - lastAutovacuum: string; - lastAnalyze: string; - lastAutoanalyze: string; -} - -type MonitorTab = "overview" | "activity" | "tables" | "history" | "locks" | "indexes" | "bloat"; - -export function PerformanceMonitor({ projectId }: { projectId: string }) { - const projects = useProjectStore((s) => s.projects); - const details = projects[projectId]; - const historyEntries = useHistoryStore((s) => s.entries); - - const [tab, setTab] = useState("overview"); - const [dbStats, setDbStats] = useState<[string, string][]>([]); - const [activity, setActivity] = useState([]); - const [tableStats, setTableStats] = useState([]); - const [locks, setLocks] = useState([]); - const [indexUsage, setIndexUsage] = useState([]); - const [bloat, setBloat] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [autoRefresh, setAutoRefresh] = useState(false); - const [lastRefresh, setLastRefresh] = useState(null); - const intervalRef = useRef | null>(null); - - const refresh = useCallback(async () => { - if (!details) return; - setIsLoading(true); - try { - const driver = DriverFactory.getDriver(details.driver); - - const basePromises = Promise.allSettled([ - driver.loadDatabaseStats(projectId), - driver.loadActivity(projectId), - driver.loadTableStats(projectId), - ]); - - const extraPromises = Promise.allSettled([ - driver.loadLocks ? driver.loadLocks(projectId) : Promise.resolve(undefined), - driver.loadIndexUsage ? driver.loadIndexUsage(projectId) : Promise.resolve(undefined), - driver.loadTableBloat ? driver.loadTableBloat(projectId) : Promise.resolve(undefined), - ]); - - const [baseResults, extraResults] = await Promise.all([basePromises, extraPromises]); - - const [stats, act, tStats] = baseResults; - const [lk, iu, bl] = extraResults; - - if (stats.status === "fulfilled") setDbStats(stats.value); - if (act.status === "fulfilled") { - setActivity( - act.value.map((r) => ({ - pid: r[0], - user: r[1], - database: r[2], - state: r[3], - waitEventType: r[4], - waitEvent: r[5], - query: r[6], - durationSec: r[7], - backendType: r[8], - clientAddr: r[9], - })) - ); - } - if (tStats.status === "fulfilled") { - setTableStats( - tStats.value.map((r) => ({ - schema: r[0], - table: r[1], - seqScan: r[2], - seqTupRead: r[3], - idxScan: r[4], - idxTupFetch: r[5], - inserts: r[6], - updates: r[7], - deletes: r[8], - liveTuples: r[9], - deadTuples: r[10], - lastVacuum: r[11], - lastAutovacuum: r[12], - lastAnalyze: r[13], - })) - ); - } - if (lk.status === "fulfilled" && lk.value) { - setLocks( - lk.value.map((r) => ({ - pid: r[0], - user: r[1], - mode: r[2], - locktype: r[3], - status: r[4], - relation: r[5], - schema: r[6], - query: r[7], - duration: r[8], - waitEvent: r[9], - })) - ); - } - if (iu.status === "fulfilled" && iu.value) { - setIndexUsage( - iu.value.map((r) => ({ - schema: r[0], - table: r[1], - index: r[2], - size: r[3], - scans: r[4], - tuplesRead: r[5], - tuplesFetched: r[6], - status: r[7], - definition: r[8], - })) - ); - } - if (bl.status === "fulfilled" && bl.value) { - setBloat( - bl.value.map((r) => ({ - schema: r[0], - table: r[1], - liveTuples: r[2], - deadTuples: r[3], - bloatPct: r[4], - totalSize: r[5], - lastVacuum: r[6], - lastAutovacuum: r[7], - lastAnalyze: r[8], - lastAutoanalyze: r[9], - })) - ); - } - setLastRefresh(new Date()); - } catch (e) { - console.error("Performance monitor refresh failed:", e); - } finally { - setIsLoading(false); - } - }, [projectId, details]); - - useEffect(() => { - void refresh(); - }, [refresh]); - - useEffect(() => { - if (autoRefresh) { - intervalRef.current = setInterval(() => void refresh(), 5000); - } - return () => { - if (intervalRef.current) clearInterval(intervalRef.current); - }; - }, [autoRefresh, refresh]); - - // Project-specific history - const projectHistory = historyEntries.filter((e) => e.projectId === projectId); - const avgTime = projectHistory.length > 0 - ? projectHistory.reduce((sum, e) => sum + e.executionTime, 0) / projectHistory.length - : 0; - const failedQueries = projectHistory.filter((e) => !e.success).length; - const slowQueries = [...projectHistory].sort((a, b) => b.executionTime - a.executionTime).slice(0, 10); - - const statValue = (name: string) => dbStats.find(([n]) => n === name)?.[1] ?? "N/A"; - - const unusedIndexCount = indexUsage.filter((i) => i.status === "unused").length; - const tablesNeedingVacuum = bloat.filter((b) => parseFloat(b.bloatPct) > 10); - const waitingLocks = locks.filter((l) => l.status === "waiting"); - - const tabs: { id: MonitorTab; label: string; icon: React.ReactNode }[] = [ - { id: "overview", label: "Overview", icon: }, - { id: "activity", label: "Activity", icon: }, - { id: "tables", label: "Table Stats", icon:
}, - { id: "history", label: "Query History", icon: }, - { id: "locks", label: "Locks", icon: }, - { id: "indexes", label: "Index Advisor", icon: }, - { id: "bloat", label: "Bloat", icon: }, - ]; - - return ( -
- {/* Header */} -
-
- - Performance Monitor - - {details?.database ?? projectId} - -
-
- {lastRefresh && ( - - Refreshed at {lastRefresh.toLocaleTimeString()} - - )} - - -
-
- - {/* Tab bar */} -
- {tabs.map((t) => ( - - ))} -
- - {/* Content */} -
- {tab === "overview" && ( -
- {/* Stat cards */} -
- } label="Active Connections" value={statValue("Active Connections")} /> - } label="Database Size" value={statValue("Database Size")} /> - } label="Cache Hit Ratio" value={statValue("Cache Hit Ratio")} /> - } label="Deadlocks" value={statValue("Deadlocks")} /> -
- - {/* All stats table */} -
-
- Database Statistics -
-
- {dbStats.map(([name, val]) => ( -
- {name} - {val} -
- ))} - {dbStats.length === 0 && ( -
- No stats available -
- )} -
-
- - {/* Session history summary */} -
-
- Session Query Summary -
-
-
-
{projectHistory.length}
-
Total Queries
-
-
-
{avgTime.toFixed(1)}ms
-
Avg Execution Time
-
-
-
0 && "text-destructive")}>{failedQueries}
-
Failed Queries
-
-
-
-
- )} - - {tab === "activity" && ( -
-
-
- - - {["PID", "User", "State", "Duration", "Wait", "Backend", "Client", "Query"].map((h) => ( - - ))} - - - - {activity.map((row) => ( - - - - - - - - - - - ))} - {activity.length === 0 && ( - - - - )} - -
{h}
{row.pid}{row.user} - - {row.state} - - {parseFloat(row.durationSec).toFixed(1)}s{row.waitEvent || "-"}{row.backendType}{row.clientAddr}{row.query}
No active connections
-
-
- )} - - {tab === "tables" && ( -
-

- Cumulative stats since server start or last pg_stat_reset(). Source: pg_stat_user_tables -

-
-
- - - - {["Schema", "Table", "Seq Scan", "Idx Scan", "Live Tuples", "Dead Tuples", "Inserts", "Updates", "Deletes", "Last Vacuum", "Last Analyze"].map((h) => ( - - ))} - - - - {tableStats.map((row) => { - const deadRatio = parseInt(row.liveTuples) > 0 - ? (parseInt(row.deadTuples) / parseInt(row.liveTuples)) * 100 - : 0; - return ( - - - - - - - - - - - - - - ); - })} - {tableStats.length === 0 && ( - - - - )} - -
{h}
{row.schema}{row.table}{parseInt(row.seqScan).toLocaleString()}{parseInt(row.idxScan).toLocaleString()}{parseInt(row.liveTuples).toLocaleString()} 10 && "text-destructive font-medium")}> - {parseInt(row.deadTuples).toLocaleString()} - {deadRatio > 10 && ({deadRatio.toFixed(0)}%)} - {parseInt(row.inserts).toLocaleString()}{parseInt(row.updates).toLocaleString()}{parseInt(row.deletes).toLocaleString()}{row.lastVacuum === "never" ? "never" : new Date(row.lastVacuum).toLocaleDateString()}{row.lastAnalyze === "never" ? "never" : new Date(row.lastAnalyze).toLocaleDateString()}
No table stats available
-
-
-
- )} - - {tab === "history" && ( -
- {/* Slow queries */} -
-
- Slowest Queries (Session) -
-
- {slowQueries.map((q) => ( -
-
- - {new Date(q.timestamp).toLocaleTimeString()} - {q.success ? `${q.rowCount} rows` : "FAILED"} - - 1000 && "text-destructive")}> - {q.executionTime.toFixed(1)}ms - -
-
-                      {q.sql.slice(0, 200)}{q.sql.length > 200 ? "..." : ""}
-                    
- {q.error && ( -
{q.error}
- )} -
- ))} - {slowQueries.length === 0 && ( -
- No queries executed yet in this session -
- )} -
-
-
- )} - - {tab === "locks" && ( -
- {waitingLocks.length > 0 && ( -
- - {waitingLocks.length} lock{waitingLocks.length !== 1 ? "s" : ""} waiting to be granted - -
- )} -
-
- - - - {["PID", "User", "Mode", "Lock Type", "Status", "Relation", "Duration", "Query"].map((h) => ( - - ))} - - - - {locks.map((row, idx) => ( - - - - - - - - - - - ))} - {locks.length === 0 && ( - - - - )} - -
{h}
{row.pid}{row.user}{row.mode}{row.locktype} - - {row.status} - - {row.relation || "-"}{parseFloat(row.duration || "0").toFixed(1)}s{row.query}
No active locks
-
-
-
- )} - - {tab === "indexes" && ( -
- {unusedIndexCount > 0 && ( -
- - {unusedIndexCount} unused index{unusedIndexCount !== 1 ? "es" : ""} found -- consider removing to save space and improve write performance - -
- )} -
-
- - - - {["Schema", "Table", "Index", "Size", "Scans", "Status", "Definition"].map((h) => ( - - ))} - - - - {indexUsage.map((row, idx) => ( - - - - - - - - - - ))} - {indexUsage.length === 0 && ( - - - - )} - -
{h}
{row.schema}{row.table}{row.index}{row.size}{parseInt(row.scans).toLocaleString()} - - {row.status === "rarely_used" ? "rarely used" : row.status} - - {row.definition}
No non-primary indexes found
-
-
-
- )} - - {tab === "bloat" && ( -
- {tablesNeedingVacuum.length > 0 && ( -
- - {tablesNeedingVacuum.length} table{tablesNeedingVacuum.length !== 1 ? "s" : ""} with {">"} 10% bloat -- consider running VACUUM - -
- )} -
-
- - - - {["Schema", "Table", "Live Tuples", "Dead Tuples", "Bloat %", "Total Size", "Last Vacuum", "Last Analyze"].map((h) => ( - - ))} - - - - {bloat.map((row, idx) => { - const pct = parseFloat(row.bloatPct) || 0; - const barColor = pct > 30 ? "bg-red-500" : pct > 10 ? "bg-yellow-500" : "bg-green-500"; - return ( - - - - - - - - - - - ); - })} - {bloat.length === 0 && ( - - - - )} - -
{h}
{row.schema}{row.table}{parseInt(row.liveTuples).toLocaleString()}{parseInt(row.deadTuples).toLocaleString()} -
-
-
-
- 30 && "text-red-600 dark:text-red-400 font-medium", - pct > 10 && pct <= 30 && "text-yellow-600 dark:text-yellow-400", - pct <= 10 && "text-muted-foreground", - )}> - {pct.toFixed(1)}% - -
-
{row.totalSize}{row.lastVacuum === "never" ? "never" : new Date(row.lastVacuum).toLocaleDateString()}{row.lastAnalyze === "never" ? "never" : new Date(row.lastAnalyze).toLocaleDateString()}
No table bloat data available
-
-
-
- )} -
-
- ); -} - -function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { - return ( -
-
- {icon} - {label} -
-
{value}
-
- ); -} diff --git a/src/components/performance-monitor/activity-tab.tsx b/src/components/performance-monitor/activity-tab.tsx new file mode 100644 index 0000000..b422d24 --- /dev/null +++ b/src/components/performance-monitor/activity-tab.tsx @@ -0,0 +1,82 @@ +import { cn } from "@/lib/utils"; +import type { ActivityRow } from "./types"; + +interface ActivityTabProps { + activity: ActivityRow[]; +} + +export function ActivityTab({ activity }: ActivityTabProps) { + return ( +
+
+ + + + {["PID", "User", "State", "Duration", "Wait", "Backend", "Client", "Query"].map( + (h) => ( + + ), + )} + + + + {activity.map((row) => ( + + + + + + + + + + + ))} + {activity.length === 0 && ( + + + + )} + +
+ {h} +
{row.pid}{row.user} + + {row.state} + + + {parseFloat(row.durationSec).toFixed(1)}s + + {row.waitEvent || "-"} + + {row.backendType} + + {row.clientAddr} + + {row.query} +
+ No active connections +
+
+
+ ); +} diff --git a/src/components/performance-monitor/bloat-tab.tsx b/src/components/performance-monitor/bloat-tab.tsx new file mode 100644 index 0000000..ecf1309 --- /dev/null +++ b/src/components/performance-monitor/bloat-tab.tsx @@ -0,0 +1,111 @@ +import { cn } from "@/lib/utils"; +import type { BloatRow } from "./types"; + +interface BloatTabProps { + bloat: BloatRow[]; + tablesNeedingVacuum: BloatRow[]; +} + +export function BloatTab({ bloat, tablesNeedingVacuum }: BloatTabProps) { + return ( +
+ {tablesNeedingVacuum.length > 0 && ( +
+ + {tablesNeedingVacuum.length} table{tablesNeedingVacuum.length !== 1 ? "s" : ""} with{" "} + {">"} 10% bloat -- consider running VACUUM + +
+ )} +
+
+ + + + {[ + "Schema", + "Table", + "Live Tuples", + "Dead Tuples", + "Bloat %", + "Total Size", + "Last Vacuum", + "Last Analyze", + ].map((h) => ( + + ))} + + + + {bloat.map((row, idx) => { + const pct = parseFloat(row.bloatPct) || 0; + const barColor = + pct > 30 ? "bg-red-500" : pct > 10 ? "bg-yellow-500" : "bg-green-500"; + return ( + + + + + + + + + + + ); + })} + {bloat.length === 0 && ( + + + + )} + +
+ {h} +
+ {row.schema} + {row.table} + {parseInt(row.liveTuples, 10).toLocaleString()} + + {parseInt(row.deadTuples, 10).toLocaleString()} + +
+
+
+
+ 30 && "text-red-600 dark:text-red-400 font-medium", + pct > 10 && pct <= 30 && "text-yellow-600 dark:text-yellow-400", + pct <= 10 && "text-muted-foreground", + )} + > + {pct.toFixed(1)}% + +
+
{row.totalSize} + {row.lastVacuum === "never" + ? "never" + : new Date(row.lastVacuum).toLocaleDateString()} + + {row.lastAnalyze === "never" + ? "never" + : new Date(row.lastAnalyze).toLocaleDateString()} +
+ No table bloat data available +
+
+
+
+ ); +} diff --git a/src/components/performance-monitor/history-tab.tsx b/src/components/performance-monitor/history-tab.tsx new file mode 100644 index 0000000..b1f2530 --- /dev/null +++ b/src/components/performance-monitor/history-tab.tsx @@ -0,0 +1,56 @@ +import { cn } from "@/lib/utils"; +import type { HistoryEntry } from "@/stores/history-store"; + +interface HistoryTabProps { + slowQueries: HistoryEntry[]; +} + +export function HistoryTab({ slowQueries }: HistoryTabProps) { + return ( +
+ {/* Slow queries */} +
+
+ Slowest Queries (Session) +
+
+ {slowQueries.map((q) => ( +
+
+ + {new Date(q.timestamp).toLocaleTimeString()} -{" "} + {q.success ? `${q.rowCount} rows` : "FAILED"} + + 1000 && "text-destructive", + )} + > + {q.executionTime.toFixed(1)}ms + +
+
+                {q.sql.slice(0, 200)}
+                {q.sql.length > 200 ? "..." : ""}
+              
+ {q.error && ( +
{q.error}
+ )} +
+ ))} + {slowQueries.length === 0 && ( +
+ No queries executed yet in this session +
+ )} +
+
+
+ ); +} diff --git a/src/components/performance-monitor/index.tsx b/src/components/performance-monitor/index.tsx new file mode 100644 index 0000000..22a652a --- /dev/null +++ b/src/components/performance-monitor/index.tsx @@ -0,0 +1,294 @@ +import { + Activity, + BarChart3, + Clock, + Gauge, + Loader2, + Lock, + Pause, + Play, + RefreshCw, + Search, + Table, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { DriverFactory } from "@/lib/database-driver"; +import { cn } from "@/lib/utils"; +import { useHistoryStore } from "@/stores/history-store"; +import { useProjectStore } from "@/stores/project-store"; +import { ActivityTab } from "./activity-tab"; +import { BloatTab } from "./bloat-tab"; +import { HistoryTab } from "./history-tab"; +import { IndexesTab } from "./indexes-tab"; +import { LocksTab } from "./locks-tab"; +import { OverviewTab } from "./overview-tab"; +import { TableStatsTab } from "./table-stats-tab"; +import type { + ActivityRow, + BloatRow, + IndexUsageRow, + LockRow, + MonitorTab, + TableStatRow, +} from "./types"; + +export function PerformanceMonitor({ projectId }: { projectId: string }) { + const projects = useProjectStore((s) => s.projects); + const details = projects[projectId]; + const historyEntries = useHistoryStore((s) => s.entries); + + const [tab, setTab] = useState("overview"); + const [dbStats, setDbStats] = useState<[string, string][]>([]); + const [activity, setActivity] = useState([]); + const [tableStats, setTableStats] = useState([]); + const [locks, setLocks] = useState([]); + const [indexUsage, setIndexUsage] = useState([]); + const [bloat, setBloat] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(false); + const [lastRefresh, setLastRefresh] = useState(null); + const intervalRef = useRef | null>(null); + + const refresh = useCallback(async () => { + if (!details) return; + setIsLoading(true); + try { + const driver = DriverFactory.getDriver(details.driver); + + const basePromises = Promise.allSettled([ + driver.loadDatabaseStats(projectId), + driver.loadActivity(projectId), + driver.loadTableStats(projectId), + ]); + + const extraPromises = Promise.allSettled([ + driver.loadLocks ? driver.loadLocks(projectId) : Promise.resolve(undefined), + driver.loadIndexUsage ? driver.loadIndexUsage(projectId) : Promise.resolve(undefined), + driver.loadTableBloat ? driver.loadTableBloat(projectId) : Promise.resolve(undefined), + ]); + + const [baseResults, extraResults] = await Promise.all([basePromises, extraPromises]); + + const [stats, act, tStats] = baseResults; + const [lk, iu, bl] = extraResults; + + if (stats.status === "fulfilled") setDbStats(stats.value); + if (act.status === "fulfilled") { + setActivity( + act.value.map((r) => ({ + pid: r[0], + user: r[1], + database: r[2], + state: r[3], + waitEventType: r[4], + waitEvent: r[5], + query: r[6], + durationSec: r[7], + backendType: r[8], + clientAddr: r[9], + })), + ); + } + if (tStats.status === "fulfilled") { + setTableStats( + tStats.value.map((r) => ({ + schema: r[0], + table: r[1], + seqScan: r[2], + seqTupRead: r[3], + idxScan: r[4], + idxTupFetch: r[5], + inserts: r[6], + updates: r[7], + deletes: r[8], + liveTuples: r[9], + deadTuples: r[10], + lastVacuum: r[11], + lastAutovacuum: r[12], + lastAnalyze: r[13], + })), + ); + } + if (lk.status === "fulfilled" && lk.value) { + setLocks( + lk.value.map((r) => ({ + pid: r[0], + user: r[1], + mode: r[2], + locktype: r[3], + status: r[4], + relation: r[5], + schema: r[6], + query: r[7], + duration: r[8], + waitEvent: r[9], + })), + ); + } + if (iu.status === "fulfilled" && iu.value) { + setIndexUsage( + iu.value.map((r) => ({ + schema: r[0], + table: r[1], + index: r[2], + size: r[3], + scans: r[4], + tuplesRead: r[5], + tuplesFetched: r[6], + status: r[7], + definition: r[8], + })), + ); + } + if (bl.status === "fulfilled" && bl.value) { + setBloat( + bl.value.map((r) => ({ + schema: r[0], + table: r[1], + liveTuples: r[2], + deadTuples: r[3], + bloatPct: r[4], + totalSize: r[5], + lastVacuum: r[6], + lastAutovacuum: r[7], + lastAnalyze: r[8], + lastAutoanalyze: r[9], + })), + ); + } + setLastRefresh(new Date()); + } catch (e) { + console.error("Performance monitor refresh failed:", e); + } finally { + setIsLoading(false); + } + }, [projectId, details]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (autoRefresh) { + intervalRef.current = setInterval(() => void refresh(), 5000); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [autoRefresh, refresh]); + + const projectHistory = historyEntries.filter((e) => e.projectId === projectId); + const avgTime = + projectHistory.length > 0 + ? projectHistory.reduce((sum, e) => sum + e.executionTime, 0) / projectHistory.length + : 0; + const failedQueries = projectHistory.filter((e) => !e.success).length; + const slowQueries = [...projectHistory] + .sort((a, b) => b.executionTime - a.executionTime) + .slice(0, 10); + + const unusedIndexCount = indexUsage.filter((i) => i.status === "unused").length; + const tablesNeedingVacuum = bloat.filter((b) => parseFloat(b.bloatPct) > 10); + const waitingLocks = locks.filter((l) => l.status === "waiting"); + + const tabs: { id: MonitorTab; label: string; icon: React.ReactNode }[] = [ + { id: "overview", label: "Overview", icon: }, + { id: "activity", label: "Activity", icon: }, + { id: "tables", label: "Table Stats", icon: }, + { id: "history", label: "Query History", icon: }, + { id: "locks", label: "Locks", icon: }, + { id: "indexes", label: "Index Advisor", icon: }, + { id: "bloat", label: "Bloat", icon: }, + ]; + + return ( +
+ {/* Header */} +
+
+ + Performance Monitor + + {details?.database ?? projectId} + +
+
+ {lastRefresh && ( + + Refreshed at {lastRefresh.toLocaleTimeString()} + + )} + + +
+
+ + {/* Tab bar */} +
+ {tabs.map((t) => ( + + ))} +
+ + {/* Content */} +
+ {tab === "overview" && ( + + )} + + {tab === "activity" && } + + {tab === "tables" && } + + {tab === "history" && } + + {tab === "locks" && } + + {tab === "indexes" && ( + + )} + + {tab === "bloat" && } +
+
+ ); +} diff --git a/src/components/performance-monitor/indexes-tab.tsx b/src/components/performance-monitor/indexes-tab.tsx new file mode 100644 index 0000000..4ef0a82 --- /dev/null +++ b/src/components/performance-monitor/indexes-tab.tsx @@ -0,0 +1,89 @@ +import { cn } from "@/lib/utils"; +import type { IndexUsageRow } from "./types"; + +interface IndexesTabProps { + indexUsage: IndexUsageRow[]; + unusedIndexCount: number; +} + +export function IndexesTab({ indexUsage, unusedIndexCount }: IndexesTabProps) { + return ( +
+ {unusedIndexCount > 0 && ( +
+ + {unusedIndexCount} unused index{unusedIndexCount !== 1 ? "es" : ""} found -- consider + removing to save space and improve write performance + +
+ )} +
+
+
+ + + {["Schema", "Table", "Index", "Size", "Scans", "Status", "Definition"].map((h) => ( + + ))} + + + + {indexUsage.map((row, idx) => ( + + + + + + + + + + ))} + {indexUsage.length === 0 && ( + + + + )} + +
+ {h} +
+ {row.schema} + {row.table}{row.index}{row.size} + {parseInt(row.scans, 10).toLocaleString()} + + + {row.status === "rarely_used" ? "rarely used" : row.status} + + + {row.definition} +
+ No non-primary indexes found +
+
+
+
+ ); +} diff --git a/src/components/performance-monitor/locks-tab.tsx b/src/components/performance-monitor/locks-tab.tsx new file mode 100644 index 0000000..b003594 --- /dev/null +++ b/src/components/performance-monitor/locks-tab.tsx @@ -0,0 +1,99 @@ +import { cn } from "@/lib/utils"; +import type { LockRow } from "./types"; + +interface LocksTabProps { + locks: LockRow[]; + waitingLocks: LockRow[]; +} + +export function LocksTab({ locks, waitingLocks }: LocksTabProps) { + return ( +
+ {waitingLocks.length > 0 && ( +
+ + {waitingLocks.length} lock{waitingLocks.length !== 1 ? "s" : ""} waiting to be granted + +
+ )} +
+
+ + + + {[ + "PID", + "User", + "Mode", + "Lock Type", + "Status", + "Relation", + "Duration", + "Query", + ].map((h) => ( + + ))} + + + + {locks.map((row, idx) => ( + + + + + + + + + + + ))} + {locks.length === 0 && ( + + + + )} + +
+ {h} +
{row.pid}{row.user}{row.mode} + {row.locktype} + + + {row.status} + + {row.relation || "-"} + {parseFloat(row.duration || "0").toFixed(1)}s + + {row.query} +
+ No active locks +
+
+
+
+ ); +} diff --git a/src/components/performance-monitor/overview-tab.tsx b/src/components/performance-monitor/overview-tab.tsx new file mode 100644 index 0000000..fcb9796 --- /dev/null +++ b/src/components/performance-monitor/overview-tab.tsx @@ -0,0 +1,99 @@ +import { Database, HardDrive, Users, Zap } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { HistoryEntry } from "@/stores/history-store"; + +interface OverviewTabProps { + dbStats: [string, string][]; + projectHistory: HistoryEntry[]; + avgTime: number; + failedQueries: number; +} + +export function OverviewTab({ dbStats, projectHistory, avgTime, failedQueries }: OverviewTabProps) { + const statValue = (name: string) => dbStats.find(([n]) => n === name)?.[1] ?? "N/A"; + + return ( +
+ {/* Stat cards */} +
+ } + label="Active Connections" + value={statValue("Active Connections")} + /> + } + label="Database Size" + value={statValue("Database Size")} + /> + } + label="Cache Hit Ratio" + value={statValue("Cache Hit Ratio")} + /> + } + label="Deadlocks" + value={statValue("Deadlocks")} + /> +
+ + {/* All stats table */} +
+
+ Database Statistics +
+
+ {dbStats.map(([name, val]) => ( +
+ {name} + {val} +
+ ))} + {dbStats.length === 0 && ( +
+ No stats available +
+ )} +
+
+ + {/* Session history summary */} +
+
+ Session Query Summary +
+
+
+
{projectHistory.length}
+
Total Queries
+
+
+
{avgTime.toFixed(1)}ms
+
Avg Execution Time
+
+
+
0 && "text-destructive")} + > + {failedQueries} +
+
Failed Queries
+
+
+
+
+ ); +} + +function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} diff --git a/src/components/performance-monitor/table-stats-tab.tsx b/src/components/performance-monitor/table-stats-tab.tsx new file mode 100644 index 0000000..a15353d --- /dev/null +++ b/src/components/performance-monitor/table-stats-tab.tsx @@ -0,0 +1,111 @@ +import { cn } from "@/lib/utils"; +import type { TableStatRow } from "./types"; + +interface TableStatsTabProps { + tableStats: TableStatRow[]; +} + +export function TableStatsTab({ tableStats }: TableStatsTabProps) { + return ( +
+

+ Cumulative stats since server start or last pg_stat_reset(). Source: pg_stat_user_tables +

+
+
+ + + + {[ + "Schema", + "Table", + "Seq Scan", + "Idx Scan", + "Live Tuples", + "Dead Tuples", + "Inserts", + "Updates", + "Deletes", + "Last Vacuum", + "Last Analyze", + ].map((h) => ( + + ))} + + + + {tableStats.map((row) => { + const deadRatio = + parseInt(row.liveTuples, 10) > 0 + ? (parseInt(row.deadTuples, 10) / parseInt(row.liveTuples, 10)) * 100 + : 0; + return ( + + + + + + + + + + + + + + ); + })} + {tableStats.length === 0 && ( + + + + )} + +
+ {h} +
+ {row.schema} + {row.table} + {parseInt(row.seqScan, 10).toLocaleString()} + + {parseInt(row.idxScan, 10).toLocaleString()} + + {parseInt(row.liveTuples, 10).toLocaleString()} + 10 && "text-destructive font-medium", + )} + > + {parseInt(row.deadTuples, 10).toLocaleString()} + {deadRatio > 10 && ( + ({deadRatio.toFixed(0)}%) + )} + + {parseInt(row.inserts, 10).toLocaleString()} + + {parseInt(row.updates, 10).toLocaleString()} + + {parseInt(row.deletes, 10).toLocaleString()} + + {row.lastVacuum === "never" + ? "never" + : new Date(row.lastVacuum).toLocaleDateString()} + + {row.lastAnalyze === "never" + ? "never" + : new Date(row.lastAnalyze).toLocaleDateString()} +
+ No table stats available +
+
+
+
+ ); +} diff --git a/src/components/performance-monitor/types.ts b/src/components/performance-monitor/types.ts new file mode 100644 index 0000000..3f24139 --- /dev/null +++ b/src/components/performance-monitor/types.ts @@ -0,0 +1,76 @@ +export interface ActivityRow { + pid: string; + user: string; + database: string; + state: string; + waitEventType: string; + waitEvent: string; + query: string; + durationSec: string; + backendType: string; + clientAddr: string; +} + +export interface TableStatRow { + schema: string; + table: string; + seqScan: string; + seqTupRead: string; + idxScan: string; + idxTupFetch: string; + inserts: string; + updates: string; + deletes: string; + liveTuples: string; + deadTuples: string; + lastVacuum: string; + lastAutovacuum: string; + lastAnalyze: string; +} + +export interface LockRow { + pid: string; + user: string; + mode: string; + locktype: string; + status: string; + relation: string; + schema: string; + query: string; + duration: string; + waitEvent: string; +} + +export interface IndexUsageRow { + schema: string; + table: string; + index: string; + size: string; + scans: string; + tuplesRead: string; + tuplesFetched: string; + status: string; + definition: string; +} + +export interface BloatRow { + schema: string; + table: string; + liveTuples: string; + deadTuples: string; + bloatPct: string; + totalSize: string; + lastVacuum: string; + lastAutovacuum: string; + lastAnalyze: string; + lastAutoanalyze: string; +} + +export type MonitorTab = + | "overview" + | "activity" + | "tables" + | "history" + | "locks" + | "indexes" + | "bloat"; diff --git a/src/components/pg-settings-panel.tsx b/src/components/pg-settings-panel.tsx index 7ef5bb3..086fec8 100644 --- a/src/components/pg-settings-panel.tsx +++ b/src/components/pg-settings-panel.tsx @@ -1,10 +1,10 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; -import { DriverFactory } from "@/lib/database-driver"; -import { useProjectStore } from "@/stores/project-store"; -import { cn } from "@/lib/utils"; -import { Settings, Loader2, RefreshCw } from "lucide-react"; +import { Loader2, RefreshCw, Settings } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { DriverFactory } from "@/lib/database-driver"; +import { cn } from "@/lib/utils"; +import { useProjectStore } from "@/stores/project-store"; interface PgSetting { name: string; @@ -35,17 +35,28 @@ export function PgSettingsPanel({ projectId }: { projectId: string }) { const driver = DriverFactory.getDriver(details.driver); const result = await driver.loadPgSettings?.(projectId); if (result) { - setSettings(result.map((r) => ({ - name: r[0], setting: r[1], unit: r[2], category: r[3], - description: r[4], context: r[5], source: r[6], bootVal: r[7], resetVal: r[8], - }))); + setSettings( + result.map((r) => ({ + name: r[0], + setting: r[1], + unit: r[2], + category: r[3], + description: r[4], + context: r[5], + source: r[6], + bootVal: r[7], + resetVal: r[8], + })), + ); } } finally { setIsLoading(false); } }, [projectId, details]); - useEffect(() => { void refresh(); }, [refresh]); + useEffect(() => { + void refresh(); + }, [refresh]); const categories = useMemo(() => { const cats = new Set(settings.map((s) => s.category)); @@ -56,24 +67,33 @@ export function PgSettingsPanel({ projectId }: { projectId: string }) { const filtered = settings.filter((s) => { if (categoryFilter && s.category !== categoryFilter) return false; if (contextFilter && s.context !== contextFilter) return false; - if (lowerFilter && !s.name.toLowerCase().includes(lowerFilter) && !s.description.toLowerCase().includes(lowerFilter)) return false; + if ( + lowerFilter && + !s.name.toLowerCase().includes(lowerFilter) && + !s.description.toLowerCase().includes(lowerFilter) + ) + return false; return true; }); - // Group by category const grouped = new Map(); for (const s of filtered) { if (!grouped.has(s.category)) grouped.set(s.category, []); - grouped.get(s.category)!.push(s); + grouped.get(s.category)?.push(s); } const contextColor = (ctx: string) => { switch (ctx) { - case "user": return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"; - case "superuser": return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"; - case "postmaster": return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"; - case "sighup": return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"; - default: return "bg-muted text-muted-foreground"; + case "user": + return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"; + case "superuser": + return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"; + case "postmaster": + return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"; + case "sighup": + return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"; + default: + return "bg-muted text-muted-foreground"; } }; @@ -83,12 +103,26 @@ export function PgSettingsPanel({ projectId }: { projectId: string }) {
PostgreSQL Settings - {details?.database ?? projectId} + + {details?.database ?? projectId} +
- {filtered.length}/{settings.length} -
@@ -106,7 +140,11 @@ export function PgSettingsPanel({ projectId }: { projectId: string }) { className="h-7 rounded-md border bg-input/50 px-2 font-mono text-xs text-foreground" > - {categories.map((c) => )} + {categories.map((c) => ( + + ))} setSearchTerm(e.target.value)} - className="h-7 w-48 rounded border border-border bg-input pl-7 pr-7 text-xs font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" - /> - {searchTerm && ( - - )} -
- )} - - )} - - - ); -} - -function DiffView({ - pinnedColumns, - pinnedRows, - currentColumns, - currentRows, -}: { - pinnedColumns: string[]; - pinnedRows: string[][]; - currentColumns: string[]; - currentRows: string[][]; -}) { - const [diffResult, setDiffResult] = useState<{ - added: string[][]; - removed: string[][]; - unchangedCount: number; - } | null>(null); - const [computing, setComputing] = useState(false); - - const colsMatch = - pinnedColumns.length === currentColumns.length && pinnedColumns.every((c, i) => c === currentColumns[i]); - - // Compute diff in Rust backend for performance - const prevKeyRef = useRef(""); - const diffKey = `${pinnedRows.length}:${currentRows.length}`; - if (diffKey !== prevKeyRef.current && colsMatch) { - prevKeyRef.current = diffKey; - setComputing(true); - setDiffResult(null); - - // Pack rows into the compact wire format for Rust - const CELL_SEP = "\x1F"; - const ROW_SEP = "\x1E"; - const packRows = (columns: string[], rows: string[][]) => { - const header = columns.join(CELL_SEP); - if (rows.length === 0) return header; - return header + ROW_SEP + rows.map((r) => r.join(CELL_SEP)).join(ROW_SEP); - }; - - const pinnedPacked = packRows(pinnedColumns, pinnedRows); - const currentPacked = packRows(currentColumns, currentRows); - - invoke<[string, string, number]>("compute_diff", { - pinned_packed: pinnedPacked, - current_packed: currentPacked, - }).then(([addedPacked, removedPacked, unchangedCount]) => { - const unpackRows = (packed: string): string[][] => { - if (!packed) return []; - const parts = packed.split(ROW_SEP); - // Skip header (index 0) - return parts.slice(1).map((r) => r.split(CELL_SEP)); - }; - setDiffResult({ - added: unpackRows(addedPacked), - removed: unpackRows(removedPacked), - unchangedCount, - }); - setComputing(false); - }).catch(() => { - // Fallback: compute in JS if Rust command fails - const pinnedSet = new Set(pinnedRows.map((r) => r.join(CELL_SEP))); - const currentSet = new Set(currentRows.map((r) => r.join(CELL_SEP))); - setDiffResult({ - added: currentRows.filter((r) => !pinnedSet.has(r.join(CELL_SEP))), - removed: pinnedRows.filter((r) => !currentSet.has(r.join(CELL_SEP))), - unchangedCount: currentRows.filter((r) => pinnedSet.has(r.join(CELL_SEP))).length, - }); - setComputing(false); - }); - } - - if (!colsMatch) { - return ( -
- -
Column structures differ
-
Pinned: {pinnedColumns.join(", ")}
-
Current: {currentColumns.join(", ")}
-
- ); - } - - if (computing || !diffResult) { - return ( -
- - Computing diff... -
- ); - } - - const { added, removed, unchangedCount } = diffResult; - - return ( -
-
- - +{added.length} added - - - -{removed.length} removed - - ={unchangedCount} unchanged -
- - - - - - ))} - - - - {removed.map((row, i) => ( - - - {row.map((cell, j) => ( - - ))} - - ))} - {added.map((row, i) => ( - - - {row.map((cell, j) => ( - - ))} - - ))} - -
- {pinnedColumns.map((col) => ( - - {col} -
- - {cell} -
+ - {cell} -
-
- ); -} diff --git a/src/components/results-panel/constants.ts b/src/components/results-panel/constants.ts new file mode 100644 index 0000000..de8d9af --- /dev/null +++ b/src/components/results-panel/constants.ts @@ -0,0 +1,5 @@ +export const CELL_SEP = "\x1F"; +export const ROW_SEP = "\x1E"; +export const MAX_CONCURRENT_PAGE_FETCHES = 6; +export const MAX_QUEUED_PAGE_FETCHES = 32; +export const CACHE_WINDOW_PAGES = 24; diff --git a/src/components/results-panel/diff-view.tsx b/src/components/results-panel/diff-view.tsx new file mode 100644 index 0000000..ed8decc --- /dev/null +++ b/src/components/results-panel/diff-view.tsx @@ -0,0 +1,153 @@ +import { invoke } from "@tauri-apps/api/core"; +import { Diff, Loader2 } from "lucide-react"; +import { useRef, useState } from "react"; + +export function DiffView({ + pinnedColumns, + pinnedRows, + currentColumns, + currentRows, +}: { + pinnedColumns: string[]; + pinnedRows: string[][]; + currentColumns: string[]; + currentRows: string[][]; +}) { + const [diffResult, setDiffResult] = useState<{ + added: string[][]; + removed: string[][]; + unchangedCount: number; + } | null>(null); + const [computing, setComputing] = useState(false); + + const colsMatch = + pinnedColumns.length === currentColumns.length && + pinnedColumns.every((c, i) => c === currentColumns[i]); + + // Compute diff in Rust backend for performance + const prevKeyRef = useRef(""); + const diffKey = `${pinnedRows.length}:${currentRows.length}`; + if (diffKey !== prevKeyRef.current && colsMatch) { + prevKeyRef.current = diffKey; + setComputing(true); + setDiffResult(null); + + // Pack rows into the compact wire format for Rust + const CELL_SEP = "\x1F"; + const ROW_SEP = "\x1E"; + const packRows = (columns: string[], rows: string[][]) => { + const header = columns.join(CELL_SEP); + if (rows.length === 0) return header; + return header + ROW_SEP + rows.map((r) => r.join(CELL_SEP)).join(ROW_SEP); + }; + + const pinnedPacked = packRows(pinnedColumns, pinnedRows); + const currentPacked = packRows(currentColumns, currentRows); + + invoke<[string, string, number]>("compute_diff", { + pinned_packed: pinnedPacked, + current_packed: currentPacked, + }) + .then(([addedPacked, removedPacked, unchangedCount]) => { + const unpackRows = (packed: string): string[][] => { + if (!packed) return []; + const parts = packed.split(ROW_SEP); + // Skip header (index 0) + return parts.slice(1).map((r) => r.split(CELL_SEP)); + }; + setDiffResult({ + added: unpackRows(addedPacked), + removed: unpackRows(removedPacked), + unchangedCount, + }); + setComputing(false); + }) + .catch(() => { + // Fallback: compute in JS if Rust command fails + const pinnedSet = new Set(pinnedRows.map((r) => r.join(CELL_SEP))); + const currentSet = new Set(currentRows.map((r) => r.join(CELL_SEP))); + setDiffResult({ + added: currentRows.filter((r) => !pinnedSet.has(r.join(CELL_SEP))), + removed: pinnedRows.filter((r) => !currentSet.has(r.join(CELL_SEP))), + unchangedCount: currentRows.filter((r) => pinnedSet.has(r.join(CELL_SEP))).length, + }); + setComputing(false); + }); + } + + if (!colsMatch) { + return ( +
+ +
Column structures differ
+
Pinned: {pinnedColumns.join(", ")}
+
Current: {currentColumns.join(", ")}
+
+ ); + } + + if (computing || !diffResult) { + return ( +
+ + Computing diff... +
+ ); + } + + const { added, removed, unchangedCount } = diffResult; + + return ( +
+
+ + +{added.length} added + + + -{removed.length} removed + + + ={unchangedCount} unchanged + +
+ + + + + + ))} + + + + {removed.map((row, i) => ( + + + {row.map((cell, j) => ( + + ))} + + ))} + {added.map((row, i) => ( + + + {row.map((cell, j) => ( + + ))} + + ))} + +
+ {pinnedColumns.map((col) => ( + + {col} +
- + {cell} +
+ + {cell} +
+
+ ); +} diff --git a/src/components/results-panel/index.tsx b/src/components/results-panel/index.tsx new file mode 100644 index 0000000..e8d0d56 --- /dev/null +++ b/src/components/results-panel/index.tsx @@ -0,0 +1,265 @@ +import { Loader2, X, XCircle } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { DriverFactory } from "@/lib/database-driver"; +import { useProjectStore } from "@/stores/project-store"; +import { useActiveTab } from "@/stores/tab-store"; +import { useUIStore } from "@/stores/ui-store"; +import { ExplainPanel } from "../explain-panel"; +import { QueryHistory } from "../query-history"; +import { ResultsGrid } from "../results-grid"; +import { ResultsMap } from "../results-map"; +import { ResultsRecord } from "../results-record"; +import { DiffView } from "./diff-view"; +import { ResultsToolbar } from "./toolbar"; +import type { PanelView } from "./types"; +import { useEditMode } from "./use-edit-mode"; +import { useVirtualPaging } from "./use-virtual-paging"; + +export function ResultsPanel() { + const activeTab = useActiveTab(); + const viewMode = useUIStore((s) => s.viewMode); + const setViewMode = useUIStore((s) => s.setViewMode); + const pinnedResult = useUIStore((s) => s.pinnedResult); + const [panelView, setPanelView] = useState("grid"); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + // Debounce search term — avoids filtering 50K rows on every keystroke + useEffect(() => { + if (!searchTerm.trim()) { + setDebouncedSearch(""); + return; + } + const timer = setTimeout(() => setDebouncedSearch(searchTerm), 200); + return () => clearTimeout(timer); + }, [searchTerm]); + + const result = activeTab?.result; + const isExecuting = activeTab?.isExecuting; + const vq = activeTab?.virtualQuery; + + const handleCancel = useCallback(async () => { + if (!activeTab?.projectId || !activeTab.isExecuting) return; + const d = useProjectStore.getState().projects[activeTab.projectId]; + if (!d) return; + try { + const driver = DriverFactory.getDriver(d.driver); + await driver.cancelQuery?.(activeTab.projectId); + } catch (err) { + console.error("Failed to cancel query:", err); + } + }, [activeTab?.projectId, activeTab?.isExecuting]); + + const { gridRef, handlePageNeeded, handleViewportRowChange, restoreRowIndex } = useVirtualPaging({ + vq, + projectId: activeTab?.projectId, + }); + + const { + isEditing, + editState, + editError, + setEditError, + isCommitting, + pendingDeleteCount, + editableTable, + fkMap, + handleFKNavigate, + handleEnterEdit, + handleDiscard, + handleCommit, + handleDeleteRows, + handleConfirmDelete, + handleCancelDelete, + handleCellEdit, + handleRowDelete, + handleRowRestore, + } = useEditMode({ + projectId: activeTab?.projectId, + editorValue: activeTab?.editorValue, + result, + }); + + const filteredRows = useMemo(() => { + if (isEditing) return result?.rows ?? []; + if (!result || !debouncedSearch.trim()) return result?.rows ?? []; + const term = debouncedSearch.toLowerCase(); + return result.rows.filter((row) => row.some((cell) => cell.toLowerCase().includes(term))); + }, [result, debouncedSearch, isEditing]); + + const explainResult = activeTab?.explainResult; + const hasExplain = !!explainResult; + + const toolbarProps = { + panelView, + setPanelView, + searchTerm, + setSearchTerm, + setViewMode, + viewMode, + hasExplain, + isExecuting: !!isExecuting, + isEditing, + editState, + editableTable: !!editableTable && !vq, + isCommitting, + editError, + onEnterEdit: handleEnterEdit, + onCommit: handleCommit, + onDeleteRows: handleDeleteRows, + onConfirmDelete: handleConfirmDelete, + onCancelDelete: handleCancelDelete, + pendingDeleteCount, + onDiscard: handleDiscard, + onCancel: handleCancel, + virtualQuery: vq, + }; + + if (panelView === "explain" && hasExplain) { + return ( +
+ + +
+ ); + } + + if (panelView !== "history" && isExecuting && !result) { + return ( +
+ +
+ + Executing query... +
+
+ ); + } + + if (panelView === "history") { + return ( +
+ + +
+ ); + } + + if (panelView === "diff" && pinnedResult && result) { + return ( +
+ + +
+ ); + } + + if (panelView === "map" && result) { + return ( +
+ + +
+ ); + } + + if (!result) { + return ( +
+ +
+ No data to display +
+
+ ); + } + + return ( +
+ + {editError && !isEditing && ( +
+ + {editError} + +
+ )} + {viewMode === "grid" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/components/results-panel/toolbar-edit.tsx b/src/components/results-panel/toolbar-edit.tsx new file mode 100644 index 0000000..6fea165 --- /dev/null +++ b/src/components/results-panel/toolbar-edit.tsx @@ -0,0 +1,103 @@ +import { Loader2, Save, Trash2, X } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import type { EditState } from "./types"; + +interface ToolbarEditProps { + editState: EditState | null; + editError: string | null; + isCommitting: boolean; + pendingDeleteCount: number; + onCommit: () => void; + onDeleteRows: () => void; + onConfirmDelete: () => void; + onCancelDelete: () => void; + onDiscard: () => void; +} + +export function ToolbarEdit({ + editState, + editError, + isCommitting, + pendingDeleteCount, + onCommit, + onDeleteRows, + onConfirmDelete, + onCancelDelete, + onDiscard, +}: ToolbarEditProps) { + return ( + <> + {editError && ( + + {editError} + + )} + + + 0} + onOpenChange={(open) => { + if (!open) onCancelDelete(); + }} + > + + + Delete rows + + Are you sure you want to permanently delete {pendingDeleteCount} row + {pendingDeleteCount !== 1 ? "s" : ""}? This action cannot be undone. + + + + + + + + + + + ); +} diff --git a/src/components/results-panel/toolbar-export.tsx b/src/components/results-panel/toolbar-export.tsx new file mode 100644 index 0000000..1fbb05b --- /dev/null +++ b/src/components/results-panel/toolbar-export.tsx @@ -0,0 +1,95 @@ +import { Copy, Download } from "lucide-react"; +import { useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { copyToClipboard, type ExportFormat, exportResults } from "@/lib/export"; + +interface ToolbarExportProps { + columns: string[]; + filteredRows: string[][]; + hasResult: boolean; +} + +export function ToolbarExport({ columns, filteredRows, hasResult }: ToolbarExportProps) { + const [exportOpen, setExportOpen] = useState(false); + const exportRef = useRef(null); + + const handleExport = (format: ExportFormat) => { + if (!hasResult) return; + exportResults(format, columns, filteredRows); + setExportOpen(false); + }; + + const handleCopy = (format: ExportFormat) => { + if (!hasResult) return; + void copyToClipboard(format, columns, filteredRows); + setExportOpen(false); + }; + + return ( +
+ + {exportOpen && + createPortal( + <> +
setExportOpen(false)} + /> +
{ + const r = exportRef.current?.getBoundingClientRect(); + return r ? r.bottom + 4 : 0; + })(), + left: (() => { + const r = exportRef.current?.getBoundingClientRect(); + return r ? Math.max(0, r.right - 208) : 0; + })(), + }} + > +
+ Download +
+ {(["csv", "json", "sql", "markdown", "xml"] as ExportFormat[]).map((fmt) => ( + + ))} +
+
+ Copy to clipboard +
+ {(["csv", "json", "sql", "markdown"] as ExportFormat[]).map((fmt) => ( + + ))} +
+ , + document.body, + )} +
+ ); +} diff --git a/src/components/results-panel/toolbar.tsx b/src/components/results-panel/toolbar.tsx new file mode 100644 index 0000000..797394d --- /dev/null +++ b/src/components/results-panel/toolbar.tsx @@ -0,0 +1,292 @@ +import { + CheckCircle2, + Clock, + Diff, + Edit3, + GitBranch, + History, + Loader2, + Pin, + Search, + Square, + X, +} from "lucide-react"; +import { useUIStore } from "@/stores/ui-store"; +import { hasGeometryColumn } from "../results-map"; +import { ToolbarEdit } from "./toolbar-edit"; +import { ToolbarExport } from "./toolbar-export"; +import type { ToolbarProps } from "./types"; + +export function ResultsToolbar(props: ToolbarProps) { + const { + panelView, + setPanelView, + result, + columns, + filteredRows, + searchTerm, + setSearchTerm, + filteredCount, + setViewMode, + viewMode, + hasExplain, + isExecuting, + isEditing, + editState, + editableTable, + isCommitting, + editError, + onEnterEdit, + onCommit, + onDeleteRows, + onConfirmDelete, + onCancelDelete, + pendingDeleteCount, + onDiscard, + onCancel, + virtualQuery, + } = props; + + const pinnedResult = useUIStore((s) => s.pinnedResult); + const pinResult = useUIStore((s) => s.pinResult); + const clearPinnedResult = useUIStore((s) => s.clearPinnedResult); + + return ( +
+
+ {/* Panel tabs — segment control */} +
+ + + {hasExplain && ( + + )} + + {result && hasGeometryColumn(columns, filteredRows) && ( + + )} +
+ + {/* Result stats */} + {panelView !== "history" && result && ( +
+ {isExecuting ? ( + + ) : ( + + )} + + {virtualQuery + ? `${virtualQuery.totalRows.toLocaleString()} rows (virtual)` + : searchTerm + ? `${filteredCount.toLocaleString()} / ${result.rows.length.toLocaleString()} rows` + : `${result.rows.length.toLocaleString()} rows`} + {result.capped && !virtualQuery && ( + (capped at 500K) + )} + + + + {result.time.toFixed(0)}ms + {isEditing && editState?.cellEdits.size ? ( + <> + + + {editState.cellEdits.size} edit{editState.cellEdits.size !== 1 ? "s" : ""} + + + ) : null} + {isEditing && editState?.deletedRows.size ? ( + <> + + + {editState.deletedRows.size} delete{editState.deletedRows.size !== 1 ? "s" : ""} + + + ) : null} +
+ )} + + {/* Stop button — visible while executing */} + {isExecuting && onCancel && ( + + )} +
+ +
+ {/* Edit mode controls */} + {isEditing ? ( + + ) : ( + <> + {/* Edit button */} + {panelView !== "history" && editableTable && result && result.rows.length > 0 && ( + + )} + + {/* Pin / Diff */} + {panelView !== "history" && result && result.rows.length > 0 && !virtualQuery && ( + <> + {pinnedResult ? ( +
+ + Pinned: {pinnedResult.label} + +
+ ) : ( + + )} + {pinnedResult && ( + + )} + + )} + + {/* Export dropdown */} + {panelView !== "history" && result && result.rows.length > 0 && !virtualQuery && ( + + )} + + {/* Search */} + {panelView !== "history" && result && !virtualQuery && ( +
+ + setSearchTerm(e.target.value)} + className="h-7 w-48 rounded border border-border bg-input pl-7 pr-7 text-xs font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + {searchTerm && ( + + )} +
+ )} + + )} +
+
+ ); +} diff --git a/src/components/results-panel/types.ts b/src/components/results-panel/types.ts new file mode 100644 index 0000000..03d2c10 --- /dev/null +++ b/src/components/results-panel/types.ts @@ -0,0 +1,38 @@ +export type PanelView = "grid" | "record" | "history" | "explain" | "diff" | "map"; + +export interface EditState { + schema: string; + table: string; + pkColumns: string[]; + cellEdits: Map; + deletedRows: Set; +} + +export interface ToolbarProps { + panelView: PanelView; + setPanelView: (v: PanelView) => void; + result: { rows: string[][]; time: number; capped?: boolean } | null; + columns: string[]; + filteredRows: string[][]; + searchTerm: string; + setSearchTerm: (v: string) => void; + filteredCount: number; + setViewMode: (mode: "grid" | "record") => void; + viewMode: "grid" | "record"; + hasExplain: boolean; + isExecuting: boolean; + isEditing: boolean; + editState: EditState | null; + editableTable: boolean; + isCommitting: boolean; + editError: string | null; + onEnterEdit: () => void; + onCommit: () => void; + onDeleteRows: () => void; + onConfirmDelete: () => void; + onCancelDelete: () => void; + pendingDeleteCount: number; + onDiscard: () => void; + onCancel?: () => void; + virtualQuery?: { queryId: string; totalRows: number; time: number; pageSize: number }; +} diff --git a/src/components/results-panel/use-edit-mode.ts b/src/components/results-panel/use-edit-mode.ts new file mode 100644 index 0000000..223f6c8 --- /dev/null +++ b/src/components/results-panel/use-edit-mode.ts @@ -0,0 +1,282 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ForeignKey } from "@/lib/database-driver"; +import { DriverFactory } from "@/lib/database-driver"; +import { + generateDelete, + generateUpdate, + parseSelectTable, + quoteIdent, + quoteLiteral, +} from "@/lib/sql-utils"; +import { useProjectStore } from "@/stores/project-store"; +import { useTabStore } from "@/stores/tab-store"; +import type { EditState } from "./types"; + +interface UseEditModeArgs { + projectId: string | undefined; + editorValue: string | undefined; + result: + | { columns: string[]; rows: string[][]; time: number; capped?: boolean } + | null + | undefined; +} + +export function useEditMode({ projectId, editorValue, result }: UseEditModeArgs) { + const [isEditing, setIsEditing] = useState(false); + const [editState, setEditState] = useState(null); + const [editError, setEditError] = useState(null); + const [isCommitting, setIsCommitting] = useState(false); + const [pendingDeleteCount, setPendingDeleteCount] = useState(0); + + const editableTable = useMemo(() => { + if (!editorValue) return null; + return parseSelectTable(editorValue); + }, [editorValue]); + + const [fkMap, setFkMap] = useState< + Map + >(new Map()); + + useEffect(() => { + if (!editableTable || !projectId) { + setFkMap(new Map()); + return; + } + const pid = projectId; + const d = useProjectStore.getState().projects[pid]; + if (!d) return; + + const driver = DriverFactory.getDriver(d.driver); + driver + .loadForeignKeys(pid, editableTable.schema) + .then((fks: ForeignKey[]) => { + const map = new Map(); + for (const fk of fks) { + if (fk.sourceTable === editableTable.table) { + map.set(fk.sourceColumn, { + schema: editableTable.schema, + table: fk.targetTable, + column: fk.targetColumn, + }); + } + } + setFkMap(map); + }) + .catch(() => setFkMap(new Map())); + }, [editableTable, projectId]); + + const handleFKNavigate = useCallback( + (colName: string, value: string) => { + const target = fkMap.get(colName); + if (!target || !projectId) return; + + const pid = projectId; + const sql = `SELECT * FROM ${quoteIdent(target.schema)}.${quoteIdent(target.table)} WHERE ${quoteIdent(target.column)} = ${quoteLiteral(value)} LIMIT 100`; + useTabStore.getState().openTab(pid, sql); + + const d = useProjectStore.getState().projects[pid]; + if (!d) return; + const newTabIdx = useTabStore.getState().tabs.length - 1; + useTabStore.getState().setExecuting(newTabIdx, true); + const driver = DriverFactory.getDriver(d.driver); + driver + .runQuery(pid, sql) + .then(([cols, rows, time]) => { + useTabStore.getState().updateResult(newTabIdx, { columns: cols, rows, time }); + }) + .catch(() => { + useTabStore.getState().setExecuting(newTabIdx, false); + }); + }, + [fkMap, projectId], + ); + + const handleEnterEdit = useCallback(async () => { + if (!editableTable || !projectId) return; + const d = useProjectStore.getState().projects[projectId]; + if (!d) return; + setEditError(null); + + try { + const driver = DriverFactory.getDriver(d.driver); + const indexes = await driver.loadIndexes( + projectId, + editableTable.schema, + editableTable.table, + ); + const pkColumns = [...new Set(indexes.filter((i) => i.isPrimary).map((i) => i.columnName))]; + + if (pkColumns.length === 0) { + setEditError("No primary key found. Inline editing requires a primary key."); + return; + } + + const resultCols = result?.columns ?? []; + const missingPKs = pkColumns.filter((pk) => !resultCols.includes(pk)); + if (missingPKs.length > 0) { + setEditError( + `Primary key column(s) ${missingPKs.join(", ")} not in query results. Select all PK columns to edit.`, + ); + return; + } + + setEditState({ + schema: editableTable.schema, + table: editableTable.table, + pkColumns, + cellEdits: new Map(), + deletedRows: new Set(), + }); + setIsEditing(true); + } catch (err: any) { + setEditError(err?.message ?? "Failed to load table info"); + } + }, [editableTable, projectId, result?.columns]); + + const handleDiscard = useCallback(() => { + setIsEditing(false); + setEditState(null); + setEditError(null); + }, []); + + const runAndRefresh = useCallback( + async (statements: string[]) => { + if (!projectId || statements.length === 0) return; + setIsCommitting(true); + setEditError(null); + + try { + const d = useProjectStore.getState().projects[projectId]; + if (!d) throw new Error("Project not found"); + const driver = DriverFactory.getDriver(d.driver); + + const txnSql = ["BEGIN", ...statements, "COMMIT"].join(";\n"); + await driver.runQuery(projectId, txnSql, 30000); + + const [cols, rows, time] = await driver.runQuery(projectId, editorValue ?? ""); + const tabIdx = useTabStore.getState().selectedTabIndex; + useTabStore.getState().updateResult(tabIdx, { columns: cols, rows, time }); + + setIsEditing(false); + setEditState(null); + setPendingDeleteCount(0); + } catch (err: any) { + setEditError(err?.message ?? "Commit failed"); + } finally { + setIsCommitting(false); + } + }, + [projectId, editorValue], + ); + + const handleCommit = useCallback(() => { + if (!editState || !result) return; + const { schema, table, pkColumns, cellEdits, deletedRows } = editState; + const columns = result.columns; + const originalRows = result.rows; + + const editsByRow = new Map>(); + for (const [key, value] of cellEdits) { + const [rowStr, colStr] = key.split(":"); + const rowIdx = parseInt(rowStr, 10); + const colIdx = parseInt(colStr, 10); + if (deletedRows.has(rowIdx)) continue; + if (!editsByRow.has(rowIdx)) editsByRow.set(rowIdx, new Map()); + editsByRow.get(rowIdx)?.set(colIdx, value); + } + + const statements: string[] = []; + for (const [rowIdx, changes] of editsByRow) { + statements.push( + generateUpdate(schema, table, columns, originalRows[rowIdx], changes, pkColumns), + ); + } + + if (statements.length === 0) { + handleDiscard(); + return; + } + + void runAndRefresh(statements); + }, [editState, result, handleDiscard, runAndRefresh]); + + const handleDeleteRows = useCallback(() => { + if (!editState || editState.deletedRows.size === 0) return; + setPendingDeleteCount(editState.deletedRows.size); + }, [editState]); + + const handleConfirmDelete = useCallback(() => { + if (!editState || !result) return; + const { schema, table, pkColumns, deletedRows } = editState; + const columns = result.columns; + const originalRows = result.rows; + + const statements: string[] = []; + for (const rowIdx of deletedRows) { + statements.push(generateDelete(schema, table, columns, originalRows[rowIdx], pkColumns)); + } + + setPendingDeleteCount(0); + void runAndRefresh(statements); + }, [editState, result, runAndRefresh]); + + const handleCancelDelete = useCallback(() => { + setPendingDeleteCount(0); + }, []); + + const handleCellEdit = useCallback( + (rowIndex: number, colIndex: number, value: string) => { + setEditState((prev) => { + if (!prev) return prev; + const newEdits = new Map(prev.cellEdits); + const original = result?.rows[rowIndex]?.[colIndex] ?? ""; + if (value === original) { + newEdits.delete(`${rowIndex}:${colIndex}`); + } else { + newEdits.set(`${rowIndex}:${colIndex}`, value); + } + return { ...prev, cellEdits: newEdits }; + }); + }, + [result], + ); + + const handleRowDelete = useCallback((rowIndex: number) => { + setEditState((prev) => { + if (!prev) return prev; + const newDeleted = new Set(prev.deletedRows); + newDeleted.add(rowIndex); + return { ...prev, deletedRows: newDeleted }; + }); + }, []); + + const handleRowRestore = useCallback((rowIndex: number) => { + setEditState((prev) => { + if (!prev) return prev; + const newDeleted = new Set(prev.deletedRows); + newDeleted.delete(rowIndex); + return { ...prev, deletedRows: newDeleted }; + }); + }, []); + + return { + isEditing, + editState, + editError, + setEditError, + isCommitting, + pendingDeleteCount, + editableTable, + fkMap, + handleFKNavigate, + handleEnterEdit, + handleDiscard, + handleCommit, + handleDeleteRows, + handleConfirmDelete, + handleCancelDelete, + handleCellEdit, + handleRowDelete, + handleRowRestore, + }; +} diff --git a/src/components/results-panel/use-virtual-paging.ts b/src/components/results-panel/use-virtual-paging.ts new file mode 100644 index 0000000..821e060 --- /dev/null +++ b/src/components/results-panel/use-virtual-paging.ts @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useRef } from "react"; +import { DriverFactory } from "@/lib/database-driver"; +import * as virtualCache from "@/lib/virtual-cache"; +import { useProjectStore } from "@/stores/project-store"; +import { useTabStore } from "@/stores/tab-store"; +import { + CACHE_WINDOW_PAGES, + CELL_SEP, + MAX_CONCURRENT_PAGE_FETCHES, + MAX_QUEUED_PAGE_FETCHES, + ROW_SEP, +} from "./constants"; + +interface VirtualQuery { + queryId: string; + totalRows: number; + time: number; + pageSize: number; + colCount: number; +} + +interface UseVirtualPagingArgs { + vq: VirtualQuery | undefined; + projectId: string | undefined; +} + +export function useVirtualPaging({ vq, projectId }: UseVirtualPagingArgs) { + const loadingPages = useRef(new Set()); + const queuedPages = useRef([]); + const queuedPageSet = useRef(new Set()); + const activeFetches = useRef(0); + const latestRequestedPage = useRef(0); + const gridRef = useRef<{ invalidatePage: (pageIndex: number) => void }>(null); + const virtualViewportRows = useRef(new Map()); + + useEffect(() => { + loadingPages.current.clear(); + queuedPages.current = []; + queuedPageSet.current.clear(); + activeFetches.current = 0; + }, []); + + const handleViewportRowChange = useCallback( + (rowIndex: number) => { + if (!vq?.queryId) return; + virtualViewportRows.current.set(vq.queryId, rowIndex); + }, + [vq?.queryId], + ); + + const restoreRowIndex = vq?.queryId ? (virtualViewportRows.current.get(vq.queryId) ?? 0) : 0; + + const fetchPage = useCallback( + async (pageIndex: number) => { + if (!vq || !projectId) return; + const d = useProjectStore.getState().projects[projectId]; + if (!d) return; + const driver = DriverFactory.getDriver(d.driver); + if (!driver.fetchPage) return; + + const offset = pageIndex * vq.pageSize; + const packed = await driver.fetchPage( + projectId, + vq.queryId, + vq.colCount, + offset, + vq.pageSize, + ); + + // Drop stale page responses after tab/query switches. + const selectedIdx = useTabStore.getState().selectedTabIndex; + const selectedTab = useTabStore.getState().tabs[selectedIdx]; + if (selectedTab?.virtualQuery?.queryId !== vq.queryId) return; + + const rows = packed ? packed.split(ROW_SEP).map((r) => r.split(CELL_SEP)) : []; + const expectedRows = Math.max(0, Math.min(vq.pageSize, vq.totalRows - offset)); + if (expectedRows > 0 && rows.length === 0) { + // Keep page as "missing" so viewport observer can retry instead of caching a permanent empty page. + return; + } + virtualCache.setPage(vq.queryId, pageIndex, rows); + // Evict around the user's latest viewport, not the page that happened to resolve last. + virtualCache.evictDistant(vq.queryId, latestRequestedPage.current, CACHE_WINDOW_PAGES); + gridRef.current?.invalidatePage(pageIndex); + }, + [vq, projectId], + ); + + const pumpQueue = useCallback(() => { + if (!vq || !projectId) return; + + if (queuedPages.current.length > 1) { + const target = latestRequestedPage.current; + queuedPages.current.sort((a, b) => Math.abs(a - target) - Math.abs(b - target)); + } + + while (activeFetches.current < MAX_CONCURRENT_PAGE_FETCHES && queuedPages.current.length > 0) { + const pageIndex = queuedPages.current.shift()!; + queuedPageSet.current.delete(pageIndex); + + if (loadingPages.current.has(pageIndex) || virtualCache.hasPage(vq.queryId, pageIndex)) { + continue; + } + + loadingPages.current.add(pageIndex); + activeFetches.current += 1; + + void fetchPage(pageIndex).finally(() => { + loadingPages.current.delete(pageIndex); + activeFetches.current -= 1; + pumpQueue(); + }); + } + }, [vq, projectId, fetchPage]); + + const handlePageNeeded = useCallback( + (pageIndex: number) => { + if (!vq || !projectId) return; + latestRequestedPage.current = pageIndex; + if ( + loadingPages.current.has(pageIndex) || + virtualCache.hasPage(vq.queryId, pageIndex) || + queuedPageSet.current.has(pageIndex) + ) { + return; + } + + if (queuedPages.current.length >= MAX_QUEUED_PAGE_FETCHES) { + queuedPages.current = queuedPages.current.filter((p) => Math.abs(p - pageIndex) <= 8); + queuedPageSet.current = new Set(queuedPages.current); + } + + queuedPages.current.push(pageIndex); + queuedPageSet.current.add(pageIndex); + pumpQueue(); + }, + [vq, projectId, pumpQueue], + ); + + useEffect(() => { + if (!vq) return; + const anchorPage = Math.max(0, Math.floor(restoreRowIndex / vq.pageSize)); + const startPage = Math.max(0, anchorPage - 1); + const endPage = Math.min(anchorPage + 3, Math.ceil(vq.totalRows / vq.pageSize) - 1); + for (let p = startPage; p <= endPage; p++) { + handlePageNeeded(p); + } + }, [vq?.queryId, vq?.totalRows, vq?.pageSize, restoreRowIndex, handlePageNeeded, vq]); + + return { + gridRef, + handlePageNeeded, + handleViewportRowChange, + restoreRowIndex, + }; +} diff --git a/src/components/roles-panel.tsx b/src/components/roles-panel.tsx index 8f18ec5..bfaf74d 100644 --- a/src/components/roles-panel.tsx +++ b/src/components/roles-panel.tsx @@ -1,9 +1,9 @@ -import { useState, useEffect, useCallback } from "react"; +import { Database, Key, Shield, ShieldCheck, ShieldX, User, Users } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { DriverFactory } from "@/lib/database-driver"; -import { useProjectStore } from "@/stores/project-store"; -import type { PgRole, TableGrant, DbGrant } from "@/types"; import { cn } from "@/lib/utils"; -import { Shield, ShieldCheck, ShieldX, User, Users, Key, Database } from "lucide-react"; +import { useProjectStore } from "@/stores/project-store"; +import type { DbGrant, PgRole, TableGrant } from "@/types"; interface RolesPanelProps { projectId: string; @@ -47,7 +47,9 @@ export function RolesPanel({ projectId }: RolesPanelProps) { if (loading) { return ( -
Loading roles...
+
+ Loading roles... +
); } @@ -63,10 +65,13 @@ export function RolesPanel({ projectId }: RolesPanelProps) { {roles.map((role) => ( ))} @@ -100,7 +107,11 @@ export function RolesPanel({ projectId }: RolesPanelProps) { selected.superuser ? "bg-amber-500/10" : "bg-primary/10", )} > - {selected.superuser ? : } + {selected.superuser ? ( + + ) : ( + + )}
{selected.name}
@@ -113,7 +124,9 @@ export function RolesPanel({ projectId }: RolesPanelProps) { {/* Attributes */}
-
Attributes
+
+ Attributes +
{( [ @@ -134,7 +147,11 @@ export function RolesPanel({ projectId }: RolesPanelProps) { : "bg-muted/30 text-muted-foreground/40 border border-border/20", )} > - {active ? : } + {active ? ( + + ) : ( + + )} {label} ))} @@ -144,11 +161,14 @@ export function RolesPanel({ projectId }: RolesPanelProps) { {/* Member of */} {selected.member_of.length > 0 && (
-
Member of
+
+ Member of +
{selected.member_of.map((g) => (