From 80962d7d1161f9abd127f3dbd1f202c27425aa42 Mon Sep 17 00:00:00 2001 From: "Adrian Alfred C. Bonpin" Date: Sat, 1 Aug 2026 05:56:47 +0800 Subject: [PATCH] DB viewer + query editor enhancements (home-screen-ux-query-editor) (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add query_history table migration (v5) (Task 1) * feat: add isDestructiveQuery utility (Task 2) * feat: add tabType discriminator and openQueryTab to dbViewerStore (Task 3) * feat: add execute_query command with pagination and query history (Task 4) * feat: add typed wrappers for executeQuery, getQueryHistory, clearQueryHistory (Task 5) * fix: global search bypasses folder scope when filters active (Task 6) * feat: add TagFilterDropdown with checkboxes and empty state (Task 7) * feat: add DbTypeFilterDropdown with checkboxes and clear all (Task 8) * feat: wire TagFilterDropdown/DbTypeFilterDropdown into ActionRow, add inline tag creation (Task 9) * feat: add Name input to GeneralTab for connection editing (Task 10) * feat: add QueryEditor Monaco wrapper with SQL mode and Cmd+Enter (Task 11) * feat: add DestructiveQueryDialog with SQL preview and confirmation (Task 12) * feat: integrate query tabs, Monaco editor, destructive guard into DbViewerScreen (Task 13) * fix: harden moveConnection against race conditions on rapid drags (Task 14) * docs: update AGENTS.md implementation status for Home Screen UX + Query Editor (Task 15) * feat: switch tag filter to OR semantics, add environment filter (F-T16) * feat: add activeEnvironment filter state to uiStore and useFilteredConnections (F-T17) * feat: add environment filter select to Filters dropdown (F-T18) * feat: filter folder cards by tag match or contained connections (F-T19) * fix: keep grid header width to content, border last column * fix: hide select-all checkbox and empty-state when no table open * fix: filter folder cards by any active filter, show global search results (F-T20) * feat: show 'Showing Search Results' breadcrumb with clear button (F-T21) * docs: update README + AGENTS.md for Query Editor, filters, and planned AI integration (BYOK) * feat: refresh indicator with spinning icon and pulse, defer auto-refresh on tab switch * feat: smart default schema selection, refresh schemas on database switch * fix: auto-refresh waits for in-flight refresh to complete before next tick * style: shrink db viewer sidebar nav icons from 20px to 16px * style: shrink db viewer sidebar nav buttons to 32px (8px padding) * style: make Tables panel title xs, regular weight, muted * style: bump Tables panel title back to sm, keep regular weight and muted * feat: export schema diagram as PNG/JPEG/SVG (entire schema or viewport) * chore: lockfile for html-to-image * fix: raise schema visualizer toolbar above legend so export menu isn't hidden * feat: schema export via save dialog, transparent background option, save notification * fix: render nothing in tab bar when no tabs are open * style: reduce tab bar height from 40px to 36px * style: reduce tab bar height to 32px * style: revert tab bar height to 36px * feat: split tab bar with fixed +Query and Changes actions on the right * style: blue play-icon Query button in tab bar * refactor: remove sidebar New Query button (now in tab bar) * style: conditional bottom padding in sidebar toolbar when nothing is below * feat: distinguish table and query tabs with icons * style: tab icons follow active/inactive state, muted colors * feat: query tab toolbar (run/format/dialect badge) + bare transparent editor * style: blue rounded Run Query button in query toolbar * feat: smart platform-aware shortcut tooltip on Run Query (⌘+⏎ / Ctrl+Enter) * style: show only the shortcut in the Run Query tooltip * fix: Cmd+Enter keybinding stale closure; add run pulse to query toolbar; bundle monaco locally (offline) * feat: show placeholder text in empty query editor * feat: SQL autocomplete — keywords + table names from active schema * feat: per-table column autocomplete on 'table.' + docs update * feat: query-variant result toolbar — export/refresh/columns left, smart-unit execution time right * fix: populate execution_time_ms on query results so the toolbar can show time taken * fix: re-measure monaco fonts after async font load to stop cursor drift * feat: resizable + collapsible query results panel * refactor: move results caret onto the resize handle (centered), bottom caret when collapsed * style: thin drag strip with caret on its own centered pill * refactor: remove Queue button from table toolbar (Changes lives in tab bar) * style: changes button becomes bordered rounded icon with count badge * docs: mark tab-bar Changes queue button in AGENTS.md and README --- AGENTS.md | 27 +- README.md | 24 +- bun.lock | 38 + package.json | 4 + src-tauri/src/commands/db_viewer.rs | 4 + src-tauri/src/commands/mod.rs | 3 +- src-tauri/src/commands/query.rs | 748 ++++++++++++++++++ src-tauri/src/lib.rs | 5 +- src-tauri/src/models/db_viewer.rs | 2 + src-tauri/src/store/migrations.rs | 77 +- src-tauri/src/store/mod.rs | 79 +- .../connections/ConnectionGrid.test.tsx | 94 ++- src/components/connections/ConnectionGrid.tsx | 45 +- .../connections/GeneralTab.test.tsx | 16 +- src/components/connections/GeneralTab.tsx | 10 + .../db-viewer/ChangesQueuePanel.test.tsx | 17 + .../db-viewer/ChangesQueuePanel.tsx | 9 +- .../db-viewer/DbViewerScreen.test.tsx | 418 +++++++++- src/components/db-viewer/DbViewerScreen.tsx | 509 ++++++++++-- src/components/db-viewer/DbViewerSidebar.tsx | 26 +- .../db-viewer/DbViewerToolbar.test.tsx | 34 +- src/components/db-viewer/DbViewerToolbar.tsx | 9 +- .../db-viewer/SchemaVisualizerPage.test.tsx | 368 ++++++++- .../db-viewer/SchemaVisualizerPage.tsx | 238 +++++- src/components/db-viewer/TabBar.test.tsx | 84 +- src/components/db-viewer/TabBar.tsx | 134 +++- .../db-viewer/TableControls.test.tsx | 317 ++++++++ src/components/db-viewer/TableControls.tsx | 481 +++++------ .../editor/DestructiveQueryDialog.test.tsx | 40 + .../editor/DestructiveQueryDialog.tsx | 40 + src/components/editor/QueryEditor.test.tsx | 89 +++ src/components/editor/QueryEditor.tsx | 87 ++ src/components/editor/QueryToolbar.test.tsx | 139 ++++ src/components/editor/QueryToolbar.tsx | 110 +++ .../folders/FolderBreadcrumb.test.tsx | 13 + src/components/folders/FolderBreadcrumb.tsx | 33 +- src/components/grid/VirtualDataGrid.test.tsx | 54 ++ src/components/grid/VirtualDataGrid.tsx | 93 ++- src/components/layout/ActionRow.test.tsx | 5 - src/components/layout/ActionRow.tsx | 14 +- .../layout/DbTypeFilterDropdown.test.tsx | 78 ++ .../layout/DbTypeFilterDropdown.tsx | 117 +++ src/components/search/SearchBar.tsx | 8 +- .../tags/SearchableTagPicker.test.tsx | 23 + src/components/tags/SearchableTagPicker.tsx | 76 +- .../tags/TagFilterDropdown.test.tsx | 67 ++ src/components/tags/TagFilterDropdown.tsx | 106 +++ src/hooks/useConnections.test.ts | 138 ++++ src/hooks/useConnections.ts | 10 +- src/hooks/useDbConnection.test.tsx | 153 ++++ src/hooks/useDbConnection.ts | 72 +- src/index.css | 7 + src/lib/commands.test.ts | 35 + src/lib/commands.ts | 34 + src/lib/monacoSetup.ts | 80 ++ src/lib/sqlCompletion.test.ts | 152 ++++ src/lib/sqlCompletion.ts | 172 ++++ src/lib/utils.test.ts | 112 +++ src/lib/utils.ts | 57 +- src/main.tsx | 1 + src/stores/connectionStore.test.ts | 41 + src/stores/connectionStore.ts | 19 +- src/stores/dbViewerStore.test.ts | 42 + src/stores/dbViewerStore.ts | 33 + src/stores/uiStore.test.ts | 13 +- src/stores/uiStore.ts | 7 +- 66 files changed, 5681 insertions(+), 509 deletions(-) create mode 100644 src-tauri/src/commands/query.rs create mode 100644 src/components/db-viewer/TableControls.test.tsx create mode 100644 src/components/editor/DestructiveQueryDialog.test.tsx create mode 100644 src/components/editor/DestructiveQueryDialog.tsx create mode 100644 src/components/editor/QueryEditor.test.tsx create mode 100644 src/components/editor/QueryEditor.tsx create mode 100644 src/components/editor/QueryToolbar.test.tsx create mode 100644 src/components/editor/QueryToolbar.tsx create mode 100644 src/components/layout/DbTypeFilterDropdown.test.tsx create mode 100644 src/components/layout/DbTypeFilterDropdown.tsx create mode 100644 src/components/tags/SearchableTagPicker.test.tsx create mode 100644 src/components/tags/TagFilterDropdown.test.tsx create mode 100644 src/components/tags/TagFilterDropdown.tsx create mode 100644 src/hooks/useConnections.test.ts create mode 100644 src/hooks/useDbConnection.test.tsx create mode 100644 src/lib/monacoSetup.ts create mode 100644 src/lib/sqlCompletion.test.ts create mode 100644 src/lib/sqlCompletion.ts diff --git a/AGENTS.md b/AGENTS.md index b1efc72..6a05639 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,11 +203,16 @@ cargo test # Rust tests | Connection cards grid (by folder) | ✅ | Grouped display, single-click to open DB viewer | | Folders CRUD | ✅ | Nested folders, reparent on delete, breadcrumb nav | | Tags CRUD | ✅ | Colors, drag reorder, filter connections by tag | -| DB type filter (Postgres/MySQL/SQLite/Redis) | ✅ | Toggle chips to filter connection grid | -| Global search (Cmd+K) | ✅ | Connection URL detection auto-fills new-connection form | +| Tag filter dropdown | ✅ | ActionRow Tags button → dropdown with checkboxes, active-count badge, Manage tags → Settings. **OR semantics** — a connection shows if it has ANY selected tag (not all) | +| Folder tag matching | ✅ | When any filter is active, folder cards show only if the folder matches a selected tag OR contains matching connections (directly or in subfolders) | +| DB type filter (Postgres/MySQL/SQLite/Redis) | ✅ | Dropdown with checkboxes + Clear all; folder cards hidden when their contents don't match the DB type | +| Environment filter | ✅ | Select in Filters dropdown: All / Production / Staging / Development / None (unassigned); counts toward active badge | +| Global search (Cmd+K) | ✅ | Connection URL detection auto-fills new-connection form; shows results from ALL folders as if at root (folder scope bypassed while searching); breadcrumb shows "Showing Search Results" with Clear button | +| Connection name editing | ✅ | Name field in GeneralTab edit form | | Import/Export connections (JSON) | ✅ | Bulk import with validation, skipped-record reporting | | Bulk select + delete connections/folders | ✅ | Checkbox selection with confirmation dialog | -| Drag-and-drop connections to folders | ❌ | Currently only via edit form | +| Drag-and-drop connections to folders | ✅ | Optimistic update with atomic snapshot rollback (race-condition hardened) | +| Inline tag creation | ✅ | "Create first tag" inline form (name + color) in SearchableTagPicker empty state | | Move-to-folder bulk action | ❌ | | | Favorites / Recent connections | ❌ | | | Connection status indicator on cards | ❌ | | @@ -232,7 +237,7 @@ cargo test # Rust tests | Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete | | Export toolbar (JSON, CSV, SQL, Markdown) | ✅ | Client-side Blob download of visible rows | | Auto-refresh timer | ✅ | Configurable interval in settings | -| Changes queue (INSERT, UPDATE, DELETE) | ✅ | Queue changes → Commit All; cancel individual changes | +| Changes queue (INSERT, UPDATE, DELETE) | ✅ | Queue changes → Commit All; cancel individual changes. Tab bar shows a **Changes** icon button with a pending-count badge that toggles the bottom panel (the queue dropdown was removed from the table toolbar — one entry point only) | | Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test | | Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt | | Inline cell editing | ❌ | Cells are read-only; changes via queue Insert button only | @@ -258,11 +263,12 @@ cargo test # Rust tests ### Query Editor | Feature | Status | Details | | :--- | :---: | :--- | -| SQL text editor (Monaco) | ❌ | `src/components/editor/` does not exist yet | -| SQL autocomplete (keywords, tables, columns) | ❌ | | -| Custom query execution (arbitrary SQL) | ❌ | Only `SELECT * FROM table` via tab open | +| SQL text editor (Monaco) | ✅ | Lazy-loaded Monaco SQL editor with Cmd/Ctrl+Enter to run (`src/components/editor/QueryEditor.tsx`) | +| Custom query execution (arbitrary SQL) | ✅ | `execute_query` Rust command: subquery-wrapped pagination + raw fallback; PostgreSQL + SQLite; results in virtualized grid | +| Destructive query guard | ✅ | Confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE (`isDestructiveQuery` + `DestructiveQueryDialog`) | +| Query history / recent queries | 🟡 | Backend + commands done (v5 `query_history` table, `get_query_history`/`clear_query_history`); UI dropdown to show history in the query editor is **not wired yet** | +| SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) | | Multiple result sets | ❌ | | -| Query history / recent queries | ❌ | No persistence or UI | | Saved queries (named, organized) | ❌ | No `queries` table in local SQLite | | Query favorites / pinning | ❌ | | | Editor settings (font, tab size, word wrap, minimap) | ❌ | Settings page has "Editor" tab with "coming soon" placeholder | @@ -303,6 +309,11 @@ cargo test # Rust tests | Getting started / onboarding flow | ❌ | | | Welcome tooltips / tour | ❌ | | +### AI Integration (Future Planning) +| Feature | Status | Details | +| :--- | :---: | :--- | +| AI assistant (BYOK) | 🔮 | **Planned for future.** Bring-Your-Own-Key model (user supplies their own API key — no paywall, no bundling). Intended use cases: natural-language → SQL generation, query explanations, schema summaries, error message suggestions. No design decided yet — deliberate before implementation (privacy: SQL/text only sent to user's chosen provider, key stored in OS keychain like DB passwords). | + --- ## Related Documents diff --git a/README.md b/README.md index 422795a..111f9b5 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ Most database GUI clients either lock essential productivity features behind pay | SSH tunneling | 🟡 (likely paid) | ✅ | 🟡 *Config UI done* | | OS credential vault | ✅ | ✅ | **Keychain / Secret Service** | | Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** | -| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** | -| Query history | ✅ (auto-saved) | ✅ | 🟡 *Upcoming* | -| AI assistant | ✅ (BYO key) | ❌ (paid only) | ❌ | +| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** (tab-bar **Changes** button with count badge toggles the commit panel) | +| Query history | ✅ (auto-saved) | ✅ | 🟡 *Backend done, UI pending* | +| AI assistant | ✅ (BYO key) | ❌ (paid only) | 🔮 *Planned — BYOK* | | Open source | ❌ | ✅ (GPLv3) | **✅ (MIT)** | | Desktop shell | Native webview | Electron (~250MB) | **Tauri 2.0 (~40MB)** | @@ -96,10 +96,15 @@ Full tree-view navigation of all native PostgreSQL schema objects: - **Schema Visualizer (ER Diagram)** — interactive React Flow graph with dagre auto-layout, crow's foot notation (1:1, 1:N, N:M), color-coded relationships, schema selector, zoom controls, collapsible columns (PK/FK/unique-only), cross-schema FK support for PostgreSQL + SQLite ### SQL Editor & Query Workbench -- **Multi-Tab Workspace** — unlimited named tabs, close with Cmd/Ctrl+W, session persistence across restarts -- **Changes Queue** — queue INSERT/UPDATE/DELETE changes; preview before committing all +- **Monaco SQL Editor** — lazy-loaded [Monaco Editor](https://microsoft.github.io/monaco-editor/) with SQL syntax highlighting, Cmd/Ctrl+Enter to run +- **Custom Query Execution** — run arbitrary SQL on PostgreSQL + SQLite via the Rust `execute_query` command; subquery-wrapped pagination with automatic raw fallback for CTEs/multi-statement SQL +- **Destructive Query Guard** — confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE before execution +- **Query Tabs** — dedicated query tabs alongside table tabs, results rendered in the same virtualized data grid, close with Cmd/Ctrl+W +- **SQL Autocomplete** — keyword + table suggestions from the active schema; typing `table.` suggests that table's columns (schema introspection, cached per schema) +- **Multi-Tab Workspace** — unlimited named tabs, session persistence across restarts +- **Changes Queue** — queue INSERT/UPDATE/DELETE changes; preview before committing all. The tab bar's **Changes** button (checklist icon + pending-count badge) toggles the bottom Commit All panel — the single entry point - **Smart Default Sort** — auto-detects `updated_at`, `created_at`, `_id` columns for logical initial sorting -- *(Monaco Editor with SQL autocomplete, query history, and saved snippets — upcoming)* +- *(query history UI dropdown, saved queries/snippets — upcoming)* ### Data Grid & Schema Browser - **Virtualized Grid** — row-level virtualization via `@tanstack/react-virtual` handles 100k+ rows @@ -133,7 +138,7 @@ Full tree-view navigation of all native PostgreSQL schema objects: | **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI | | **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design | | **State** | [Zustand](https://zustand.docs.pmnd.rs) / [Jotai](https://jotai.org) | Lightweight client-state for tabs, connections, queries | -| **Code Editor** | *(planned)* [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing with autocomplete (coming soon) | +| **Code Editor** | [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing with Cmd+Enter execution, lazy-loaded | | **Data Grid** | [TanStack Virtual](https://tanstack.com/virtual) | Virtualized row rendering for 100k+ rows | | **Local DB** | SQLite via [rusqlite](https://github.com/rusqlite/rusqlite) | User settings, saved queries, workspace state | @@ -225,9 +230,11 @@ gridline/ - **Phase 5 — Admin Tools** — `pg_dump`/`pg_restore` UI wrappers with real-time progress, DB-to-DB sync, backup/restore format selectors - **Phase 6 — Schema Visualizer** — Interactive ER diagram with React Flow + dagre, crow's foot notation, schema selector, legend, collapsible column views, PostgreSQL + SQLite support - **Home Screen & Organization** — Connection cards by folder, folders CRUD, tags CRUD with colors, global search (Cmd+K), import/export connections (JSON), bulk select/delete, DB type filter, demo SQLite database +- **Query Editor (Core)** — Monaco SQL editor with Cmd+Enter execution, `execute_query` Rust command (PostgreSQL + SQLite, subquery pagination with raw fallback), query tabs in the DB viewer, destructive query confirmation dialog, `query_history` persistence backend +- **Home Screen Filters** — Tag filter with OR semantics, folder cards matching tags or containing matching connections, DB type filter hiding empty folders, environment filter (All/Production/Staging/Development/None), global search across all folders with "Showing Search Results" breadcrumb + Clear ### 🟡 In Progress / Upcoming -- **SQL Editor** — Monaco Editor integration with schema-aware SQL autocomplete, query history, saved queries +- **Query Editor (Polish)** — SQL autocomplete (keywords, tables, and per-table columns), query history UI dropdown, saved queries - **SSH/SSL Runtime** — SSH tunnel via `ssh2` crate, SSL/TLS config passed to `sqlx`/`tokio-postgres` - **Inline Cell Editing** — Edit cells directly in the data grid - **Data Import** — CSV, JSON import with column mapping @@ -238,6 +245,7 @@ gridline/ - **Deeper PostgreSQL** — Indexes, constraints, materialized views, stored procedure view, user/role management - **Collaboration** — Team workspaces, shared connections, query sharing - **Notebook Reports** — SQL-backed markdown reports with embedded results +- **AI Integration (BYOK)** — Bring-Your-Own-Key AI assistant: natural-language → SQL generation, query explanations, schema summaries, error suggestions. Key stored in OS keychain; only user's chosen provider sees SQL/text. --- diff --git a/bun.lock b/bun.lock index 335b452..86c8cc5 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@dnd-kit/utilities": "^3.2.2", "@fontsource/outfit": "^5.3.0", "@fontsource/space-mono": "^5.3.0", + "@monaco-editor/react": "^4.7.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-virtual": "^3.14.8", "@tauri-apps/api": "^2", @@ -17,11 +18,14 @@ "@tauri-apps/plugin-opener": "^2", "@xyflow/react": "^12.11.2", "dagre": "^0.8.5", + "html-to-image": "^1.11.13", "lucide-react": "^1.26.0", + "monaco-editor": "^0.56.0", "motion": "^12.42.2", "react": "^19.1.0", "react-dom": "^19.1.0", "simple-icons": "^16.27.1", + "sql-formatter": "^15.8.2", "tauri-plugin-keyring-store-api": "^0.2.0", "zustand": "^5.0.14", }, @@ -180,6 +184,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], + + "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], @@ -342,6 +350,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], @@ -366,6 +376,8 @@ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -382,6 +394,8 @@ "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], @@ -420,8 +434,12 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="], "enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="], @@ -452,6 +470,8 @@ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], @@ -500,10 +520,16 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="], + + "moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="], + "motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="], "motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="], @@ -514,6 +540,8 @@ "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + "nearley": ["nearley@2.20.1", "", { "dependencies": { "commander": "^2.19.0", "moo": "^0.5.0", "railroad-diagrams": "^1.0.0", "randexp": "0.4.6" }, "bin": { "nearleyc": "bin/nearleyc.js", "nearley-test": "bin/nearley-test.js", "nearley-unparse": "bin/nearley-unparse.js", "nearley-railroad": "bin/nearley-railroad.js" } }, "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ=="], + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], @@ -532,6 +560,10 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "railroad-diagrams": ["railroad-diagrams@1.0.0", "", {}, "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A=="], + + "randexp": ["randexp@0.4.6", "", { "dependencies": { "discontinuous-range": "1.0.0", "ret": "~0.1.10" } }, "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], @@ -544,6 +576,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="], + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], @@ -558,8 +592,12 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "sql-formatter": ["sql-formatter@15.8.2", "", { "dependencies": { "argparse": "^2.0.1", "nearley": "^2.20.1" }, "bin": { "sql-formatter": "bin/sql-formatter-cli.cjs" } }, "sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], diff --git a/package.json b/package.json index ed0cae3..3ed2614 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@dnd-kit/utilities": "^3.2.2", "@fontsource/outfit": "^5.3.0", "@fontsource/space-mono": "^5.3.0", + "@monaco-editor/react": "^4.7.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-virtual": "^3.14.8", "@tauri-apps/api": "^2", @@ -26,11 +27,14 @@ "@tauri-apps/plugin-opener": "^2", "@xyflow/react": "^12.11.2", "dagre": "^0.8.5", + "html-to-image": "^1.11.13", "lucide-react": "^1.26.0", + "monaco-editor": "^0.56.0", "motion": "^12.42.2", "react": "^19.1.0", "react-dom": "^19.1.0", "simple-icons": "^16.27.1", + "sql-formatter": "^15.8.2", "tauri-plugin-keyring-store-api": "^0.2.0", "zustand": "^5.0.14" }, diff --git a/src-tauri/src/commands/db_viewer.rs b/src-tauri/src/commands/db_viewer.rs index e3ec2f9..bba1ae5 100644 --- a/src-tauri/src/commands/db_viewer.rs +++ b/src-tauri/src/commands/db_viewer.rs @@ -880,6 +880,7 @@ ORDER BY c.ordinal_position"#; total_rows, page: p, page_size: ps, + execution_time_ms: None, }) } Some(crate::db::pool::DbHandle::Sqlite(conn)) => { @@ -990,6 +991,7 @@ ORDER BY c.ordinal_position"#; total_rows, page: p, page_size: ps, + execution_time_ms: None, }) } None => Err("Connection not found".to_string()), @@ -1097,6 +1099,7 @@ ORDER BY c.ordinal_position"#; total_rows: 1, page: 1, page_size: 1, + execution_time_ms: None, }) } Some(crate::db::pool::DbHandle::Sqlite(conn)) => { @@ -1178,6 +1181,7 @@ ORDER BY c.ordinal_position"#; total_rows: 1, page: 1, page_size: 1, + execution_time_ms: None, }) } None => Err("Connection not found".to_string()), diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ada8af3..d5a1c18 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,4 +9,5 @@ pub mod ssh; pub mod keychain; pub mod demo; pub mod backup; -pub mod schema_graph; \ No newline at end of file +pub mod schema_graph; +pub mod query; \ No newline at end of file diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs new file mode 100644 index 0000000..e23951c --- /dev/null +++ b/src-tauri/src/commands/query.rs @@ -0,0 +1,748 @@ +//! Arbitrary SQL query execution with subquery-based pagination and +//! query‑history recording. +//! +//! Architecture: +//! 1. Subquery wrapping is attempted first: +//! `SELECT * FROM (user_query) AS _gridline_data LIMIT x OFFSET y` +//! `SELECT COUNT(*) FROM (user_query) AS _gridline_cnt` +//! 2. If wrapping fails (CTEs, multi‑statement), fall back to raw +//! execution with client‑side slicing. +//! 3. Every query is recorded in the local `query_history` table. + +use crate::db::pool::DbHandle; +use crate::models::db_viewer::{ColumnInfo, QueryResult}; +use serde::{Deserialize, Serialize}; +use std::time::Instant; +use tauri::State; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// QueryHistoryEntry +// --------------------------------------------------------------------------- + +/// A single record in the local `query_history` table. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryHistoryEntry { + pub id: String, + pub connection_id: String, + pub query_text: String, + pub execution_time_ms: Option, + pub row_count: Option, + /// `"success"` or `"error"`. + pub status: String, + pub error_message: Option, + pub executed_at: String, +} + +// --------------------------------------------------------------------------- +// Core executor (not a Tauri command itself — called by the command wrapper) +// --------------------------------------------------------------------------- + +/// Execute arbitrary SQL on PostgreSQL or SQLite with subquery‑based +/// pagination and automatic fallback to client‑side slicing. +/// +/// Returns `(QueryResult, Option)` so the caller can +/// write the history record through the store. +pub(crate) async fn execute_query_inner( + pool_manager: &mut crate::db::pool::ConnectionPoolManager, + db_store: &std::sync::Mutex, + connection_id: &str, + query: &str, + page: i64, + page_size: i64, +) -> Result { + let start = Instant::now(); + let history_id = Uuid::new_v4().to_string(); + + // Try subquery-wrapped execution first; fall back to raw on failure. + let mut result = match pool_manager.get(connection_id) { + Some(DbHandle::Postgresql(client, _)) => { + execute_pg_query(client, query, page, page_size).await + } + Some(DbHandle::Sqlite(conn)) => { + execute_sqlite_query(conn, query, page, page_size) + } + None => { + let elapsed = start.elapsed().as_millis() as i64; + let err = "Connection not found".to_string(); + insert_history( + db_store, + &history_id, + connection_id, + query, + Some(elapsed), + None, + "error", + Some(&err), + ); + return Err(err); + } + }; + + let elapsed = start.elapsed().as_millis() as i64; + + // Attach server-side execution time to the returned result so the UI can + // show "time taken" for query runs. + if let Ok(qr) = &mut result { + qr.execution_time_ms = Some(elapsed); + } + + match &result { + Ok(qr) => { + insert_history( + db_store, + &history_id, + connection_id, + query, + Some(elapsed), + Some(qr.rows.len() as i64), + "success", + None, + ); + } + Err(e) => { + insert_history( + db_store, + &history_id, + connection_id, + query, + Some(elapsed), + None, + "error", + Some(e), + ); + } + } + + result +} + +/// Helper: insert a query_history row through the store, swallowing errors. +fn insert_history( + db_store: &std::sync::Mutex, + id: &str, + connection_id: &str, + query_text: &str, + execution_time_ms: Option, + row_count: Option, + status: &str, + error_message: Option<&str>, +) { + if let Ok(store) = db_store.lock() { + let _ = store.insert_query_history( + id, + connection_id, + query_text, + execution_time_ms, + row_count, + status, + error_message, + ); + } +} + +// --------------------------------------------------------------------------- +// PostgreSQL execution +// --------------------------------------------------------------------------- + +/// Try subquery‑wrapped pagination for PostgreSQL. Falls back to raw +/// execution via `simple_query` if wrapping produces a parse error. +async fn execute_pg_query( + client: &tokio_postgres::Client, + query: &str, + page: i64, + page_size: i64, +) -> Result { + let trimmed = query.trim(); + if trimmed.is_empty() { + return Err("Query cannot be empty".to_string()); + } + + let off = (page.saturating_sub(1).max(0)) * page_size; + + // Attempt subquery wrapping. + let wrapped_data = format!( + "SELECT * FROM ({}) AS _gridline_data LIMIT $1 OFFSET $2", + trimmed + ); + let wrapped_count = format!( + "SELECT COUNT(*) FROM ({}) AS _gridline_cnt", + trimmed + ); + + // Try the wrapped count query first — if this fails we fall back to raw. + let total_rows: i64 = match client + .query_one(&wrapped_count, &[]) + .await + { + Ok(row) => row.get::<_, i64>(0), + Err(_) => { + // Wrapping failed — fall back to raw execution. + return execute_pg_raw(client, trimmed, page, page_size, off).await; + } + }; + + // Now execute the wrapped data query. + let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await { + Ok(rows) => rows, + Err(_) => { + return execute_pg_raw(client, trimmed, page, page_size, off).await; + } + }; + + // Build column info from the data rows. + let columns: Vec = match data_rows.first() { + Some(first) => first + .columns() + .iter() + .map(|c| ColumnInfo { + name: c.name().to_string(), + data_type: format!("{:?}", c.type_()), + is_nullable: true, + is_pk: false, + is_fk: false, + fk_ref: None, + default_value: None, + }) + .collect(), + None => { + // No rows — fall back to raw execution which handles + // column metadata via simple_query. + return execute_pg_raw(client, trimmed, page, page_size, off).await; + } + }; + + // Convert rows to JSON. + let rows: Vec> = data_rows + .iter() + .map(|row| { + (0..row.len()) + .map(|i| crate::commands::db_viewer::pg_value_to_json(row, i)) + .collect() + }) + .collect(); + + Ok(QueryResult { + columns, + rows, + total_rows, + page, + page_size, + execution_time_ms: None, + }) +} + +// --------------------------------------------------------------------------- +// PostgreSQL raw fallback (simple_query) +// --------------------------------------------------------------------------- + +/// Execute a raw SQL string via `simple_query`, collecting all result rows +/// and slicing them on the client side for pagination. +async fn execute_pg_raw( + client: &tokio_postgres::Client, + query: &str, + page: i64, + page_size: i64, + offset: i64, +) -> Result { + let messages = client + .simple_query(query) + .await + .map_err(|e| crate::commands::db_viewer::pg_error_message(&e))?; + + let mut columns: Vec = Vec::new(); + let mut all_rows: Vec> = Vec::new(); + let mut saw_columns = false; + + for msg in messages { + match msg { + tokio_postgres::SimpleQueryMessage::Row(row) => { + if !saw_columns { + columns = row + .columns() + .iter() + .map(|c| ColumnInfo { + name: c.name().to_string(), + data_type: "text".to_string(), + is_nullable: true, + is_pk: false, + is_fk: false, + fk_ref: None, + default_value: None, + }) + .collect(); + saw_columns = true; + } + let values: Vec = (0..row.len()) + .map(|i| { + // simple_query protocol returns everything as strings; + // try_get with just the index returns Option<&str>. + match row.try_get::(i) { + Ok(Some(s)) => serde_json::Value::String(s.to_string()), + Ok(None) => serde_json::Value::Null, + Err(_) => serde_json::Value::Null, + } + }) + .collect(); + all_rows.push(values); + } + tokio_postgres::SimpleQueryMessage::CommandComplete(..) => { + // DML statements like INSERT, UPDATE, DELETE, etc. + // Return empty result with row count from the tag. + } + _ => {} + } + } + + let total_rows = all_rows.len() as i64; + let uoffset = offset as usize; + let ulimit = page_size as usize; + + let rows: Vec> = if uoffset < all_rows.len() { + all_rows + .into_iter() + .skip(uoffset) + .take(ulimit) + .collect() + } else { + Vec::new() + }; + + Ok(QueryResult { + columns, + rows, + total_rows, + page, + page_size, + execution_time_ms: None, + }) +} + +// --------------------------------------------------------------------------- +// SQLite execution +// --------------------------------------------------------------------------- + +/// Try subquery‑wrapped pagination for SQLite. Falls back to raw execution +/// if wrapping produces a parse error. +fn execute_sqlite_query( + conn: &rusqlite::Connection, + query: &str, + page: i64, + page_size: i64, +) -> Result { + let trimmed = query.trim(); + if trimmed.is_empty() { + return Err("Query cannot be empty".to_string()); + } + + let off = (page.saturating_sub(1).max(0)) * page_size; + + // Attempt subquery wrapping. + let wrapped_data = format!( + "SELECT * FROM ({}) AS _gridline_data LIMIT {} OFFSET {}", + trimmed, page_size, off + ); + let wrapped_count = format!( + "SELECT COUNT(*) FROM ({}) AS _gridline_cnt", + trimmed + ); + + // Try the wrapped count query first. + let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) { + Ok(n) => n, + Err(_) => { + // Wrapping failed — fall back to raw execution. + return execute_sqlite_raw(conn, trimmed, page, page_size, off); + } + }; + + // Execute the wrapped data query. + let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) { + Ok(result) => result, + Err(_) => { + return execute_sqlite_raw(conn, trimmed, page, page_size, off); + } + }; + + Ok(QueryResult { + columns, + rows: all_rows, + total_rows, + page, + page_size, + execution_time_ms: None, + }) +} + +/// Execute a SQL string on SQLite and return `(columns, rows)`. +fn execute_sqlite_with_query( + conn: &rusqlite::Connection, + sql: &str, +) -> Result<(Vec, Vec>), String> { + let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?; + + let columns: Vec = (0..stmt.column_count()) + .map(|i| { + let name = stmt.column_name(i).unwrap_or("?").to_string(); + ColumnInfo { + name, + data_type: "TEXT".to_string(), + is_nullable: true, + is_pk: false, + is_fk: false, + fk_ref: None, + default_value: None, + } + }) + .collect(); + + let col_count = stmt.column_count(); + let rows: Vec> = stmt + .query_map([], |row| { + let mut vals = Vec::with_capacity(col_count); + for i in 0..col_count { + vals.push(sqlite_value_to_json(row, i)); + } + Ok(vals) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + Ok((columns, rows)) +} + +// --------------------------------------------------------------------------- +// SQLite raw fallback +// --------------------------------------------------------------------------- + +/// Execute raw SQL on SQLite without subquery wrapping, paginating +/// client‑side. +fn execute_sqlite_raw( + conn: &rusqlite::Connection, + query: &str, + page: i64, + page_size: i64, + offset: i64, +) -> Result { + let (columns, all_rows) = execute_sqlite_with_query(conn, query)?; + + let total_rows = all_rows.len() as i64; + let uoffset = offset as usize; + let ulimit = page_size as usize; + + let rows: Vec> = if uoffset < all_rows.len() { + all_rows + .into_iter() + .skip(uoffset) + .take(ulimit) + .collect() + } else { + Vec::new() + }; + + Ok(QueryResult { + columns, + rows, + total_rows, + page, + page_size, + execution_time_ms: None, + }) +} + +// --------------------------------------------------------------------------- +// Value conversion helpers +// --------------------------------------------------------------------------- + +/// Convert a `rusqlite::Row` cell to `serde_json::Value`. +fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value { + use rusqlite::types::ValueRef; + match row.get_ref(i) { + Ok(ValueRef::Null) => serde_json::Value::Null, + Ok(ValueRef::Integer(v)) => serde_json::json!(v), + Ok(ValueRef::Real(v)) => serde_json::json!(v), + Ok(ValueRef::Text(v)) => { + serde_json::Value::String(String::from_utf8_lossy(v).to_string()) + } + Ok(ValueRef::Blob(v)) => { + serde_json::Value::String(format!("[{}B blob]", v.len())) + } + Err(_) => serde_json::Value::Null, + } +} + +// --------------------------------------------------------------------------- +// Query history commands +// --------------------------------------------------------------------------- + +pub(crate) fn get_query_history_inner( + db_store: &std::sync::Mutex, + connection_id: Option<&str>, + limit: i64, + offset: i64, +) -> Result, String> { + let store = db_store.lock().map_err(|e| e.to_string())?; + store.get_query_history(connection_id, limit, offset) +} + +pub(crate) fn clear_query_history_inner( + db_store: &std::sync::Mutex, + connection_id: Option<&str>, +) -> Result<(), String> { + let store = db_store.lock().map_err(|e| e.to_string())?; + store.clear_query_history(connection_id) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn execute_query( + connection_id: String, + query: String, + page: Option, + page_size: Option, + state: State<'_, crate::AppState>, +) -> Result { + let p = page.unwrap_or(1); + let ps = page_size.unwrap_or(50); + let mut pm = state.pool_manager.lock().await; + execute_query_inner(&mut pm, &state.db_store, &connection_id, &query, p, ps).await +} + +#[tauri::command] +pub async fn get_query_history( + connection_id: Option, + limit: Option, + offset: Option, + state: State<'_, crate::AppState>, +) -> Result, String> { + let l = limit.unwrap_or(50); + let o = offset.unwrap_or(0); + let store = state.db_store.lock().map_err(|e| e.to_string())?; + store.get_query_history(connection_id.as_deref(), l, o) +} + +#[tauri::command] +pub async fn clear_query_history( + connection_id: Option, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + let store = state.db_store.lock().map_err(|e| e.to_string())?; + store.clear_query_history(connection_id.as_deref()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::Store; + use rusqlite::Connection as SqliteConnection; + use std::sync::Mutex; + + /// Create an in‑memory Store with all migrations applied. + fn test_store() -> Mutex { + let conn = SqliteConnection::open_in_memory().unwrap(); + crate::store::migrations::run_migrations(&conn).unwrap(); + // Insert a placeholder connection so FK constraints are satisfied. + conn.execute( + "INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) + VALUES ('test-conn', 'test', 'sqlite', ':memory:', NULL, datetime('now'), datetime('now'))", + [], + ) + .unwrap(); + Mutex::new(Store::from_connection(conn)) + } + + /// Open an in‑memory SQLite database and return a DbHandle. + fn test_sqlite_handle() -> DbHandle { + let conn = SqliteConnection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT); + INSERT INTO users VALUES (1, 'Alice', 'alice@example.com'); + INSERT INTO users VALUES (2, 'Bob', 'bob@example.com'); + INSERT INTO users VALUES (3, 'Charlie', 'charlie@example.com');", + ) + .unwrap(); + DbHandle::Sqlite(conn) + } + + /// Open an in‑memory SQLite database for CTE testing. + fn test_sqlite_cte_handle() -> DbHandle { + let conn = SqliteConnection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE items (id INTEGER PRIMARY KEY, val INTEGER); + INSERT INTO items VALUES (1, 10); + INSERT INTO items VALUES (2, 20); + INSERT INTO items VALUES (3, 30); + INSERT INTO items VALUES (4, 40); + INSERT INTO items VALUES (5, 50);", + ) + .unwrap(); + DbHandle::Sqlite(conn) + } + + // ------------------------------------------------------------------ + // Test 1: Basic SELECT execution with pagination + // ------------------------------------------------------------------ + + #[test] + fn basic_select_with_pagination() { + let _store = test_store(); + let conn = test_sqlite_handle(); + + let (columns, rows) = + execute_sqlite_with_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id") + .unwrap(); + + assert_eq!(columns.len(), 3); + assert_eq!(columns[0].name, "id"); + assert_eq!(rows.len(), 3); + + // Verify row values + assert_eq!(rows[0][0], serde_json::json!(1)); + assert_eq!(rows[0][1], serde_json::json!("Alice")); + assert_eq!(rows[1][0], serde_json::json!(2)); + } + + // ------------------------------------------------------------------ + // Test 2: Subquery wrapping produces correct LIMIT/OFFSET + // ------------------------------------------------------------------ + + #[test] + fn subquery_wrapping_paginates_correctly() { + let _store = test_store(); + let conn = test_sqlite_handle(); + + // Page 1: 2 rows + let result = + execute_sqlite_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id", 1, 2) + .unwrap(); + + assert_eq!(result.total_rows, 3); + assert_eq!(result.rows.len(), 2); + assert_eq!(result.rows[0][1], serde_json::json!("Alice")); + assert_eq!(result.rows[1][1], serde_json::json!("Bob")); + assert_eq!(result.page, 1); + assert_eq!(result.page_size, 2); + + // Page 2: 1 row + let result2 = + execute_sqlite_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id", 2, 2) + .unwrap(); + + assert_eq!(result2.total_rows, 3); + assert_eq!(result2.rows.len(), 1); + assert_eq!(result2.rows[0][1], serde_json::json!("Charlie")); + } + + // ------------------------------------------------------------------ + // Test 3: EXPLAIN falls back to raw execution with client‑side pagination + // ------------------------------------------------------------------ + + #[test] + fn cte_falls_back_to_raw_execution() { + let _store = test_store(); + let conn = test_sqlite_cte_handle(); + + // EXPLAIN cannot be wrapped in a subquery: + // SELECT * FROM (EXPLAIN SELECT ...) is a syntax error. + // This forces the fallback to raw execution. + let query = "EXPLAIN SELECT * FROM items WHERE val > 20"; + + let result = execute_sqlite_query(&unwrap_sqlite(&conn), query, 1, 10).unwrap(); + + // Should fall back to raw — all rows fetched, client‑slice. + // EXPLAIN returns rows (addr, opcode, p1, p2, p3, p4, p5, comment). + assert!(result.total_rows > 0, "EXPLAIN should return rows"); + assert!(result.rows.len() <= 10, "page_size should limit rows"); + assert_eq!(result.columns.len(), 8, "EXPLAIN has 8 columns"); + } + + // ------------------------------------------------------------------ + // Test 4: Query history is recorded + // ------------------------------------------------------------------ + + #[test] + fn query_history_is_recorded() { + let store = test_store(); + + // Directly insert a history entry and read it back. + let entry_id = "hist-001"; + { + let s = store.lock().unwrap(); + s.insert_query_history( + entry_id, + "test-conn", + "SELECT 1", + Some(42), + Some(1), + "success", + None, + ) + .unwrap(); + } + + // Read it back. + { + let s = store.lock().unwrap(); + let history = s.get_query_history(Some("test-conn"), 10, 0).unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].id, "hist-001"); + assert_eq!(history[0].connection_id, "test-conn"); + assert_eq!(history[0].query_text, "SELECT 1"); + assert_eq!(history[0].execution_time_ms, Some(42)); + assert_eq!(history[0].row_count, Some(1)); + assert_eq!(history[0].status, "success"); + assert_eq!(history[0].error_message, None); + } + + // Insert an error entry. + { + let s = store.lock().unwrap(); + s.insert_query_history( + "hist-002", + "test-conn", + "SELECT invalid", + Some(5), + None, + "error", + Some("syntax error"), + ) + .unwrap(); + } + + // Read back with limit. + { + let s = store.lock().unwrap(); + let history = s.get_query_history(Some("test-conn"), 1, 0).unwrap(); + assert_eq!(history.len(), 1); + // Most recent first (DESC order) + assert_eq!(history[0].id, "hist-002"); + } + + // Clear history for connection. + { + let s = store.lock().unwrap(); + s.clear_query_history(Some("test-conn")).unwrap(); + let history = s.get_query_history(Some("test-conn"), 10, 0).unwrap(); + assert_eq!(history.len(), 0); + } + } + + // ------------------------------------------------------------------ + // Helper: unwrap a DbHandle::Sqlite to get the connection reference + // ------------------------------------------------------------------ + + fn unwrap_sqlite(handle: &DbHandle) -> &SqliteConnection { + match handle { + DbHandle::Sqlite(conn) => conn, + _ => panic!("Expected Sqlite handle"), + } + } +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4e7971..d0c8d3a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,7 +19,7 @@ pub struct AppState { pub ssh_manager: StdMutex, } -use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup, schema_graph}; +use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup, schema_graph, query}; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] @@ -95,6 +95,9 @@ pub fn run() { backup::pg_restore, backup::db_sync, schema_graph::get_schema_graph, + query::execute_query, + query::get_query_history, + query::clear_query_history, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models/db_viewer.rs b/src-tauri/src/models/db_viewer.rs index e6bdc0c..a4e96d9 100644 --- a/src-tauri/src/models/db_viewer.rs +++ b/src-tauri/src/models/db_viewer.rs @@ -42,6 +42,7 @@ pub struct QueryResult { pub total_rows: i64, pub page: i64, pub page_size: i64, + pub execution_time_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -216,6 +217,7 @@ mod tests { total_rows: 0, page: 1, page_size: 100, + execution_time_ms: None, }; let json = serde_json::to_string(&result).unwrap(); assert!(json.contains(r#""rows":[]"#)); diff --git a/src-tauri/src/store/migrations.rs b/src-tauri/src/store/migrations.rs index 98dbbb7..d62c47e 100644 --- a/src-tauri/src/store/migrations.rs +++ b/src-tauri/src/store/migrations.rs @@ -169,6 +169,31 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> { .map_err(|e| e.to_string())?; } + // v5: query_history + if current_ver < 5 { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS query_history ( + id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL, + query_text TEXT NOT NULL, + execution_time_ms INTEGER, + row_count INTEGER, + status TEXT NOT NULL CHECK(status IN ('success', 'error')), + error_message TEXT, + executed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_query_history_connection + ON query_history(connection_id, executed_at DESC);" + ).map_err(|e| e.to_string())?; + + conn.execute( + "INSERT INTO schema_version (version) VALUES (5)", + [], + ) + .map_err(|e| e.to_string())?; + } + Ok(()) } @@ -219,6 +244,56 @@ mod tests { let count: i64 = conn .query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(count, 3); + assert_eq!(count, 4); + } + + #[test] + fn v5_creates_query_history_table() { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + // Verify the table exists + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM query_history", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + // Verify columns via PRAGMA + let columns: Vec = { + let mut stmt = conn.prepare("PRAGMA table_info(query_history)").unwrap(); + let rows = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + assert!(columns.contains(&"id".to_string())); + assert!(columns.contains(&"connection_id".to_string())); + assert!(columns.contains(&"query_text".to_string())); + assert!(columns.contains(&"execution_time_ms".to_string())); + assert!(columns.contains(&"row_count".to_string())); + assert!(columns.contains(&"status".to_string())); + assert!(columns.contains(&"error_message".to_string())); + assert!(columns.contains(&"executed_at".to_string())); + } + + #[test] + fn query_history_cascades_on_connection_delete() { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + // Insert a connection + let conn_id = "test-conn-id"; + conn.execute( + "INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))", + rusqlite::params![conn_id], + ).unwrap(); + // Insert query history entry + conn.execute( + "INSERT INTO query_history (id, connection_id, query_text, status, executed_at) VALUES ('qh1', ?1, 'SELECT 1', 'success', datetime('now'))", + rusqlite::params![conn_id], + ).unwrap(); + // Delete connection — should cascade + conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM query_history WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); } } \ No newline at end of file diff --git a/src-tauri/src/store/mod.rs b/src-tauri/src/store/mod.rs index 4daf19f..32a0af7 100644 --- a/src-tauri/src/store/mod.rs +++ b/src-tauri/src/store/mod.rs @@ -447,6 +447,83 @@ impl Store { .map_err(|e| e.to_string())?; Ok(()) } + + /// Insert a row into the `query_history` table. + pub fn insert_query_history( + &self, + id: &str, + connection_id: &str, + query_text: &str, + execution_time_ms: Option, + row_count: Option, + status: &str, + error_message: Option<&str>, + ) -> Result<(), String> { + let conn = self.conn.lock().map_err(|e| e.to_string())?; + let now = Self::now(); + conn.execute( + "INSERT INTO query_history (id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![id, connection_id, query_text, execution_time_ms, row_count, status, error_message, now], + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + + /// Fetch query history rows, optionally filtered by `connection_id`. + /// Returns results ordered by `executed_at DESC`. + pub fn get_query_history( + &self, + connection_id: Option<&str>, + limit: i64, + offset: i64, + ) -> Result, String> { + let conn = self.conn.lock().map_err(|e| e.to_string())?; + let (sql, params): (String, Vec>) = + if let Some(cid) = connection_id { + ( + "SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at FROM query_history WHERE connection_id = ?1 ORDER BY executed_at DESC LIMIT ?2 OFFSET ?3".to_string(), + vec![Box::new(cid.to_string()), Box::new(limit), Box::new(offset)], + ) + } else { + ( + "SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at FROM query_history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2".to_string(), + vec![Box::new(limit), Box::new(offset)], + ) + }; + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(rusqlite::params_from_iter(&refs), |row| { + Ok(crate::commands::query::QueryHistoryEntry { + id: row.get(0)?, + connection_id: row.get(1)?, + query_text: row.get(2)?, + execution_time_ms: row.get(3)?, + row_count: row.get(4)?, + status: row.get(5)?, + error_message: row.get(6)?, + executed_at: row.get(7)?, + }) + }) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + } + + /// Delete all query history rows, optionally filtered by `connection_id`. + pub fn clear_query_history(&self, connection_id: Option<&str>) -> Result<(), String> { + let conn = self.conn.lock().map_err(|e| e.to_string())?; + if let Some(cid) = connection_id { + conn.execute( + "DELETE FROM query_history WHERE connection_id = ?1", + params![cid], + ) + .map_err(|e| e.to_string())?; + } else { + conn.execute("DELETE FROM query_history", []) + .map_err(|e| e.to_string())?; + } + Ok(()) + } } #[cfg(test)] @@ -690,7 +767,7 @@ mod tests { #[test] fn ssh_ssl_fields_persist_and_retrieve() { let store = fresh_store(); - let conn = store + let _conn = store .create_connection(ConnectionInput { name: "SSH-Tunnel-DB".into(), db_type: "postgresql".into(), diff --git a/src/components/connections/ConnectionGrid.test.tsx b/src/components/connections/ConnectionGrid.test.tsx index 7d039e7..a729457 100644 --- a/src/components/connections/ConnectionGrid.test.tsx +++ b/src/components/connections/ConnectionGrid.test.tsx @@ -1,13 +1,14 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ConnectionGrid } from "./ConnectionGrid"; -import type { Connection, Folder } from "../../lib/types"; +import { useUiStore } from "../../stores/uiStore"; +import type { Connection, Folder, Tag } from "../../lib/types"; const makeConn = (id: string, folder_id: string | null = null): Connection => ({ id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432, username: null, folder_id, keychain_ref: null, tag_ids: [], - created_at: "", updated_at: "", + created_at: "", updated_at: "", environment: null, }); const folders: Folder[] = [ @@ -17,6 +18,10 @@ const folders: Folder[] = [ ]; describe("ConnectionGrid", () => { + beforeEach(() => { + useUiStore.setState({ activeTagIds: [], activeDbTypes: [], activeEnvironment: null }); + }); + it("renders empty state when no connections and no folders", () => { render(); expect(screen.getByText(/no connections yet/i)).toBeInTheDocument(); @@ -66,4 +71,87 @@ describe("ConnectionGrid", () => { expect(screen.getByText("Work")).toBeInTheDocument(); expect(screen.getByText("Personal")).toBeInTheDocument(); }); + + it("hides folders that match no tags and contain no matching connections", () => { + useUiStore.setState({ activeTagIds: ["t1"] }); + const taggedFolders: Folder[] = [ + { id: "f1", name: "Tagged Folder", parent_id: null, tag_ids: ["t1"], created_at: "", updated_at: "" }, + { id: "f2", name: "Untagged Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + ]; + const tags: Tag[] = [{ id: "t1", name: "prod", color: "#f00", created_at: "" }]; + render(); + expect(screen.getByText("Tagged Folder")).toBeInTheDocument(); + expect(screen.queryByText("Untagged Folder")).not.toBeInTheDocument(); + }); + + it("shows folder when it contains a matching connection even if untagged", () => { + useUiStore.setState({ activeTagIds: ["t1"] }); + const foldersWithConn: Folder[] = [ + { id: "f1", name: "Parent", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + ]; + const conns = [makeConn("c1", "f1")]; + conns[0] = { ...conns[0], tag_ids: ["t1"] }; + const tags: Tag[] = [{ id: "t1", name: "prod", color: "#f00", created_at: "" }]; + render(); + expect(screen.getByText("Parent")).toBeInTheDocument(); + }); + + it("shows all folders when no tag filter is active", () => { + useUiStore.setState({ activeTagIds: [] }); + render(); + expect(screen.getByText("Work")).toBeInTheDocument(); + expect(screen.getByText("Personal")).toBeInTheDocument(); + }); + + it("hides folders whose connections don't match the DB type filter", () => { + useUiStore.setState({ activeDbTypes: ["sqlite"] }); + const typedFolders: Folder[] = [ + { id: "f1", name: "PG Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + { id: "f2", name: "SQLite Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + ]; + const conns: Connection[] = [ + { ...makeConn("c1", "f1"), db_type: "postgresql" }, + { ...makeConn("c2", "f2"), db_type: "sqlite" }, + ]; + render(); + expect(screen.queryByText("PG Folder")).not.toBeInTheDocument(); + expect(screen.getByText("SQLite Folder")).toBeInTheDocument(); + }); + + it("hides folders whose connections don't match the environment filter", () => { + useUiStore.setState({ activeEnvironment: "production" }); + const envFolders: Folder[] = [ + { id: "f1", name: "Prod Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + { id: "f2", name: "Dev Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + ]; + const conns: Connection[] = [ + { ...makeConn("c1", "f1"), environment: "production" }, + { ...makeConn("c2", "f2"), environment: "development" }, + ]; + render(); + expect(screen.getByText("Prod Folder")).toBeInTheDocument(); + expect(screen.queryByText("Dev Folder")).not.toBeInTheDocument(); + }); + + it("shows search results from all folders as if at root", () => { + useUiStore.setState({ activeFolderId: "f1", searchQuery: "conn" }); + const searchFolders: Folder[] = [ + { id: "f1", name: "Folder 1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + { id: "f2", name: "Folder 2", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + ]; + const conns = [makeConn("c1", "f1"), makeConn("c2", "f2")]; + render( + + ); + expect(screen.getByText("Conn c2")).toBeInTheDocument(); + expect(screen.queryByText("Folder 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Folder 2")).not.toBeInTheDocument(); + expect(screen.getByText("Showing Search Results")).toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/src/components/connections/ConnectionGrid.tsx b/src/components/connections/ConnectionGrid.tsx index 3da58d3..bdc7efe 100644 --- a/src/components/connections/ConnectionGrid.tsx +++ b/src/components/connections/ConnectionGrid.tsx @@ -1,5 +1,4 @@ import type { Connection, Folder, Tag } from "../../lib/types"; -import { useMemo } from "react"; import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react"; import { useDroppable } from "@dnd-kit/core"; import { ConnectionCard } from "./ConnectionCard"; @@ -127,17 +126,47 @@ export function ConnectionGrid({ const selectedItemIds = useUiStore((s) => s.selectedItemIds); const toggleItemSelection = useUiStore((s) => s.toggleItemSelection); const clearSelection = useUiStore((s) => s.clearSelection); + const activeTagIds = useUiStore((s) => s.activeTagIds); + const activeDbTypes = useUiStore((s) => s.activeDbTypes); + const activeEnvironment = useUiStore((s) => s.activeEnvironment); - const currentFolderId = - activeFolderId !== null && folders.some((f) => f.id === activeFolderId) + const currentFolderId = hasSearch + ? null + : activeFolderId !== null && folders.some((f) => f.id === activeFolderId) ? activeFolderId : null; + const hasActiveFilters = + activeTagIds.length > 0 || + activeDbTypes.length > 0 || + (activeEnvironment !== null && activeEnvironment !== undefined); + + const connectionMatchesFilters = (c: Connection) => { + if (activeTagIds.length > 0 && !c.tag_ids.some((id) => activeTagIds.includes(id))) { + return false; + } + if (activeDbTypes.length > 0 && !activeDbTypes.includes(c.db_type)) { + return false; + } + if (activeEnvironment !== null && activeEnvironment !== undefined && c.environment !== activeEnvironment) { + return false; + } + return true; + }; + const visibleFolders = hasSearch ? [] - : getChildFolders(folders, currentFolderId); - const directConnections = connections.filter( - (c) => c.folder_id === currentFolderId, - ); + : getChildFolders(folders, currentFolderId).filter((f) => { + if (!hasActiveFilters) return true; + const folderMatchesTags = f.tag_ids.some((id) => activeTagIds.includes(id)); + if (folderMatchesTags) return true; + const subIds = new Set(getDescendantFolderIds(folders, f.id)); + return connections.some( + (c) => c.folder_id !== null && subIds.has(c.folder_id) && connectionMatchesFilters(c), + ); + }); + const directConnections = hasSearch + ? connections + : connections.filter((c) => c.folder_id === currentFolderId); const allStoreConnections = useConnectionStore((s) => s.connections); const allStoreFolders = useConnectionStore((s) => s.folders); // Check if any direct connections OR any subfolder has connections anywhere below @@ -171,6 +200,8 @@ export function ConnectionGrid({ folders={folders} activeFolderId={currentFolderId} onNavigate={handleBreadcrumbNavigate} + hasSearch={hasSearch} + onClearSearch={() => useUiStore.getState().clearFilters()} /> {activeFolder && (
diff --git a/src/components/connections/GeneralTab.test.tsx b/src/components/connections/GeneralTab.test.tsx index c6a7a2e..7aa3e8c 100644 --- a/src/components/connections/GeneralTab.test.tsx +++ b/src/components/connections/GeneralTab.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; import { GeneralTab } from "./GeneralTab"; import type { ConnectionFormData } from "./connectionFormData"; @@ -19,6 +19,18 @@ const BASE_FORM: ConnectionFormData = { }; describe("GeneralTab", () => { + it("renders a Name input and passes value to onChange", () => { + const onChange = vi.fn(); + render(); + + const nameInput = screen.getByLabelText("Name"); + expect(nameInput).toBeInTheDocument(); + expect(nameInput).toHaveValue(BASE_FORM.name); + + fireEvent.change(nameInput, { target: { value: "My New Name" } }); + expect(onChange).toHaveBeenCalledWith({ name: "My New Name" }); + }); + it("renders host, port, user, password, and database fields", () => { render( {}} />); diff --git a/src/components/connections/GeneralTab.tsx b/src/components/connections/GeneralTab.tsx index 21f4806..0d2da00 100644 --- a/src/components/connections/GeneralTab.tsx +++ b/src/components/connections/GeneralTab.tsx @@ -14,6 +14,16 @@ export function GeneralTab({ form, onChange }: GeneralTabProps) { return (
+
+ + onChange({ name: value })} + placeholder="My Production Database" + aria-label="Name" + /> +
+ {!isSqlite && (
diff --git a/src/components/db-viewer/ChangesQueuePanel.test.tsx b/src/components/db-viewer/ChangesQueuePanel.test.tsx index d9d4ba6..e15ffa9 100644 --- a/src/components/db-viewer/ChangesQueuePanel.test.tsx +++ b/src/components/db-viewer/ChangesQueuePanel.test.tsx @@ -28,6 +28,23 @@ describe("ChangesQueuePanel", () => { expect(screen.getByText(/users/i)).toBeInTheDocument(); }); + it("toggle button flips the store expanded state", async () => { + const user = userEvent.setup(); + useDbViewerStore.getState().addChange({ + type: "update", + schema: "public", + table: "users", + primaryKey: { id: 1 }, + oldData: { name: "Bob" }, + newData: { name: "Alice" }, + }); + render(); + await user.click(screen.getByText(/1 pending change/i)); + expect(useDbViewerStore.getState().changesPanelExpanded).toBe(false); + await user.click(screen.getByText(/1 pending change/i)); + expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true); + }); + it("cancel button changes status", async () => { const user = userEvent.setup(); useDbViewerStore.getState().addChange({ diff --git a/src/components/db-viewer/ChangesQueuePanel.tsx b/src/components/db-viewer/ChangesQueuePanel.tsx index ddb0df9..27af4c6 100644 --- a/src/components/db-viewer/ChangesQueuePanel.tsx +++ b/src/components/db-viewer/ChangesQueuePanel.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useCallback } from "react"; import { X, Check, ChevronUp, ChevronDown } from "lucide-react"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useUiStore } from "../../stores/uiStore"; @@ -58,7 +58,10 @@ export function ChangesQueuePanel() { const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted); const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed); const notify = useNotificationStore((state) => state.notify); - const [expanded, setExpanded] = useState(true); + const expanded = useDbViewerStore((state) => state.changesPanelExpanded); + const toggleChangesPanel = useDbViewerStore( + (state) => state.toggleChangesPanel, + ); const handleCommitAll = useCallback(async () => { const connectionId = useUiStore.getState().activeConnectionId; @@ -114,7 +117,7 @@ export function ChangesQueuePanel() {