diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..795614c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release + +# Builds Gridline installers for macOS (Apple Silicon + Intel), Windows, and +# Linux, then uploads them to a draft GitHub Release. +# +# Trigger: push a version tag from the `main` branch (production), e.g. +# git checkout main && git pull +# git tag v0.5.0 && git push origin v0.5.0 +# +# SIGNING STATUS: builds are UNSIGNED for now (no code-signing certs yet — +# see README "Download a release"). tauri-action automatically signs + +# notarizes when the signing secrets are present, so the moment we add +# APPLE_CERTIFICATE / APPLE_API_KEY / WINDOWS_CERTIFICATE (or Azure Trusted +# Signing) to repo secrets, future builds are signed — no changes to this +# file required. + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + publish: + strategy: + fail-fast: false + matrix: + include: + - platform: macos-latest # Apple Silicon (M1/M2/M3+) + args: --target aarch64-apple-darwin + - platform: macos-15-intel # Intel Macs (last Intel runner; retired ~Aug 2027) + args: --target x86_64-apple-darwin + - platform: ubuntu-22.04 # Linux x86_64 (.deb / .rpm / .AppImage) + args: '' + - platform: windows-latest # Windows x86_64 (NSIS .exe + .msi) + args: '' + runs-on: ${{ matrix.platform }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux dependencies + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin, x86_64-apple-darwin + + - name: Cache Rust build artifacts + uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + + - name: Install frontend dependencies + run: bun install --frozen-lockfile + + - name: Build and upload to GitHub Release + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tagName: ${{ github.ref_name }} + releaseName: 'Gridline ${{ github.ref_name }}' + releaseDraft: true + args: ${{ matrix.args }} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 37087b5..0cf0ade 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,11 +284,11 @@ cargo test # Rust tests ### Backup & Restore | Feature | Status | Details | | :--- | :---: | :--- | -| pg_dump wrapper | ✅ | Rust command spawns pg_dump with real-time progress events (backup-progress) | -| pg_restore wrapper | ✅ | Rust command spawns pg_restore with progress events | +| pg_dump wrapper | ✅ | Rust command spawns pg_dump with real-time progress events (backup-progress). Core logic extracted into headless-testable `run_pg_dump` (takes `PgConnParams` + options directly); Tauri command is a thin wrapper (store lookup → keychain → run → emit). Live integration tests in `backup.test.rs` (`#[ignore]`d, driven by `GRIDLINE_TEST_SRC_*`/`GRIDLINE_TEST_TGT_*` env vars) | +| pg_restore wrapper | ✅ | Rust command spawns pg_restore with progress events. **Plain-format dumps are executed via `psql -f`** (pg_restore can't read plain SQL text); custom/tar/directory use pg_restore. Core logic in headless-testable `run_pg_restore` | | Backup UI | ✅ | In-page view: format selector, file browse (Tauri dialog), schema dropdown, no-owner toggle, progress bar with event-driven status | -| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar | -| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore | +| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive | +| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` | | Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below | | SQLite .dump | ❌ | | | Table structure export (DDL) | ❌ | | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..85b7b83 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index 87db65b..6ff5ea0 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,117 @@ -# Gridline +

+ Gridline data grid with FK preview +

-A modern, open-source, high-performance database GUI client for PostgreSQL and beyond. Built with Tauri 2.0, Rust, and React — lightweight by design, powerful by default. +

Gridline

-> **Inspired by DB Pro and Beekeeper Studio's best ideas. Freed from their paywalls.** No caps on tabs, connections, or saved queries. Deep PostgreSQL tooling (`pg_dump`, `pg_restore`, DB-to-DB sync) that commercial alternatives lock behind paywalls or leave to the CLI. +

+ A lightweight, open-source database GUI for PostgreSQL and SQLite.
+ Unlimited connections, tabs, and saved queries — with first-class pg_dump, pg_restore, and DB-to-DB sync. +

+ +

+ Data Grid & FK Preview — browse 50+ real demo orders and inspect related rows in one click. +

+ +

+ Tauri + Rust + React + TypeScript + Apache 2.0 License + GitHub stars +

+ +

+ Download Latest + Open an Issue +

--- -## Why Gridline? +## What is Gridline? -Most database GUI clients either lock essential productivity features behind paywalls or treat PostgreSQL administration as an afterthought. Gridline is different: +Gridline is a modern, open-source database GUI client built with [Tauri 2.0](https://tauri.app), Rust, and React. It is lightweight by design (~40 MB baseline), powerful by default, and free from the paywalls that limit commercial alternatives. -| Capability | DB Pro (Free) | Beekeeper (Free) | Gridline | -| :--- | :---: | :---: | :---: | -| Open tabs | 3 | Unlimited | **Unlimited** | -| Saved connections | 2 | Unlimited | **Unlimited** | -| Saved queries | 5 | Unlimited | **Unlimited** | -| Data export (CSV, JSON, SQL) | ❌ (paid only) | Basic only | **JSON, CSV, SQL, Markdown** | -| Data import (CSV, JSON) | ❌ (paid only) | ✅ | **✅ CSV/JSON + column mapping** | -| pg_dump / pg_restore GUI | ❌ | ❌ (paid only) | **First-class UI** | -| DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** | -| Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** | -| Indexes, constraints, matviews, procedures | 🔒 paid | ❌ | **✅ Full object views** | -| ER diagram / schema visualizer | ❌ (planned) | ❌ (paid only) | **✅ Interactive React Flow** | -| Inline cell editing | ✅ | ✅ | **✅ Inline editing** (double-click / Enter, ctid/rowid locator, stale-write guard; optimistic staged values + pending dot from the changes queue; smart editors — PG enum dropdowns and searchable FK dropdowns of referenced rows) | -| Keyboard cell navigation | ✅ | ✅ | **✅** (arrows + Tab wrap, Esc cancels editing) | -| Cell copy (right-click / Ctrl+C) | ✅ | ✅ | **✅** (context menu: Copy / Copy JSON / Edit / Set NULL / Open FK / View Row / Select Row) | -| Row detail drawer | ✅ | ✅ | **✅** (via context menu View Row) | -| Visual filter builder | ✅ | ✅ | **✅** (drag-and-drop, type-aware operators) | -| SSH tunneling | 🟡 (likely paid) | ✅ | **✅ Full tunnel (password + key auth, keychain)** | -| OS credential vault | ✅ | ✅ | **Keychain / Secret Service** | -| Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** | -| Favorites / Recent connections | ✅ | ✅ | **✅ Star + recents row** | -| Connection status indicator | ❌ | ✅ | **✅ On-demand click-to-test dot** | -| Bulk move-to-folder | ✅ | ✅ | **✅ Selection toolbar → folder picker** | -| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** (tab-bar **Changes** button with count badge toggles a popover: Visual/SQL preview, per-change revert, Clear All, ⌘S commit) | -| Query history | ✅ (auto-saved) | ✅ | **✅ Toolbar dropdown, favorites, Queries view** | -| AI assistant | ✅ (BYO key) | ❌ (paid only) | 🔮 *Planned — BYOK* | -| Open source | ❌ | ✅ (GPLv3) | **✅ (MIT)** | -| Desktop shell | Native webview | Electron (~250MB) | **Tauri 2.0 (~40MB)** | +- **No caps** on connections, tabs, or saved queries. +- **Deep PostgreSQL tooling** — visual `pg_dump`, `pg_restore`, and DB-to-DB sync. +- **Full object explorer** — not just tables, but functions, triggers, sequences, enums, extensions, materialized views, and procedures. +- **Interactive ER diagram** — explore relationships visually with crow's-foot cardinality notation. +- **Production-safe editing** — stage INSERT/UPDATE/DELETE changes, review the generated SQL, then commit all at once. -> 🟡 = In progress or planned. **Bold** = Gridline's strongest differentiators. +The app ships with a built-in **Gridline Demo (SQLite)** database, so you can explore every feature immediately without setting up a server. + +--- + +## Who is it for? + +Gridline is built for developers and small teams who manage multiple database environments: + +- **Full-stack developers** switching between local, staging, and production databases. +- **Platform / DBAs** who need `pg_dump`, `pg_restore`, and sync tooling in a native UI. +- **Teams that want native speed** without Electron bloat or seat licenses. + +--- + +## Recent Changes + +- **2026-08-04:** Revamped the built-in SQLite demo database with realistic e-commerce data (20 users, 24 products, 50 orders, 100 page views, 500 audit rows) and renamed it to **Gridline Demo (SQLite)**. +- **2026-07-XX:** Added inline cell editing with a stage-first changes queue, row-detail drawer, keyboard navigation, and cell-level copy. +- **2026-07-XX:** Added visual filter builder with drag-and-drop column palette and type-aware operators. +- **2026-07-XX:** Added schema visualizer with React Flow + dagre, crow's-foot notation, and cross-schema FK support. +- **2026-07-XX:** Added query history dropdown, saved queries, and a two-pane Queries view. +- **2026-06-XX:** Added settings redesign with live theme, accent color, font size, editor options, and tag reorder. +- **2026-06-XX:** Added SSH tunneling (password + key auth) and full PostgreSQL TLS runtime for production connections. + +> See the full history in the [roadmap](#roadmap) below or the [git log](./commits). + +--- + +## Key Features + +### Connections & Workspace + +- **URI auto-fill** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` strings and have all fields populate automatically. +- **Workspace tree** — multi-level folders, color-coded tags, favorites, and recent connections. +- **OS keychain storage** — passwords and SSH secrets live in macOS Keychain / Linux Secret Service / Windows Credential Manager, never in plaintext. +- **SSH tunneling** — real `ssh2` tunnels with password or key authentication. +- **TLS / SSL** — full PostgreSQL/MySQL TLS modes plus client-certificate support. +- **Connection status** — on-demand per-card test with real server version and latency. + +### Schema Explorer + +- **Tables, views, and materialized views** — column metadata, PK/FK, defaults, nullable flags. +- **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation. +- **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher. +- **Indexes & constraints** — per-table index details plus CHECK/UNIQUE constraints beyond PK/FK. +- **Schema visualizer** — interactive ER diagram with auto-layout, cardinality legend, and collapsible columns. + +### Data Grid + +- **Virtualized rows** — handles 100k+ rows via `@tanstack/react-virtual`. +- **Server-side filtering & sorting** — pushed to SQL `WHERE`/`ORDER BY`. +- **FK preview** — click the ↗ icon on a foreign-key cell to inspect the referenced row or open a filtered tab. +- **JSON/JSONB viewer** — formatted and raw tabs with copy. +- **Inline cell editing** — double-click or press Enter; changes stage through the queue before commit. +- **Smart editors** — enum dropdowns for PG enum columns, searchable FK dropdowns for related rows. +- **Visual filter builder** — drag-and-drop columns with type-aware operators. +- **Export** — JSON, CSV, SQL, and Markdown downloads of visible rows. +- **Auto-refresh** — configurable interval timer. + +### Query Workbench + +- **Monaco SQL editor** — lazy-loaded, with keywords + table/column autocomplete. +- **Custom query execution** — arbitrary SQL with destructive-query confirmation. +- **Query tabs** — unlimited tabs, close with `Cmd/Ctrl+W`. +- **Query history** — per-connection, with favorites and pruning. +- **Saved queries** — name, folder, and manage them in the Queries view. +- **Changes queue** — stage edits, review generated SQL, revert per change, then commit all. + +### PostgreSQL Admin Tools + +- **Visual Backup** — `pg_dump` wrapper with format selector, schema filter, no-owner toggle, real-time progress. +- **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation. +- **DB-to-DB Sync** — pipe `pg_dump` → `pg_restore` between two connections. --- @@ -50,34 +122,34 @@ Most database GUI clients either lock essential productivity features behind pay
- - Saved connections home screen + + Query editor
- Home Screen — organized folders, tags, and quick search + Query Editor
- - Data grid with FK preview + + ER diagram
- Data Grid — virtualized rows, column controls, and FK preview + Schema Visualizer
- - Interactive ER diagram + + JSON popover
- Schema Visualizer — interactive ER diagram with cardinality legend + JSON Viewer
- - Enum detail view + + Home screen
- Object Explorer — deep PostgreSQL objects like enums, functions, and triggers + Home Screen
@@ -86,112 +158,139 @@ Most database GUI clients either lock essential productivity features behind pay --- -## Features +## Why Gridline vs the alternatives? -### Connection & Workspace Management -- **URI Parser** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` connection strings and have all fields auto-populated -- **Workspace Tree** — multi-level hierarchy: `Workspace → Folder → Connection`, with color-coded tags (red = Production, green = Local) -- **Credential Security** — passwords stored in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager), never as plaintext -- **Favorites & Recent Connections** — star your go-to connections; the Home screen surfaces a Recent row -- **Connection Status Indicator** — on-demand per-card dot that tests the connection through the keychain + `testConnection` (idle/checking/online/offline) -- **Bulk Move-to-Folder** — select multiple connections and move them to a folder in one step -- **Production Safeguards** — read-only locks and high-visibility warnings on connections tagged `Production` - -### PostgreSQL Object Explorer -Full tree-view navigation of all native PostgreSQL schema objects: -- **Tables & Views** — columns, types, defaults, nullability, primary/foreign keys with popover preview -- **Functions & Procedures** — source code with syntax highlighting and line numbers, argument signatures, return types, overload support -- **Triggers & Rules** — event bindings with inline definition inspection, color-coded enabled/disabled status -- **Sequences & Enums** — current values, increments, cycle flags; enum labels in bordered list view -- **Extensions** — installed extensions with version, schema, and comment -- **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 -- **Unified Objects View** — Functions, Triggers, Sequences, Enums, and Extensions share a single sidebar with an object-type dropdown switcher (title position), refresh/search, and db/schema selectors -- **Indexes & Constraints** — per-table index list (columns, method, unique/partial flags) and CHECK/UNIQUE constraints beyond PK/FK -- **Materialized Views** — distinct icon in the table tree, browsable (read-only) -- **Stored Procedures** — dedicated Procedures object type (`prokind='p'`); Functions now filters `prokind='f'` - -### SQL Editor & Query Workbench -- **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/import/drop changes; preview before committing all. The tab bar's **Changes** button (checklist icon + amber pending border + count badge) toggles a popover with a **Visual/SQL** preview toggle, per-change revert, **Clear All** / **Commit All (N)** footer and a **⌘S** shortcut — the single entry point -- **Smart Default Sort** — auto-detects `updated_at`, `created_at`, `_id` columns for logical initial sorting -- **Query History** — recent queries per connection in a toolbar dropdown (load / run / favorite / clear), consecutive-identical dedup, retention pruned to 500 per connection -- **Saved Queries** — save the current query with a name + folder from the toolbar; manage them in the Queries view -- **Queries View** — two-pane workspace: an Explorer-styled sidebar with History / Saved Queries (per-connection scope, favorites filter, animated search) beside the tabbed query editor; click any row to load it into the editor - -### Data Grid & Schema Browser -- **Virtualized Grid** — row-level virtualization via `@tanstack/react-virtual` handles 100k+ rows -- **Column Management** — resize with drag handles (double-click to auto-fit), show/hide per column, multi-column sort -- **Server-Side Filtering & Sorting** — filters and sorts pushed to SQL WHERE/ORDER BY -- **Export** — JSON, CSV, SQL, Markdown via toolbar -- **FK Preview** — ↗ icon at the start of FK cells (or context menu → Open FK reference) opens a popover with the referenced row; opens a filtered tab on demand -- **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button -- **Auto-Refresh** — configurable interval timer -- **Inline Cell Editing** — double-click/Enter to edit cells; changes stage through the queue → Commit All (ctid/rowid locator for no-PK tables, PK/generated/identity read-only, stale-write guard via affected-row-count). Cells show the staged value + an amber pending dot immediately (queue is the source of truth; dot clears on commit, values survive until refetch); queue cards show an old → new diff; re-editing a cell replaces its queue entry -- **Smart Cell Editors** — PG enum columns edit via a dropdown of enum labels; FK columns edit via a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling) -- **Keyboard Navigation** — arrow keys + Tab/Shift+Tab wrap; Esc cancels editing even when the editor has lost focus -- **Cell Copy + Context Menu** — right-click / Ctrl+C copy; menu items: Copy, Copy JSON, Edit, Set NULL, Open FK reference, View Row, Select Row; closes on outside click/Esc -- **Visual Filter Builder** — drag-and-drop column palette with type-aware operators (AND semantics, persists per tab) - -### PostgreSQL Administrative Tools -- **Visual Backup** — `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar -- **Visual Restore** — `pg_restore` wrapper with file browser, format, clean toggle, destructive confirmation checkbox -- **DB-to-DB Sync** — pipe `pg_dump` → `pg_restore` between two connections with source/target pickers, schema filter, flow indicator -- **Unified Tools View** — Backup, Restore, and DB-to-DB Sync grouped under one **Tools** view with an operation-switcher dropdown - -### App Portability -- **Export** — save all workspaces, folders, saved queries, tags, and non-sensitive metadata to a single JSON archive -- **Import** — restore your exact workspace setup on any machine instantly +| Capability | DB Pro (Free) | Beekeeper (Free) | Gridline | +| :----------------------------- | :------------: | :----------------: | :---------------------------------------------------: | +| Open tabs | 3 | Unlimited | **Unlimited** | +| Saved connections | 2 | Unlimited | **Unlimited** | +| Saved queries | 5 | Unlimited | **Unlimited** | +| Data export (CSV, JSON, SQL) | ❌ paid only | Basic only | **JSON, CSV, SQL, Markdown** | +| `pg_dump` / `pg_restore` GUI | ❌ | ❌ paid only | **First-class UI** | +| DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** | +| Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** | +| ER diagram / schema visualizer | ❌ | ❌ paid only | **✅ React Flow + dagre** | +| SSH tunneling | 🔒 likely paid | ✅ | **✅ Password + key auth, keychain** | +| OS credential vault | ✅ | ✅ | **Keychain / Secret Service / Credential Manager** | +| Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** | +| Changes queue (stage → commit) | ❌ | ❌ | **✅ Queue → Commit All** | +| Desktop shell size | Native | Electron (~250 MB) | **Tauri 2.0 (~40 MB)** | +| Open source | ❌ | ✅ GPLv3 | **✅ Apache 2.0** | --- ## Tech Stack -| Layer | Technology | Role | +| Layer | Technology | Role | +| :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------- | +| **Desktop shell** | [Tauri 2.0](https://tauri.app) | Native webview container (~40 MB baseline) | +| **Backend** | Rust + [tokio](https://tokio.rs) | Async runtime, connection pooling, CLI execution | +| **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | Pure-Rust PostgreSQL (SQLite via rusqlite) | +| **CLI integration** | `std::process::Command` | Wraps system `pg_dump` / `pg_restore` | +| **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 | Domain stores | +| **Editor** | [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing | +| **Data grid** | [TanStack Virtual](https://tanstack.com/virtual) | 100k+ row virtualization | +| **Local store** | SQLite via [rusqlite](https://github.com/rusqlite/rusqlite) | Settings, saved queries, workspace state | + +--- + +## Getting Started + +### Download a release + +Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/adrianbonpin/gridline/releases) page. + +> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/adrianbonpin/gridline/issues/new) when you hit them. + +#### Installers are unsigned (for now) + +Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet. Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee: + +- **macOS:** right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version. +- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**. +- **Linux:** no warning — install and run normally. + +Code signing **will be added in the future** (Apple Developer Program + a Windows signing cert, e.g. Azure Trusted Signing) — the CI workflow is already wired to pick up the signing secrets automatically the moment they exist, no workflow changes needed. + +#### Which file should I download? + +Each release contains **one file per platform** — you only need the one that matches your computer. (If a newer version is available, swap `0.6.0` for the version shown in the release title.) + +| Your system | Download this | Notes | | :--- | :--- | :--- | -| **Desktop Shell** | [Tauri 2.0](https://tauri.app) | Native desktop container (~40MB RAM baseline) | -| **Backend** | Rust + [tokio](https://tokio.rs) | Async runtime, connection pooling, CLI tool execution | -| **Database Drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | Pure-Rust async PostgreSQL (MySQL, SQLite to follow) | -| **CLI Integration** | `std::process::Command` | Wraps system `pg_dump` / `pg_restore` binaries | -| **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** | [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 | +| macOS **Apple Silicon** (M1/M2/M3/M4…) | `Gridline_0.6.0_aarch64.dmg` | `aarch64` = Apple's own chip | +| macOS **Intel** | `Gridline_0.6.0_x64.dmg` | `x64` = Intel/AMD | +| **Windows** (most PCs) | `Gridline_0.6.0_x64-setup.exe` | The `.msi` is an alternate installer (for enterprises/IT admins) | +| **Debian / Ubuntu** | `Gridline_0.6.0_amd64.deb` | Install: `sudo apt install ./Gridline_0.6.0_amd64.deb` | +| **Fedora / RHEL / openSUSE** | `Gridline-0.6.0-1.x86_64.rpm` | Install: `sudo dnf install Gridline-0.6.0-1.x86_64.rpm` | +| **Any other Linux** | `Gridline_0.6.0_amd64.AppImage` | Works on every distro: `chmod +x` the file, then double-click it | + +**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run. + +#### How releases are made + +Cutting a release is one command — CI builds everything. **Releases are cut from `main`, which is the production branch** — only push release tags from `main`, never from feature branches: + +```bash +git checkout main && git pull +git tag v0.6.0 +git push origin v0.6.0 +``` + +GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**. + +Before tagging, make sure the version number is in sync across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`. + +### Build from source + +```bash +# 1. Clone the repository +git clone https://github.com/adrianbonpin/gridline.git +cd gridline + +# 2. Install frontend dependencies +bun install + +# 3. Run in development mode with hot-reload +bun run tauri dev + +# 4. Build for production +bun run tauri build +``` + +### System requirements + +- **macOS:** 13 (Ventura) or newer +- **Windows:** 10 or newer +- **Linux:** Ubuntu 22.04+ or equivalent modern distribution +- **RAM:** 8 GB recommended +- **PostgreSQL client tools:** `pg_dump` and `pg_restore` are required for admin features --- ## Development -### Prerequisites -- [Rust](https://rustup.rs) (latest stable) -- [Bun](https://bun.sh) (or Node.js + npm) -- Tauri system dependencies ([see guide](https://tauri.app/start/prerequisites/)) -- PostgreSQL client tools (`pg_dump`, `pg_restore`) for admin features - -### Setup - ```bash -# Clone -git clone https://github.com/adrianbonpin/gridline.git -cd gridline +# Frontend only (Vite dev server) +bun run dev -# Install frontend dependencies -bun install - -# Run in development mode (hot-reload) +# Full Tauri app with hot-reload bun run tauri dev -# Build for production +# Production build bun run tauri build + +# Rust backend only +cd src-tauri +cargo build + +# Run tests +cargo test ``` -### Project Structure +### Project structure ``` gridline/ @@ -217,64 +316,61 @@ gridline/ └── vite.config.ts ``` -### Architecture - -``` -┌──────────────────────────────────────────────────┐ -│ Tauri Shell │ -│ ┌─────────────────┐ ┌────────────────────────┐ │ -│ │ React (WebView)│ │ Rust Backend │ │ -│ │ │ │ │ │ -│ │ • Monaco Editor │◄──►│ • sqlx/tokio-postgres │ │ -│ │ • Glide Grid │IPC│ • Connection pool │ │ -│ │ • Tailwind UI │ │ • pg_dump/restore │ │ -│ │ • Zustand state │ │ • SQLite (local) │ │ -│ │ │ │ • OS Keychain │ │ -│ └─────────────────┘ └────────────────────────┘ │ -└──────────────────────────────────────────────────┘ - │ - ▼ - ┌─────────────────────┐ - │ PostgreSQL / MySQL │ - │ SQLite / Redis │ - └─────────────────────┘ -``` - --- ## Roadmap ### ✅ Completed -- **Phase 1 — Core Shell** — Tauri 2.0 + React project, glassmorphic dark-first UI, Zustand state management, SQLite local persistence, OS keychain credentials -- **Phase 2 — Connection Management** — Rust connection pool (`sqlx`/`tokio-postgres`), PostgreSQL + SQLite browse/query, URI parser with auto-population, SSH/SSL config UI, connection testing for all DB types -- **Phase 3 — Schema Explorer** — Full PostgreSQL `pg_catalog`/`information_schema` introspection, object tree (Tables, Views, Functions, Triggers, Enums, Sequences, Extensions), per-type detail views, FK preview popover, JSON/JSONB viewer -- **Phase 4 — Data Grid & Filters** — Virtualized grid (`@tanstack/react-virtual`, 100k+ rows), server-side sorting/filtering, column show/hide, column resize, export (JSON/CSV/SQL/Markdown), auto-refresh, pagination -- **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, feature-rich demo SQLite database (12 objects incl. a view, 500-row audit table, JSON/BLOB/composite-PK/no-PK/empty tables) with re-add + regenerate actions in Settings -- **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 -- **Query History & Saved Queries** — toolbar history dropdown (load / run / favorite / clear), favorites, consecutive-identical dedup + 500-retention pruning, SaveQueryDialog, and a two-pane Queries view (History / Saved Queries sidebar scoped per connection + tabbed query workspace) -- **Consolidated Navigation** — merged Functions/Triggers/Sequences/Enums/Extensions into a single Objects view (object-type dropdown) and Backup/Restore/DB Sync into a single Tools view (operation dropdown) -- **Settings (Redesigned & Fully Wired)** — DB-viewer-styled settings screen (icon+text sidebar, tab-titled header, border-sharp no-card sections, Back returns to origin view); all settings functional: theme (light/dark/system, applied live + native macOS Overlay titlebar sync), font size, **accent color** (circle palette), default folder on startup, confirm-before-delete toggle, default ports prefill; drag-and-drop tag reorder -- **Editor Settings, SSH/SSL Runtime, Data Import** — Monaco editor options (font size/family, word wrap, minimap, tab size) applied live; real SSH tunnel (`ssh2`, password + key auth, keychain secrets, full lifecycle) and TLS (`rustls`, all modes + client certs) for PostgreSQL/MySQL; CSV/JSON import with preview + column mapping through the changes queue; table-menu loose ends (Copy table schema DDL, Empty/Delete Table via queue, export stubs wired) -- **v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL** — inline cell editing (ctid/rowid locator, stale-write guard), keyboard navigation, cell copy + context menu, row-detail drawer, visual filter builder, bulk move-to-folder, favorites + recents, on-demand connection status indicator, PG Indexes/Constraints/Materialized Views/Stored Procedures object views; version 0.5.0. + +- Tauri 2.0 + React 19 + TypeScript 5.8 project shell +- PostgreSQL and SQLite browse/query support +- Connection management with URI parser, SSH tunnels, TLS, OS keychain +- Workspace tree, folders, tags, favorites, recents +- Multi-tab DB viewer with virtualized grid, server-side filtering/sorting +- FK preview, JSON popover, inline cell editing, changes queue +- Query editor with Monaco, autocomplete, destructive-query guard +- Query history, saved queries, and Queries view +- Full PostgreSQL object explorer + schema visualizer +- Backup, restore, and DB-to-DB sync tools +- Settings redesign with theme, accent color, editor options +- Built-in **Gridline Demo (SQLite)** database ### 🔮 Future -- **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL -- **Query Workbench** — Multiple result sets, visual query builder -- **Deeper PostgreSQL** — user/role management, replication -- **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. + +- **Multi-DB parity** — MySQL browsing, Redis key browser +- **Query workbench** — multiple result sets, visual query builder +- **Deeper PostgreSQL** — user/role management, replication views +- **Notebook reports** — SQL-backed markdown reports with embedded results +- **AI assistant (BYOK)** — bring-your-own-key natural-language → SQL, query explanations, and schema summaries + +--- + +## Table of Contents + +- [What is Gridline?](#what-is-gridline) +- [Who is it for?](#who-is-it-for) +- [Recent Changes](#recent-changes) +- [Key Features](#key-features) +- [Screenshots](#screenshots) +- [Why Gridline vs the alternatives?](#why-gridline-vs-the-alternatives) +- [Tech Stack](#tech-stack) +- [Getting Started](#getting-started) +- [Development](#development) +- [Roadmap](#roadmap) +- [Contributing](#contributing) +- [License](#license) + +--- + +## Contributing + +Contributions, bug reports, and feature ideas are welcome. Gridline is Apache 2.0-licensed and intentionally stays open — no paywalled tiers, no bundled proprietary services. + +- Open a [GitHub Issue](https://github.com/adrianbonpin/gridline/issues/new) for bugs or ideas. +- Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions. --- ## License -MIT — see [LICENSE](./LICENSE) for details. - ---- - -

- Built with ♥ for developers who believe powerful tools should be free. -

\ No newline at end of file +Apache 2.0 — see [LICENSE](./LICENSE) for details. diff --git a/package.json b/package.json index 9389d99..2312a4b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gridline", "private": true, - "version": "0.5.0", + "version": "0.6.0", "description": "An open-source, high-performance database GUI client for PostgreSQL and beyond", "type": "module", "scripts": { diff --git a/screenshots/data-grid.png b/screenshots/data-grid.png index 5208ba1..60a7ae3 100644 Binary files a/screenshots/data-grid.png and b/screenshots/data-grid.png differ diff --git a/screenshots/enums.png b/screenshots/enums.png deleted file mode 100644 index b5eb436..0000000 Binary files a/screenshots/enums.png and /dev/null differ diff --git a/screenshots/er-diagram.png b/screenshots/er-diagram.png index b0e7224..33881ca 100644 Binary files a/screenshots/er-diagram.png and b/screenshots/er-diagram.png differ diff --git a/screenshots/home.png b/screenshots/home.png index b4a986a..a96f749 100644 Binary files a/screenshots/home.png and b/screenshots/home.png differ diff --git a/screenshots/json-popover.png b/screenshots/json-popover.png new file mode 100644 index 0000000..f7c8a14 Binary files /dev/null and b/screenshots/json-popover.png differ diff --git a/screenshots/query-editor.png b/screenshots/query-editor.png new file mode 100644 index 0000000..ce246f9 Binary files /dev/null and b/screenshots/query-editor.png differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 565a710..18305d1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1783,7 +1783,7 @@ dependencies = [ [[package]] name = "gridline" -version = "0.5.0" +version = "0.6.0" dependencies = [ "chrono", "deadpool-postgres", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bdd9fd5..69a87b8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gridline" -version = "0.5.0" +version = "0.6.0" description = "An open-source, high-performance database GUI client for PostgreSQL and beyond" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index 2e0bac6..80f96d3 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -3,6 +3,37 @@ use tauri::{AppHandle, Emitter, State}; use crate::models::backup::*; +// --------------------------------------------------------------------------- +// Connection params (decoupled from store/keychain so logic is headless-testable) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct PgConnParams { + pub host: String, + pub port: i64, + pub username: String, + pub database: String, + pub password: String, +} + +impl PgConnParams { + pub fn new( + host: String, + port: i64, + username: String, + database: String, + password: String, + ) -> Self { + Self { + host, + port, + username, + database, + password, + } + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -16,6 +47,10 @@ fn get_version(tool: &str) -> Option { .map(|s| s.trim().to_string()) } +fn sanitize_error(s: &str) -> String { + crate::commands::test_connection::sanitize_error(s) +} + // --------------------------------------------------------------------------- // detect_pg_tools // --------------------------------------------------------------------------- @@ -31,9 +66,237 @@ pub fn detect_pg_tools() -> PgToolStatus { } // --------------------------------------------------------------------------- -// pg_dump +// Core logic (headless-testable — no Tauri, no store, no keychain) // --------------------------------------------------------------------------- +/// Builds the base connection args shared by pg_dump and pg_restore. +fn base_conn_args(conn: &PgConnParams) -> Vec { + vec![ + format!("--host={}", conn.host), + format!("--port={}", conn.port), + format!("--username={}", conn.username), + format!("--dbname={}", conn.database), + ] +} + +/// Builds pg_dump args (excluding the --file flag, which is added by the caller). +fn build_dump_args(conn: &PgConnParams, options: &BackupOptions) -> Vec { + let mut args = base_conn_args(conn); + + match options.format.as_str() { + "custom" => args.push("--format=c".into()), + "tar" => args.push("--format=t".into()), + "directory" => args.push("--format=d".into()), + _ => {} // "plain" is the default — no format flag needed + } + + if options.no_owner { + args.push("--no-owner".into()); + } + + if let Some(ref schema) = options.schema { + args.push(format!("--schema={schema}")); + } + + if let Some(ref tables) = options.tables { + for t in tables { + args.push(format!("--table={t}")); + } + } + + args +} + +/// Builds pg_restore args (file path is passed positionally by the caller). +fn build_restore_args(conn: &PgConnParams, options: &RestoreOptions) -> Vec { + let mut args = base_conn_args(conn); + + match options.format.as_str() { + "custom" => args.push("--format=c".into()), + "tar" => args.push("--format=t".into()), + "directory" => args.push("--format=d".into()), + _ => {} + } + + if options.clean { + args.push("--clean".into()); + args.push("--if-exists".into()); + } + + if let Some(ref schema) = options.schema { + args.push(format!("--schema={schema}")); + } + + args +} + +/// Runs `pg_dump` against `conn`, writing to `options.file_path`. +/// Returns `Ok(())` on success or a sanitized error message. +pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), String> { + let mut args = build_dump_args(conn, options); + args.push(format!("--file={}", options.file_path)); + + let result = Command::new("pg_dump") + .env("PGPASSWORD", &conn.password) + .args(&args) + .output(); + + match result { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))), + Err(e) => Err(e.to_string()), + } +} + +/// Runs `pg_restore` against `conn`, reading from `options.file_path`. +/// Returns `Ok(())` on success or a sanitized error message. +/// +/// Plain-format dumps are SQL text and cannot be read by `pg_restore` — they +/// are executed with `psql` instead. The `clean` option is only honored for +/// archive formats (custom/tar/directory); the UI disables it for plain. +pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(), String> { + if options.format == "plain" { + let result = Command::new("psql") + .env("PGPASSWORD", &conn.password) + .args([ + format!("--host={}", conn.host), + format!("--port={}", conn.port), + format!("--username={}", conn.username), + format!("--dbname={}", conn.database), + format!("--file={}", options.file_path), + ]) + .output(); + + return match result { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))), + Err(e) => Err(e.to_string()), + }; + } + + let mut args = build_restore_args(conn, options); + args.push(options.file_path.clone()); + + let result = Command::new("pg_restore") + .env("PGPASSWORD", &conn.password) + .args(&args) + .output(); + + match result { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))), + Err(e) => Err(e.to_string()), + } +} + +/// Runs a DB-to-DB sync: `pg_dump` on `source` piped into `pg_restore` on `target`. +/// Returns `Ok(())` on success or a sanitized error message. +pub fn run_db_sync( + source: &PgConnParams, + target: &PgConnParams, + schema: Option<&str>, + tables: Option<&[String]>, +) -> Result<(), String> { + // --- Build pg_dump args --- + let mut dump_args = base_conn_args(source); + dump_args.push("--format=c".into()); // binary custom format for reliable piping + dump_args.push("--no-owner".into()); + + if let Some(schema) = schema { + dump_args.push(format!("--schema={schema}")); + } + + if let Some(tables) = tables { + for t in tables { + dump_args.push(format!("--table={t}")); + } + } + + // --- Build pg_restore args --- + // --clean --if-exists makes sync work into a non-empty target (the UI + // already requires a destructive-overwrite confirmation). + let mut restore_args = base_conn_args(target); + restore_args.push("--no-owner".into()); + restore_args.push("--clean".into()); + restore_args.push("--if-exists".into()); + + // --- Spawn pg_dump with piped stdout --- + let mut dump_child = Command::new("pg_dump") + .env("PGPASSWORD", &source.password) + .args(&dump_args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start pg_dump: {e}"))?; + + let dump_stdout = dump_child.stdout.take().unwrap(); + let dump_stderr_reader = dump_child.stderr.take().unwrap(); + + // Read pg_dump stderr in a separate thread so the pipe doesn't block + let dump_stderr_handle = std::thread::spawn(move || { + use std::io::Read; + let mut buf = String::new(); + let _ = dump_stderr_reader + .take(10 * 1024 * 1024) // cap at 10 MiB + .read_to_string(&mut buf); + buf + }); + + // --- Run pg_restore with pg_dump stdout as stdin --- + let restore_result = Command::new("pg_restore") + .env("PGPASSWORD", &target.password) + .args(&restore_args) + .stdin(dump_stdout) + .output(); + + // Wait for pg_dump to finish + let dump_status = dump_child.wait(); + let dump_stderr = dump_stderr_handle.join().unwrap_or_default(); + + // --- Check results --- + let dump_failed = match dump_status { + Ok(status) => !status.success(), + Err(_) => true, + }; + + if dump_failed { + return Err(format!("pg_dump failed: {}", sanitize_error(&dump_stderr))); + } + + match restore_result { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(format!( + "pg_restore failed: {}", + sanitize_error(&String::from_utf8_lossy(&output.stderr)) + )), + Err(e) => Err(format!("pg_restore failed: {e}")), + } +} + +// --------------------------------------------------------------------------- +// Tauri commands (thin wrappers: store lookup + keychain + event emission) +// --------------------------------------------------------------------------- + +fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>) { + let event = match result { + Ok(()) => BackupProgressEvent { + job_id: job_id.to_string(), + status: "completed".into(), + progress: Some(1.0), + output_line: None, + error: None, + }, + Err(e) => BackupProgressEvent { + job_id: job_id.to_string(), + status: "failed".into(), + progress: None, + output_line: None, + error: Some(e), + }, + }; + let _ = app_handle.emit("backup-progress", event); +} + #[tauri::command] pub async fn pg_dump( connection_id: String, @@ -45,10 +308,7 @@ pub async fn pg_dump( // Get connection from store (scope the std::sync::Mutex lock guard) let conn = { - let store = state - .db_store - .lock() - .map_err(|e| e.to_string())?; + let store = state.db_store.lock().map_err(|e| e.to_string())?; let connections = store.get_connections().map_err(|e| e.to_string())?; connections .into_iter() @@ -57,112 +317,30 @@ pub async fn pg_dump( }; // Get password from keychain - let password = crate::commands::keychain::get_connection_password_internal( - &app_handle, - &connection_id, - ) - .unwrap_or_default() - .unwrap_or_default(); + let password = + crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id) + .unwrap_or_default() + .unwrap_or_default(); - // Extract connection fields before moving into spawn_blocking - let host = conn.host.clone(); - let port = conn.port.unwrap_or(5432); - let username = conn.username.unwrap_or_else(|| "postgres".into()); - let database = conn.database.unwrap_or_else(|| "postgres".into()); - let file_path = options.file_path.clone(); - let format = options.format.clone(); - let no_owner = options.no_owner; - let schema = options.schema.clone(); - let tables = options.tables.clone(); + let params = PgConnParams::new( + conn.host.clone(), + conn.port.unwrap_or(5432), + conn.username.unwrap_or_else(|| "postgres".into()), + conn.database.unwrap_or_else(|| "postgres".into()), + password, + ); let job_id_clone = job_id.clone(); + let app_handle_clone = app_handle.clone(); tokio::task::spawn_blocking(move || { - let mut args: Vec = vec![ - format!("--host={host}"), - format!("--port={port}"), - format!("--username={username}"), - format!("--dbname={database}"), - ]; - - match format.as_str() { - "custom" => args.push("--format=c".into()), - "tar" => args.push("--format=t".into()), - "directory" => args.push("--format=d".into()), - _ => {} // "plain" is the default — no format flag needed - } - - if no_owner { - args.push("--no-owner".into()); - } - - if let Some(ref schema) = schema { - args.push(format!("--schema={schema}")); - } - - if let Some(ref tables) = tables { - for t in tables { - args.push(format!("--table={t}")); - } - } - - args.push(format!("--file={file_path}")); - - let result = Command::new("pg_dump") - .env("PGPASSWORD", &password) - .args(&args) - .output(); - - match result { - Ok(output) if output.status.success() => { - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "completed".into(), - progress: Some(1.0), - output_line: None, - error: None, - }, - ); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "failed".into(), - progress: None, - output_line: None, - error: Some( - crate::commands::test_connection::sanitize_error(&stderr), - ), - }, - ); - } - Err(e) => { - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "failed".into(), - progress: None, - output_line: None, - error: Some(e.to_string()), - }, - ); - } - } + let result = run_pg_dump(¶ms, &options); + emit_result(&app_handle_clone, &job_id_clone, result); }); Ok(job_id) } -// --------------------------------------------------------------------------- -// pg_restore -// --------------------------------------------------------------------------- - #[tauri::command] pub async fn pg_restore( connection_id: String, @@ -173,10 +351,7 @@ pub async fn pg_restore( let job_id = uuid::Uuid::new_v4().to_string(); let conn = { - let store = state - .db_store - .lock() - .map_err(|e| e.to_string())?; + let store = state.db_store.lock().map_err(|e| e.to_string())?; let connections = store.get_connections().map_err(|e| e.to_string())?; connections .into_iter() @@ -184,105 +359,30 @@ pub async fn pg_restore( .ok_or_else(|| format!("Connection not found: {connection_id}"))? }; - let password = crate::commands::keychain::get_connection_password_internal( - &app_handle, - &connection_id, - ) - .unwrap_or_default() - .unwrap_or_default(); + let password = + crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id) + .unwrap_or_default() + .unwrap_or_default(); - let host = conn.host.clone(); - let port = conn.port.unwrap_or(5432); - let username = conn.username.unwrap_or_else(|| "postgres".into()); - let database = conn.database.unwrap_or_else(|| "postgres".into()); - let file_path = options.file_path.clone(); - let format = options.format.clone(); - let clean = options.clean; - let schema = options.schema.clone(); + let params = PgConnParams::new( + conn.host.clone(), + conn.port.unwrap_or(5432), + conn.username.unwrap_or_else(|| "postgres".into()), + conn.database.unwrap_or_else(|| "postgres".into()), + password, + ); let job_id_clone = job_id.clone(); + let app_handle_clone = app_handle.clone(); tokio::task::spawn_blocking(move || { - let mut args: Vec = vec![ - format!("--host={host}"), - format!("--port={port}"), - format!("--username={username}"), - format!("--dbname={database}"), - ]; - - match format.as_str() { - "custom" => args.push("--format=c".into()), - "tar" => args.push("--format=t".into()), - "directory" => args.push("--format=d".into()), - _ => {} - } - - if clean { - args.push("--clean".into()); - args.push("--if-exists".into()); - } - - if let Some(ref schema) = schema { - args.push(format!("--schema={schema}")); - } - - args.push(file_path.clone()); - - let result = Command::new("pg_restore") - .env("PGPASSWORD", &password) - .args(&args) - .output(); - - match result { - Ok(output) if output.status.success() => { - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "completed".into(), - progress: Some(1.0), - output_line: None, - error: None, - }, - ); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "failed".into(), - progress: None, - output_line: None, - error: Some( - crate::commands::test_connection::sanitize_error(&stderr), - ), - }, - ); - } - Err(e) => { - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "failed".into(), - progress: None, - output_line: None, - error: Some(e.to_string()), - }, - ); - } - } + let result = run_pg_restore(¶ms, &options); + emit_result(&app_handle_clone, &job_id_clone, result); }); Ok(job_id) } -// --------------------------------------------------------------------------- -// db_sync (pg_dump | pg_restore via Unix pipe) -// --------------------------------------------------------------------------- - #[tauri::command] pub async fn db_sync( options: SyncOptions, @@ -293,10 +393,7 @@ pub async fn db_sync( // Get both connections from store let (source_conn, target_conn) = { - let store = state - .db_store - .lock() - .map_err(|e| e.to_string())?; + let store = state.db_store.lock().map_err(|e| e.to_string())?; let connections = store.get_connections().map_err(|e| e.to_string())?; let src = connections @@ -325,190 +422,52 @@ pub async fn db_sync( }; // Get passwords - let src_password = crate::commands::keychain::get_connection_password_internal( - &app_handle, - &source_conn.id, - ) - .unwrap_or_default() - .unwrap_or_default(); + let src_password = + crate::commands::keychain::get_connection_password_internal(&app_handle, &source_conn.id) + .unwrap_or_default() + .unwrap_or_default(); - let tgt_password = crate::commands::keychain::get_connection_password_internal( - &app_handle, - &target_conn.id, - ) - .unwrap_or_default() - .unwrap_or_default(); + let tgt_password = + crate::commands::keychain::get_connection_password_internal(&app_handle, &target_conn.id) + .unwrap_or_default() + .unwrap_or_default(); - // Extract connection fields - let src_host = source_conn.host.clone(); - let src_port = source_conn.port.unwrap_or(5432); - let src_username = source_conn - .username - .clone() - .unwrap_or_else(|| "postgres".into()); - let src_database = source_conn - .database - .clone() - .unwrap_or_else(|| "postgres".into()); + let source = PgConnParams::new( + source_conn.host.clone(), + source_conn.port.unwrap_or(5432), + source_conn + .username + .clone() + .unwrap_or_else(|| "postgres".into()), + source_conn + .database + .clone() + .unwrap_or_else(|| "postgres".into()), + src_password, + ); - let tgt_host = target_conn.host.clone(); - let tgt_port = target_conn.port.unwrap_or(5432); - let tgt_username = target_conn - .username - .clone() - .unwrap_or_else(|| "postgres".into()); - let tgt_database = target_conn - .database - .clone() - .unwrap_or_else(|| "postgres".into()); + let target = PgConnParams::new( + target_conn.host.clone(), + target_conn.port.unwrap_or(5432), + target_conn + .username + .clone() + .unwrap_or_else(|| "postgres".into()), + target_conn + .database + .clone() + .unwrap_or_else(|| "postgres".into()), + tgt_password, + ); let schema = options.schema.clone(); let tables = options.tables.clone(); let job_id_clone = job_id.clone(); + let app_handle_clone = app_handle.clone(); tokio::task::spawn_blocking(move || { - // --- Build pg_dump args --- - let mut dump_args: Vec = vec![ - format!("--host={src_host}"), - format!("--port={src_port}"), - format!("--username={src_username}"), - format!("--dbname={src_database}"), - "--format=c".into(), // binary custom format for reliable piping - "--no-owner".into(), - ]; - - if let Some(ref schema) = schema { - dump_args.push(format!("--schema={schema}")); - } - - if let Some(ref tables) = tables { - for t in tables { - dump_args.push(format!("--table={t}")); - } - } - - // --- Build pg_restore args --- - let restore_args: Vec = vec![ - format!("--host={tgt_host}"), - format!("--port={tgt_port}"), - format!("--username={tgt_username}"), - format!("--dbname={tgt_database}"), - "--no-owner".into(), - ]; - - // --- Spawn pg_dump with piped stdout --- - let mut dump_child = match Command::new("pg_dump") - .env("PGPASSWORD", &src_password) - .args(&dump_args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(e) => { - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "failed".into(), - progress: None, - output_line: None, - error: Some(format!("Failed to start pg_dump: {e}")), - }, - ); - return; - } - }; - - let dump_stdout = dump_child.stdout.take().unwrap(); - let dump_stderr_reader = dump_child.stderr.take().unwrap(); - - // Read pg_dump stderr in a separate thread so the pipe doesn't block - let dump_stderr_handle = std::thread::spawn(move || { - use std::io::Read; - let mut buf = String::new(); - let _ = dump_stderr_reader - .take(10 * 1024 * 1024) // cap at 10 MiB - .read_to_string(&mut buf); - buf - }); - - // --- Run pg_restore with pg_dump stdout as stdin --- - let restore_result = Command::new("pg_restore") - .env("PGPASSWORD", &tgt_password) - .args(&restore_args) - .stdin(dump_stdout) - .output(); - - // Wait for pg_dump to finish - let dump_status = dump_child.wait(); - let dump_stderr = dump_stderr_handle.join().unwrap_or_default(); - - // --- Check results --- - let dump_failed = match dump_status { - Ok(status) => !status.success(), - Err(_) => true, - }; - - if dump_failed { - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "failed".into(), - progress: None, - output_line: None, - error: Some(format!( - "pg_dump failed: {}", - crate::commands::test_connection::sanitize_error(&dump_stderr) - )), - }, - ); - return; - } - - match restore_result { - Ok(output) if output.status.success() => { - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "completed".into(), - progress: Some(1.0), - output_line: None, - error: None, - }, - ); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "failed".into(), - progress: None, - output_line: None, - error: Some(format!( - "pg_restore failed: {}", - crate::commands::test_connection::sanitize_error(&stderr) - )), - }, - ); - } - Err(e) => { - let _ = app_handle.emit( - "backup-progress", - BackupProgressEvent { - job_id: job_id_clone.clone(), - status: "failed".into(), - progress: None, - output_line: None, - error: Some(format!("pg_restore failed: {e}")), - }, - ); - } - } + let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref()); + emit_result(&app_handle_clone, &job_id_clone, result); }); Ok(job_id) @@ -574,4 +533,4 @@ pub(crate) fn build_args_for_test( #[cfg(test)] #[path = "backup.test.rs"] -mod tests; \ No newline at end of file +mod tests; diff --git a/src-tauri/src/commands/backup.test.rs b/src-tauri/src/commands/backup.test.rs index 2729980..c0fd32e 100644 --- a/src-tauri/src/commands/backup.test.rs +++ b/src-tauri/src/commands/backup.test.rs @@ -185,4 +185,210 @@ fn backup_progress_event_failed() { let json = serde_json::to_string(&evt).unwrap(); assert!(json.contains("\"failed\"")); assert!(json.contains("\"connection refused\"")); -} \ No newline at end of file +} + +// ------------------------------------------------------------------ +// Integration tests (headless, against live DBs) +// +// These exercise the real dump/restore/sync code path (run_pg_dump, +// run_pg_restore, run_db_sync) with passwords passed directly — the +// only thing skipped is the OS-keychain lookup, which is a thin, +// separately-tested concern. +// +// They are #[ignore]d by default so they don't run in normal `cargo test`. +// Run them explicitly with: +// +// GRIDLINE_TEST_SRC_HOST=... GRIDLINE_TEST_SRC_PORT=... \ +// GRIDLINE_TEST_SRC_USER=... GRIDLINE_TEST_SRC_DB=... \ +// GRIDLINE_TEST_SRC_PASSWORD=... \ +// GRIDLINE_TEST_TGT_HOST=... GRIDLINE_TEST_TGT_PORT=... \ +// GRIDLINE_TEST_TGT_USER=... GRIDLINE_TEST_TGT_DB=... \ +// GRIDLINE_TEST_TGT_PASSWORD=... \ +// cargo test --lib backup -- --ignored +// ------------------------------------------------------------------ + +fn env(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("missing env var {name}")) +} + +// Serializes the live-DB integration tests so they don't clobber each other +// when cargo runs them in parallel. +static INTEGRATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn conn_from_env(prefix: &str) -> PgConnParams { + PgConnParams::new( + env(&format!("{prefix}_HOST")), + env(&format!("{prefix}_PORT")).parse().unwrap(), + env(&format!("{prefix}_USER")), + env(&format!("{prefix}_DB")), + env(&format!("{prefix}_PASSWORD")), + ) +} + +fn psql_exec(conn: &PgConnParams, sql: &str) { + let out = Command::new("psql") + .env("PGPASSWORD", &conn.password) + .args([ + format!("--host={}", conn.host), + format!("--port={}", conn.port), + format!("--username={}", conn.username), + format!("--dbname={}", conn.database), + "-tA".into(), + "-c".into(), + sql.into(), + ]) + .output() + .expect("psql should run"); + assert!( + out.status.success(), + "psql failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +fn psql_count(conn: &PgConnParams, query: &str) -> i64 { + let out = Command::new("psql") + .env("PGPASSWORD", &conn.password) + .args([ + format!("--host={}", conn.host), + format!("--port={}", conn.port), + format!("--username={}", conn.username), + format!("--dbname={}", conn.database), + "-tA".into(), + "-c".into(), + query.into(), + ]) + .output() + .expect("psql should run"); + assert!( + out.status.success(), + "psql failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().parse().unwrap() +} + +#[test] +#[ignore] +fn integration_dump_restore_sync() { + let _guard = INTEGRATION_LOCK.lock().unwrap(); + let src = conn_from_env("GRIDLINE_TEST_SRC"); + let tgt = conn_from_env("GRIDLINE_TEST_TGT"); + + // Unique temp file per run to avoid collisions. + let dump_path = std::env::temp_dir().join(format!( + "gridline_it_dump_{}_{}.bak", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let dump_path_str = dump_path.to_str().unwrap().to_string(); + + // --- 1. Dump source (custom format — the only format pg_restore can read) --- + let dump_opts = BackupOptions { + format: "custom".into(), + file_path: dump_path_str.clone(), + schema: None, + tables: None, + no_owner: true, + }; + run_pg_dump(&src, &dump_opts).expect("pg_dump should succeed"); + + // --- 2. Restore into target --- + let restore_opts = RestoreOptions { + format: "custom".into(), + file_path: dump_path_str.clone(), + clean: true, + schema: None, + }; + run_pg_restore(&tgt, &restore_opts).expect("pg_restore should succeed"); + + // --- 3. Verify data landed in target --- + assert_eq!( + psql_count(&tgt, "SELECT count(*) FROM public.products;"), + 3, + "products should be restored" + ); + assert_eq!( + psql_count(&tgt, "SELECT count(*) FROM public.orders;"), + 3, + "orders should be restored" + ); + + // --- 4. Sync source -> target (target already has tables from the restore + // above — db_sync now passes --clean --if-exists, so it must succeed into a + // non-empty target). --- + run_db_sync(&src, &tgt, None, None).expect("db_sync should succeed"); + assert_eq!( + psql_count(&tgt, "SELECT count(*) FROM public.products;"), + 3, + "sync should re-copy products" + ); + assert_eq!( + psql_count(&tgt, "SELECT count(*) FROM public.orders;"), + 3, + "sync should re-copy orders" + ); + + // --- Cleanup --- + let _ = std::fs::remove_file(&dump_path); +} + +#[test] +#[ignore] +fn integration_plain_dump_restore() { + let _guard = INTEGRATION_LOCK.lock().unwrap(); + let src = conn_from_env("GRIDLINE_TEST_SRC"); + let tgt = conn_from_env("GRIDLINE_TEST_TGT"); + + // psql can't DROP-before-CREATE, so start from a clean target. + psql_exec( + &tgt, + "DROP TABLE IF EXISTS public.orders, public.products CASCADE;", + ); + + let dump_path = std::env::temp_dir().join(format!( + "gridline_it_plain_{}_{}.sql", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let dump_path_str = dump_path.to_str().unwrap().to_string(); + + // --- 1. Dump source in plain format --- + let dump_opts = BackupOptions { + format: "plain".into(), + file_path: dump_path_str.clone(), + schema: None, + tables: None, + no_owner: true, + }; + run_pg_dump(&src, &dump_opts).expect("pg_dump (plain) should succeed"); + + // --- 2. Restore into target (plain -> psql path) --- + let restore_opts = RestoreOptions { + format: "plain".into(), + file_path: dump_path_str.clone(), + clean: false, + schema: None, + }; + run_pg_restore(&tgt, &restore_opts).expect("pg_restore (plain/psql) should succeed"); + + // --- 3. Verify data landed in target --- + assert_eq!( + psql_count(&tgt, "SELECT count(*) FROM public.products;"), + 3, + "plain restore should load products" + ); + assert_eq!( + psql_count(&tgt, "SELECT count(*) FROM public.orders;"), + 3, + "plain restore should load orders" + ); + + let _ = std::fs::remove_file(&dump_path); +} diff --git a/src-tauri/src/commands/connections.rs b/src-tauri/src/commands/connections.rs index 6a65ea2..bc06af8 100644 --- a/src-tauri/src/commands/connections.rs +++ b/src-tauri/src/commands/connections.rs @@ -20,11 +20,7 @@ fn validate(input: &ConnectionInput) -> Result<(), String> { if input.db_type != "sqlite" { match input.port { Some(p) if (1..=65535).contains(&p) => {} - _ => { - return Err( - "port must be an integer between 1 and 65535 for this db_type".into(), - ) - } + _ => return Err("port must be an integer between 1 and 65535 for this db_type".into()), } } if let Some(u) = &input.username { @@ -358,4 +354,4 @@ mod tests { clear_recent_connections_inner(&st).unwrap(); assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0); } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/db_viewer.rs b/src-tauri/src/commands/db_viewer.rs index 8f9f10f..43916c1 100644 --- a/src-tauri/src/commands/db_viewer.rs +++ b/src-tauri/src/commands/db_viewer.rs @@ -5,8 +5,8 @@ use crate::db::pool::{DbConfig, DbHandle}; use crate::models::db_viewer::{ - Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, - IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo, + Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo, + QueryResult, SequenceInfo, TableInfo, TriggerInfo, }; use std::collections::HashMap; use tauri::State; @@ -43,11 +43,7 @@ fn redact_secrets(s: &str) -> String { || s[i..].to_lowercase().starts_with("postgresql://") { // Skip the scheme. - let scheme_end = i - + s[i..] - .find("://") - .unwrap_or(0) - + 3; + let scheme_end = i + s[i..].find("://").unwrap_or(0) + 3; out.push_str("[redacted-url://"); // Find end of authority (next '/'. '/', or end). let rest = &s[scheme_end..]; @@ -175,7 +171,9 @@ pub fn get_pg_ddl_via_dump( password: &str, ) -> Result { if !pg_dump_available() { - return Err("pg_dump not found. Install PostgreSQL client tools to copy table schema.".into()); + return Err( + "pg_dump not found. Install PostgreSQL client tools to copy table schema.".into(), + ); } let mut cmd = std::process::Command::new("pg_dump"); cmd.args([ @@ -186,7 +184,9 @@ pub fn get_pg_ddl_via_dump( ]); cmd.args(build_pg_dump_ddl_args(schema, table)); cmd.env("PGPASSWORD", password); - let out = cmd.output().map_err(|e| format!("pg_dump spawn failed: {e}"))?; + let out = cmd + .output() + .map_err(|e| format!("pg_dump spawn failed: {e}"))?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).to_string()); } @@ -249,7 +249,9 @@ fn build_pg_filter_clause( } /// Build a WHERE clause from filter rules for SQLite (positional ? params). -fn build_sqlite_filter_clause(filters: &[crate::models::db_viewer::FilterRule]) -> (String, Vec) { +fn build_sqlite_filter_clause( + filters: &[crate::models::db_viewer::FilterRule], +) -> (String, Vec) { let mut clauses = String::new(); let mut params: Vec = Vec::new(); @@ -331,39 +333,53 @@ pub(crate) fn pg_char_to_att(value: Option) -> String { } /// Assemble a PG SELECT statement from pre-formatted select items (already -/// quoted and optionally `::text`-cast), appending `ctid` when the table has -/// no primary key so later UPDATE/DELETE queue changes can target the exact -/// row. `ctid` is appended last so it does not shift visible column order. -fn build_pg_select_from_items(schema: &str, table: &str, items: Vec, has_pk: bool) -> String { +/// quoted and optionally `::text`-cast), appending `ctid` when `append_locator` +/// is true (a no-PK *physical table*) so later UPDATE/DELETE queue changes can +/// target the exact row. Views expose no `ctid`, so callers must pass `false` +/// for them. `ctid` is appended last so it does not shift visible column order. +fn build_pg_select_from_items( + schema: &str, + table: &str, + items: Vec, + append_locator: bool, +) -> String { let mut all_cols = items; - if !has_pk { + if append_locator { all_cols.push("ctid".to_string()); } - format!("SELECT {} FROM \"{}\".\"{}\"", all_cols.join(", "), schema, table) + format!( + "SELECT {} FROM \"{}\".\"{}\"", + all_cols.join(", "), + schema, + table + ) } -/// Build the PG data SELECT, appending `ctid` only when the table has no PK. +/// Build the PG data SELECT, appending `ctid` only when `append_locator` is +/// true (no-PK physical tables). Never true for views. pub(crate) fn build_pg_data_select( schema: &str, table: &str, visible_cols: &[String], - has_pk: bool, + append_locator: bool, ) -> String { let base_cols: Vec = visible_cols.iter().map(|c| format!("\"{}\"", c)).collect(); - build_pg_select_from_items(schema, table, base_cols, has_pk) + build_pg_select_from_items(schema, table, base_cols, append_locator) } -/// Build the SQLite data SELECT, appending `rowid` only when the table has no -/// PK. The table is unqualified; SQLite browsing in this app is always scoped -/// to the `main` schema, where an unqualified name resolves identically. +/// Build the SQLite data SELECT, appending `rowid` when `append_locator` is +/// true (a no-PK *physical table*) so later UPDATE/DELETE queue changes can +/// target the exact row. Views expose no `rowid`, so callers must pass `false` +/// for them. The table is unqualified; SQLite browsing in this app is always +/// scoped to the `main` schema, where an unqualified name resolves identically. pub(crate) fn build_sqlite_data_select( table: &str, visible_cols: &[String], - has_pk: bool, + append_locator: bool, ) -> String { let base_cols: Vec = visible_cols.iter().map(|c| format!("\"{}\"", c)).collect(); let mut all_cols = base_cols; - if !has_pk { + if append_locator { all_cols.push("rowid".to_string()); } format!("SELECT {} FROM \"{}\"", all_cols.join(", "), table) @@ -683,7 +699,10 @@ pub async fn apply_bulk_insert_pg( rows: &[Vec], ) -> Result { let sql = build_pg_bulk_insert_sql(schema, table, columns); - client.batch_execute("BEGIN").await.map_err(|e| e.to_string())?; + client + .batch_execute("BEGIN") + .await + .map_err(|e| e.to_string())?; let mut count = 0; for (i, row) in rows.iter().enumerate() { let boxed: Vec> = row.iter().map(pg_box_value).collect(); @@ -721,7 +740,10 @@ fn parse_json_pairs(json: &str) -> Result, Stri let obj = v .as_object() .ok_or_else(|| "change JSON must be an object".to_string())?; - Ok(obj.iter().map(|(k, val)| (k.clone(), val.clone())).collect()) + Ok(obj + .iter() + .map(|(k, val)| (k.clone(), val.clone())) + .collect()) } /// /// Each inner `Vec` represents one row, where the values @@ -768,13 +790,8 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value { 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() - )), + 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, } } @@ -941,8 +958,7 @@ pub async fn db_connect( let result = match tls { None => connect_pg_with(&pgconfig, tokio_postgres::NoTls).await, Some(cc) => { - let connector = - tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone()); + let connector = tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone()); connect_pg_with(&pgconfig, connector).await } }; @@ -1090,7 +1106,8 @@ pub async fn get_tables( }) }) .map_err(|e| e.to_string())?; - rows.collect::, _>>().map_err(|e| e.to_string()) + rows.collect::, _>>() + .map_err(|e| e.to_string()) } None => Err("Connection not found".to_string()), } @@ -1123,16 +1140,20 @@ pub async fn get_table_data( let order_clause = build_order_clause(&sorts); // Get total count (with filters applied) - let count_query = - format!("SELECT COUNT(*) FROM \"{}\".\"{}\" WHERE 1=1{}", schema, table, filter_clause); + let count_query = format!( + "SELECT COUNT(*) FROM \"{}\".\"{}\" WHERE 1=1{}", + schema, table, filter_clause + ); let count_row = if filter_params.is_empty() { client .query_one(&count_query, &[]) .await .map_err(|e| e.to_string())? } else { - let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = - filter_params.iter().map(|s| s as &(dyn tokio_postgres::types::ToSql + Sync)).collect(); + let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = filter_params + .iter() + .map(|s| s as &(dyn tokio_postgres::types::ToSql + Sync)) + .collect(); client .query_one(&count_query, ¶m_refs) .await @@ -1140,6 +1161,18 @@ pub async fn get_table_data( }; let total_rows: i64 = count_row.get(0); + // Views expose no `ctid`; detect them so no row-locator is appended + // to the data SELECT and columns stay read-only. + let is_view: bool = client + .query_one( + "SELECT EXISTS(SELECT 1 FROM information_schema.tables \ + WHERE table_schema = $1 AND table_name = $2 AND table_type = 'VIEW')", + &[&schema, &table], + ) + .await + .map(|r| r.get::<_, bool>(0)) + .unwrap_or(false); + // Get column info with FK detection and enum type names let col_query = r#"SELECT c.column_name, @@ -1203,12 +1236,9 @@ ORDER BY c.ordinal_position"#; // pg_attribute.attgenerated/attidentity are PG's internal // "char" type (OID 18) → tokio-postgres delivers i8, not // String; deserializing as String panics. Convert safely. - let attgenerated = pg_char_to_att( - r.try_get::<_, Option>(8).unwrap_or(None), - ); - let attidentity = pg_char_to_att( - r.try_get::<_, Option>(9).unwrap_or(None), - ); + let attgenerated = + pg_char_to_att(r.try_get::<_, Option>(8).unwrap_or(None)); + let attidentity = pg_char_to_att(r.try_get::<_, Option>(9).unwrap_or(None)); let is_pk: bool = r.get(3); ColumnInfo { name: r.get(0), @@ -1222,7 +1252,9 @@ ORDER BY c.ordinal_position"#; None }, default_value: r.get::<_, Option>(7), - editable: editable_from_att(&attgenerated, &attidentity) && !is_pk, + editable: editable_from_att(&attgenerated, &attidentity) + && !is_pk + && !is_view, is_generated: !attgenerated.is_empty(), } }) @@ -1232,15 +1264,41 @@ ORDER BY c.ordinal_position"#; // Custom/enum types need explicit ::text cast because tokio-postgres // FromSql rejects custom type OIDs even in simple query mode. let standard_pg_types: &[&str] = &[ - "uuid", "text", "varchar", "char", "bpchar", "name", - "int2", "int4", "int8", "smallint", "integer", "bigint", - "float4", "float8", "real", "double precision", - "numeric", "decimal", "money", - "bool", "boolean", - "date", "time", "timetz", "timestamp", "timestamptz", - "interval", "json", "jsonb", "bytea", "oid", - "timestamp without time zone", "timestamp with time zone", - "time without time zone", "time with time zone", + "uuid", + "text", + "varchar", + "char", + "bpchar", + "name", + "int2", + "int4", + "int8", + "smallint", + "integer", + "bigint", + "float4", + "float8", + "real", + "double precision", + "numeric", + "decimal", + "money", + "bool", + "boolean", + "date", + "time", + "timetz", + "timestamp", + "timestamptz", + "interval", + "json", + "jsonb", + "bytea", + "oid", + "timestamp without time zone", + "timestamp with time zone", + "time without time zone", + "time with time zone", ]; let has_pk = columns.iter().any(|c| c.is_pk); let select_items: Vec = columns @@ -1257,10 +1315,14 @@ ORDER BY c.ordinal_position"#; .collect(); // `ctid` is appended last for no-PK tables so later UPDATE/DELETE // queue changes can target the exact row. It stays out of `columns`. + // Views are excluded — they expose no ctid and are read-only. let data_query = format!( "{} WHERE 1=1{} {} LIMIT {} OFFSET {}", - build_pg_select_from_items(&schema, &table, select_items, has_pk), - filter_clause, order_clause, ps, off + build_pg_select_from_items(&schema, &table, select_items, !has_pk && !is_view), + filter_clause, + order_clause, + ps, + off ); let data_rows = if filter_params.is_empty() { client @@ -1268,8 +1330,10 @@ ORDER BY c.ordinal_position"#; .await .map_err(|e| e.to_string())? } else { - let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = - filter_params.iter().map(|s| s as &(dyn tokio_postgres::types::ToSql + Sync)).collect(); + let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = filter_params + .iter() + .map(|s| s as &(dyn tokio_postgres::types::ToSql + Sync)) + .collect(); client .query(&data_query, ¶m_refs) .await @@ -1290,19 +1354,36 @@ ORDER BY c.ordinal_position"#; }) } Some(crate::db::pool::DbHandle::Sqlite(conn)) => { + // Views expose no `rowid`; detect them so no row-locator is appended + // to the data SELECT and columns stay read-only. + let is_view: bool = conn + .query_row( + "SELECT type = 'view' FROM sqlite_master WHERE name = ?1 AND type IN ('table', 'view')", + [&table], + |r| r.get::<_, bool>(0), + ) + .unwrap_or(false); + // Build filter clause (shared by COUNT and data queries) let (filter_clause, filter_vals) = build_sqlite_filter_clause(&filters); let order_clause = build_order_clause(&sorts); - let count_query = - format!("SELECT COUNT(*) FROM \"{}\".\"{}\" WHERE 1=1{}", schema, table, filter_clause); + let count_query = format!( + "SELECT COUNT(*) FROM \"{}\".\"{}\" WHERE 1=1{}", + schema, table, filter_clause + ); let total_rows: i64 = if filter_vals.is_empty() { conn.query_row(&count_query, [], |r| r.get(0)) .map_err(|e| e.to_string())? } else { - let refs: Vec<&dyn rusqlite::types::ToSql> = filter_vals.iter().map(|v| v as &dyn rusqlite::types::ToSql).collect(); - conn.query_row(&count_query, rusqlite::params_from_iter(&refs), |r| r.get(0)) - .map_err(|e| e.to_string())? + let refs: Vec<&dyn rusqlite::types::ToSql> = filter_vals + .iter() + .map(|v| v as &dyn rusqlite::types::ToSql) + .collect(); + conn.query_row(&count_query, rusqlite::params_from_iter(&refs), |r| { + r.get(0) + }) + .map_err(|e| e.to_string())? }; // Get column metadata via PRAGMA table_info @@ -1311,10 +1392,10 @@ ORDER BY c.ordinal_position"#; let col_meta: Vec<(String, String, bool, bool, Option)> = pragma_stmt .query_map([], |row| { Ok(( - row.get::<_, String>(1)?, // name - row.get::<_, String>(2)?, // type - row.get::<_, bool>(3)?, // notnull - row.get::<_, bool>(5)?, // pk + row.get::<_, String>(1)?, // name + row.get::<_, String>(2)?, // type + row.get::<_, bool>(3)?, // notnull + row.get::<_, bool>(5)?, // pk row.get::<_, Option>(4)?, // dflt_value )) }) @@ -1329,9 +1410,9 @@ ORDER BY c.ordinal_position"#; fk_stmt .query_map([], |row| { Ok(( - row.get::<_, String>(3)?, // from (column) - row.get::<_, String>(2)?, // table - row.get::<_, String>(4)?, // to (column) + row.get::<_, String>(3)?, // from (column) + row.get::<_, String>(2)?, // table + row.get::<_, String>(4)?, // to (column) )) }) .map_err(|e| e.to_string())? @@ -1348,13 +1429,17 @@ ORDER BY c.ordinal_position"#; let fk = fk_map.get(name); ColumnInfo { name: name.clone(), - data_type: if dtype.is_empty() { "TEXT".to_string() } else { dtype.clone() }, + data_type: if dtype.is_empty() { + "TEXT".to_string() + } else { + dtype.clone() + }, is_nullable: !notnull, is_pk: *is_pk, is_fk: fk.is_some(), fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())), default_value: default_val.clone(), - editable: !*is_pk, + editable: !*is_pk && !is_view, is_generated: false, } }) @@ -1363,12 +1448,16 @@ ORDER BY c.ordinal_position"#; // Get data (with filters and sorts applied). // `rowid` is appended last for no-PK tables so later UPDATE/DELETE // queue changes can target the exact row. It stays out of `columns`. + // Views are excluded — they expose no rowid and are read-only. let visible_names: Vec = columns.iter().map(|c| c.name.clone()).collect(); let has_pk = columns.iter().any(|c| c.is_pk); let data_query = format!( "{} WHERE 1=1{} {} LIMIT {} OFFSET {}", - build_sqlite_data_select(&table, &visible_names, has_pk), - filter_clause, order_clause, ps, off + build_sqlite_data_select(&table, &visible_names, !has_pk && !is_view), + filter_clause, + order_clause, + ps, + off ); let mut stmt = conn.prepare(&data_query).map_err(|e| e.to_string())?; let col_count = stmt.column_count(); @@ -1385,7 +1474,10 @@ ORDER BY c.ordinal_position"#; .filter_map(|r| r.ok()) .collect() } else { - let refs: Vec<&dyn rusqlite::types::ToSql> = filter_vals.iter().map(|v| v as &dyn rusqlite::types::ToSql).collect(); + let refs: Vec<&dyn rusqlite::types::ToSql> = filter_vals + .iter() + .map(|v| v as &dyn rusqlite::types::ToSql) + .collect(); stmt.query_map(rusqlite::params_from_iter(&refs), |row| { let mut vals = Vec::new(); for i in 0..col_count { @@ -1561,7 +1653,11 @@ ORDER BY c.ordinal_position"#; let fk = fk_map.get(name); ColumnInfo { name: name.clone(), - data_type: if dtype.is_empty() { "TEXT".to_string() } else { dtype.clone() }, + data_type: if dtype.is_empty() { + "TEXT".to_string() + } else { + dtype.clone() + }, is_nullable: !notnull, is_pk: *is_pk, is_fk: fk.is_some(), @@ -1733,8 +1829,7 @@ pub async fn execute_change( .. } => { let pairs = parse_json_pairs(data)?; - let columns: Vec = - pairs.iter().map(|(c, _)| c.clone()).collect(); + let columns: Vec = pairs.iter().map(|(c, _)| c.clone()).collect(); ( build_insert_sql(schema, table, &columns), pairs.iter().map(|(_, v)| v.clone()).collect(), @@ -1800,7 +1895,10 @@ pub async fn refresh_connection( let mut pm = state.pool_manager.lock().await; match pm.get(&connection_id) { Some(crate::db::pool::DbHandle::Postgresql(client, _)) => { - client.query_one("SELECT 1", &[]).await.map_err(|e| e.to_string())?; + client + .query_one("SELECT 1", &[]) + .await + .map_err(|e| e.to_string())?; Ok(()) } Some(crate::db::pool::DbHandle::Sqlite(conn)) => { @@ -1901,13 +1999,14 @@ pub async fn get_constraints( .iter() .map(|r| { // contype::text decodes as a String ("c" | "u" | "x"). - let contype = match r.get::<_, Option>(3).unwrap_or_default().as_str() { - "c" => "CHECK", - "u" => "UNIQUE", - "x" => "EXCLUSION", - other => other, - } - .to_string(); + let contype = + match r.get::<_, Option>(3).unwrap_or_default().as_str() { + "c" => "CHECK", + "u" => "UNIQUE", + "x" => "EXCLUSION", + other => other, + } + .to_string(); ConstraintInfo { name: r.get(0), schema: r.get(1), @@ -1916,7 +2015,9 @@ pub async fn get_constraints( definition: r.get(4), deferrable: r.get(5), validated: r.get(6), - columns: split_columns_csv(&r.get::<_, Option>(7).unwrap_or_default()), + columns: split_columns_csv( + &r.get::<_, Option>(7).unwrap_or_default(), + ), } }) .collect()) @@ -1986,7 +2087,8 @@ pub async fn get_sequences( max_value: r.get::<_, Option>(4).unwrap_or_default(), increment: r.get::<_, Option>(5).unwrap_or_default(), current_value: r.get::<_, Option>(6).unwrap_or_default(), - cycle: r.get::<_, Option>(7) + cycle: r + .get::<_, Option>(7) .map(|s| s == "YES") .unwrap_or(false), }) @@ -2035,10 +2137,7 @@ pub async fn get_extensions( match pm.get(&connection_id) { Some(DbHandle::Postgresql(client, _)) => { let query = crate::db::introspection::pg_extensions_query(); - let rows = client - .query(&query, &[]) - .await - .map_err(|e| e.to_string())?; + let rows = client.query(&query, &[]).await.map_err(|e| e.to_string())?; Ok(rows .iter() .map(|r| ExtensionInfo { @@ -2105,7 +2204,9 @@ pub async fn get_table_ddl( // pg_dump is blocking I/O; run it off the async runtime. Credentials // travel via PGPASSWORD, never argv. let ddl = tokio::task::spawn_blocking(move || { - get_pg_ddl_via_dump(&schema, &table, &dump_host, dump_port, &user, &db, &password) + get_pg_ddl_via_dump( + &schema, &table, &dump_host, dump_port, &user, &db, &password, + ) }) .await .map_err(|e| format!("pg_dump task failed: {e}"))??; @@ -2115,7 +2216,6 @@ pub async fn get_table_ddl( } } - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -2127,11 +2227,17 @@ mod tests { #[test] fn split_columns_csv_handles_commas_and_trims() { - assert_eq!(split_columns_csv("id, name, created_at"), vec!["id", "name", "created_at"]); + assert_eq!( + split_columns_csv("id, name, created_at"), + vec!["id", "name", "created_at"] + ); assert_eq!(split_columns_csv("id"), vec!["id"]); assert_eq!(split_columns_csv(""), Vec::::new()); // expression index column list may include parens — keep raw, just split on top-level commas - assert_eq!(split_columns_csv("lower(name), id"), vec!["lower(name)", "id"]); + assert_eq!( + split_columns_csv("lower(name), id"), + vec!["lower(name)", "id"] + ); } /// bigint precision: values beyond 2^53 must round-trip as strings. @@ -2140,8 +2246,11 @@ mod tests { // A bigint beyond 2^53 must round-trip as a string, not a JS number. let big: i64 = 9_007_199_254_740_993; // 2^53 + 1 let v = i64_to_json(big); - assert_eq!(v, serde_json::Value::String("9007199254740993".to_string()), - "bigint must be a string to avoid float precision loss"); + assert_eq!( + v, + serde_json::Value::String("9007199254740993".to_string()), + "bigint must be a string to avoid float precision loss" + ); let small: i64 = 42; let v2 = i64_to_json(small); assert_eq!(v2, serde_json::Value::String("42".to_string())); @@ -2303,13 +2412,7 @@ mod tests { vec![serde_json::json!(1), serde_json::json!("y")], vec![serde_json::json!(2), serde_json::json!("z")], ]; - apply_bulk_insert_sqlite( - &conn, - "t", - &["a".to_string(), "b".to_string()], - &rows, - ) - .unwrap(); + apply_bulk_insert_sqlite(&conn, "t", &["a".to_string(), "b".to_string()], &rows).unwrap(); let count: i64 = conn .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0)) .unwrap(); @@ -2328,12 +2431,7 @@ mod tests { vec![serde_json::json!(1), serde_json::json!("y")], vec![serde_json::json!("bad"), serde_json::json!("z")], ]; - let res = apply_bulk_insert_sqlite( - &conn, - "t", - &["a".to_string(), "b".to_string()], - &rows, - ); + let res = apply_bulk_insert_sqlite(&conn, "t", &["a".to_string(), "b".to_string()], &rows); assert!(res.is_err(), "non-integer PK value should fail"); // Rollback: no rows persisted. let count: i64 = conn @@ -2349,10 +2447,10 @@ mod tests { let conn = rusqlite::Connection::open_in_memory().unwrap(); // INTEGER PRIMARY KEY rejects non-integer values (datatype mismatch), // guaranteeing row 2 fails. - conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY)", []).unwrap(); + conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY)", []) + .unwrap(); let rows = vec![vec![serde_json::json!(1)], vec![serde_json::json!("x")]]; - let err = apply_bulk_insert_sqlite(&conn, "t", &["a".to_string()], &rows) - .unwrap_err(); + let err = apply_bulk_insert_sqlite(&conn, "t", &["a".to_string()], &rows).unwrap_err(); assert!( err.contains("row 2"), "error should name the failing row index (1-based): {err}" @@ -2363,11 +2461,8 @@ mod tests { /// and quotes schema, table, and columns. #[test] fn build_pg_bulk_insert_sql_shape() { - let sql = build_pg_bulk_insert_sql( - "public", - "users", - &["id".to_string(), "name".to_string()], - ); + let sql = + build_pg_bulk_insert_sql("public", "users", &["id".to_string(), "name".to_string()]); assert_eq!( sql, r#"INSERT INTO "public"."users" ("id", "name") VALUES ($1, $2)"# @@ -2417,21 +2512,69 @@ mod tests { #[test] fn pg_locator_select_adds_ctid() { - let sql = build_pg_data_select("public", "no_pk", &["id".into(), "name".into()], false); - assert!(sql.contains("ctid"), "no-PK table must select ctid; got: {}", sql); - assert!(sql.contains("\"public\""), "schema must be quoted; got: {}", sql); + let sql = build_pg_data_select("public", "no_pk", &["id".into(), "name".into()], true); + assert!( + sql.contains("ctid"), + "no-PK table must select ctid; got: {}", + sql + ); + assert!( + sql.contains("\"public\""), + "schema must be quoted; got: {}", + sql + ); } #[test] fn pg_locator_select_omits_ctid_when_pk_present() { - let sql = build_pg_data_select("public", "with_pk", &["id".into(), "name".into()], true); - assert!(!sql.contains("ctid"), "PK table must NOT select ctid; got: {}", sql); + let sql = build_pg_data_select("public", "with_pk", &["id".into(), "name".into()], false); + assert!( + !sql.contains("ctid"), + "PK table must NOT select ctid; got: {}", + sql + ); + } + + #[test] + fn pg_locator_select_omits_ctid_for_view() { + // Views expose no ctid — the locator must never be appended for them. + let sql = build_pg_data_select("public", "order_summary", &["order_id".into()], false); + assert!( + !sql.contains("ctid"), + "view must NOT select ctid; got: {}", + sql + ); + assert!( + sql.contains("order_summary"), + "view name must be present; got: {}", + sql + ); } #[test] fn sqlite_locator_select_adds_rowid_for_no_pk() { - let sql = build_sqlite_data_select("no_pk", &["id".into(), "name".into()], false); - assert!(sql.contains("rowid"), "no-PK sqlite table must select rowid; got: {}", sql); + let sql = build_sqlite_data_select("no_pk", &["id".into(), "name".into()], true); + assert!( + sql.contains("rowid"), + "no-PK sqlite table must select rowid; got: {}", + sql + ); + } + + #[test] + fn sqlite_locator_select_omits_rowid_for_view() { + // Views expose no rowid — the locator must never be appended for them. + let sql = build_sqlite_data_select("order_summary", &["order_id".into()], false); + assert!( + !sql.contains("rowid"), + "view must NOT select rowid; got: {}", + sql + ); + assert!( + sql.contains("order_summary"), + "view name must be present; got: {}", + sql + ); } // ----------------------------------------------------------------------- @@ -2444,7 +2587,11 @@ mod tests { let locator = vec![("ctid".to_string(), serde_json::json!("(0,1)"))]; let data = vec![("name".to_string(), serde_json::json!("Bob"))]; let (sql, params) = build_pg_update_sql("public", "no_pk", &locator, &data).unwrap(); - assert!(sql.contains("\"ctid\" = $"), "locator update must WHERE on ctid; got: {}", sql); + assert!( + sql.contains("\"ctid\" = $"), + "locator update must WHERE on ctid; got: {}", + sql + ); assert_eq!(params.len(), 2); // 1 SET value + 1 WHERE value } @@ -2453,8 +2600,16 @@ mod tests { let pk = vec![("id".to_string(), serde_json::json!(1))]; let data = vec![("name".to_string(), serde_json::json!("Bob"))]; let (sql, _params) = build_pg_update_sql("public", "users", &pk, &data).unwrap(); - assert!(sql.contains("\"id\" = $"), "PK update must WHERE on id; got: {}", sql); - assert!(!sql.contains("ctid"), "PK update must NOT use ctid; got: {}", sql); + assert!( + sql.contains("\"id\" = $"), + "PK update must WHERE on id; got: {}", + sql + ); + assert!( + !sql.contains("ctid"), + "PK update must NOT use ctid; got: {}", + sql + ); } #[test] @@ -2463,13 +2618,22 @@ mod tests { let pk: Vec<(String, serde_json::Value)> = vec![]; let data = vec![("name".to_string(), serde_json::json!("Bob"))]; let result = build_pg_update_sql("public", "no_pk", &pk, &data); - assert!(result.is_err(), "empty primary_key must be rejected, not produce broken SQL"); + assert!( + result.is_err(), + "empty primary_key must be rejected, not produce broken SQL" + ); } #[test] fn affected_row_count_message_for_zero_rows() { - assert_eq!(affected_count_error(0u64), Some("row was modified or removed by another session".to_string())); + assert_eq!( + affected_count_error(0u64), + Some("row was modified or removed by another session".to_string()) + ); assert_eq!(affected_count_error(1u64), None); - assert_eq!(affected_count_error(2u64), Some("ambiguous row match".to_string())); + assert_eq!( + affected_count_error(2u64), + Some("ambiguous row match".to_string()) + ); } } diff --git a/src-tauri/src/commands/demo.rs b/src-tauri/src/commands/demo.rs index c87dc8d..5c54ccd 100644 --- a/src-tauri/src/commands/demo.rs +++ b/src-tauri/src/commands/demo.rs @@ -7,11 +7,11 @@ use std::sync::Mutex; use tauri::Manager; const DEMO_DB_FILENAME: &str = "demo.db"; -const DEMO_CONNECTION_NAME: &str = "Demo (SQLite)"; +const DEMO_CONNECTION_NAME: &str = "Gridline Demo (SQLite)"; /// Bump whenever the demo schema or seed data changes so existing demo files /// are recreated on the next launch. The demo is disposable by design — it /// should always showcase the current feature set. -const DEMO_SCHEMA_VERSION: i64 = 2; +const DEMO_SCHEMA_VERSION: i64 = 3; /// True when the demo file's `PRAGMA user_version` is at or above the current /// schema version (i.e. the file already carries the full feature set). @@ -34,8 +34,7 @@ fn ensure_demo_file(path: &Path) -> Result<(), String> { std::fs::remove_file(path).map_err(|e| e.to_string())?; } if !path.exists() { - let conn = - Connection::open(path).map_err(|e| format!("Failed to create demo DB: {e}"))?; + let conn = Connection::open(path).map_err(|e| format!("Failed to create demo DB: {e}"))?; conn.execute_batch(&get_demo_schema()) .map_err(|e| format!("Failed to seed demo DB: {e}"))?; } @@ -153,318 +152,12 @@ pub async fn regenerate_demo_db( Ok("Demo database regenerated with fresh data.".to_string()) } -/// The demo schema + seed data. Validated to exercise every Gridline feature -/// available for SQLite: PK/FK/composite-PK/self-FK metadata, JSON cells, -/// BLOBs, CHECK/UNIQUE constraints, defaults, indexes, views, an empty table, -/// a no-PK table (rowid editing), a TEXT primary key, and a 500-row table for -/// pagination / virtualization / filtering demos. +/// The demo schema + seed data. Loaded from `demo_schema.sql` so the SQL can +/// be edited and reviewed with normal editor tooling. fn get_demo_schema() -> String { - r#" - PRAGMA user_version = 2; - - -- ── Core: users ───────────────────────────────────────────────────────── - -- Row editing, JSON popover (preferences), nullable cols (phone/birth_date), - -- long text (bio -> scrolling textarea editor), smart-sort tiers - -- (updated_at / created_at / last_login_at), UNIQUE (email), defaults. - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - role TEXT NOT NULL DEFAULT 'user', - phone TEXT, - birth_date TEXT, - bio TEXT, - preferences json, - balance REAL NOT NULL DEFAULT 0, - is_active INTEGER NOT NULL DEFAULT 1, - last_login_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - - -- ── Core: categories (self-referencing FK) ────────────────────────────── - CREATE TABLE IF NOT EXISTS categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - parent_id INTEGER REFERENCES categories(id), - slug TEXT NOT NULL UNIQUE, - sort_order INTEGER NOT NULL DEFAULT 0, - description TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - - -- ── Core: products ────────────────────────────────────────────────────── - CREATE TABLE IF NOT EXISTS products ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - sku TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - description TEXT, - price REAL NOT NULL CHECK (price >= 0), - category_id INTEGER REFERENCES categories(id), - stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0), - rating REAL, - discontinued INTEGER NOT NULL DEFAULT 0, - attributes json, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - - -- ── Core: addresses (1:N from users, FK dropdown editor) ──────────────── - CREATE TABLE IF NOT EXISTS addresses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id), - label TEXT NOT NULL DEFAULT 'Home', - street TEXT NOT NULL, - city TEXT NOT NULL, - zip TEXT, - country TEXT NOT NULL DEFAULT 'USA', - is_primary INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - - -- ── Core: orders (two FKs -> users and addresses; CHECK status) ───────── - CREATE TABLE IF NOT EXISTS orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id), - shipping_address_id INTEGER REFERENCES addresses(id), - total REAL NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled')), - notes TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - shipped_at TEXT - ); - - -- ── Core: order_items (composite PRIMARY KEY; CHECK) ──────────────────── - CREATE TABLE IF NOT EXISTS order_items ( - order_id INTEGER NOT NULL REFERENCES orders(id), - product_id INTEGER NOT NULL REFERENCES products(id), - quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), - unit_price REAL NOT NULL, - PRIMARY KEY (order_id, product_id) - ); - - -- ── Big table for pagination / virtualization / filtering (500 rows) ──── - CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER REFERENCES users(id), - action TEXT NOT NULL, - entity_type TEXT NOT NULL, - entity_id INTEGER, - severity TEXT NOT NULL DEFAULT 'info' - CHECK (severity IN ('info','warning','error','critical')), - details json, - duration_ms INTEGER, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - - -- ── BLOB demo ─────────────────────────────────────────────────────────── - CREATE TABLE IF NOT EXISTS files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - mime_type TEXT NOT NULL, - content BLOB, - size_bytes INTEGER NOT NULL, - uploaded_by INTEGER REFERENCES users(id), - uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - - -- ── No primary key on purpose: exercises the rowid row-locator editing ── - CREATE TABLE IF NOT EXISTS page_views ( - url TEXT NOT NULL, - session_id TEXT NOT NULL, - user_agent TEXT, - viewed_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - - -- ── Non-integer (TEXT) primary key ────────────────────────────────────── - CREATE TABLE IF NOT EXISTS app_settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - - -- ── Deliberately empty: exercises the Empty Table change + empty state ── - CREATE TABLE IF NOT EXISTS marketing_campaigns ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - budget REAL, - starts_at TEXT, - ends_at TEXT, - status TEXT NOT NULL DEFAULT 'draft' - ); - - -- ── Read-only view for the query editor / ER diagram ──────────────────── - CREATE VIEW IF NOT EXISTS order_summary AS - SELECT - o.id AS order_id, - u.name AS customer_name, - COUNT(oi.product_id) AS item_count, - o.total AS order_total, - o.status, - o.created_at - FROM orders o - JOIN users u ON u.id = o.user_id - LEFT JOIN order_items oi ON oi.order_id = o.id - GROUP BY o.id, u.name, o.total, o.status, o.created_at; - - -- ── Indexes (surface in the copied DDL) ───────────────────────────────── - CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); - CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id); - CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at); - CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at); - - -- ═══════════════════════════════════════════════════════════════════════ - -- Seed data - -- ═══════════════════════════════════════════════════════════════════════ - - INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES - (1, 'Alice Johnson', 'alice@example.com', 'admin', '+1-555-0101', '1990-04-12', - 'Senior platform engineer and the store''s first admin. She manages the catalog, reviews every order before it ships, and keeps the demo data realistic.', - '{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US"}', - 249.50, 1, datetime('now','-2 hours'), datetime('now','-240 days'), datetime('now','-2 hours')), - (2, 'Bob Smith', 'bob@example.com', 'user', '+1-555-0102', '1985-11-30', - 'Loyal customer since 2021. Prefers mechanical keyboards and 4K displays, and always opts for the extended warranty.', - '{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-GB"}', - 12.00, 1, datetime('now','-1 day'), datetime('now','-210 days'), datetime('now','-1 day')), - (3, 'Carol Davis', 'carol@example.com', 'user', NULL, '1998-07-19', - 'Occasional shopper who mostly buys desk accessories for her home office in Seattle.', - NULL, - 0.00, 1, datetime('now','-6 days'), datetime('now','-180 days'), datetime('now','-6 days')), - (4, 'Dan Wilson', 'dan@example.com', 'user', '+1-555-0104', NULL, - 'Power user testing the checkout flow. Frequently leaves detailed feedback and files the occasional bug report.', - '{"theme":"system","notifications":{"email":false,"push":false},"locale":"de-DE"}', - 87.25, 0, datetime('now','-14 days'), datetime('now','-90 days'), datetime('now','-14 days')), - (5, 'Eve Martinez', 'eve@example.com', 'moderator', '+1-555-0105', '1993-02-08', - 'Moderator and community lead. Approves product reviews, helps with support tickets, and watches the audit log closely.', - '{"theme":"dark","notifications":{"email":true,"push":true},"locale":"fr-FR"}', - 33.75, 1, datetime('now','-35 minutes'), datetime('now','-45 days'), datetime('now','-35 minutes')); - - INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES - (1, 'Electronics', NULL, 'electronics', 1, 'Gadgets, displays and peripherals.', datetime('now','-300 days')), - (2, 'Accessories', NULL, 'accessories', 2, 'Cables, hubs and add-ons.', datetime('now','-300 days')), - (3, 'Office', NULL, 'office', 3, 'Furniture and desk essentials.', datetime('now','-300 days')), - (4, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-120 days')), - (5, 'Monitors', 1, 'monitors', 2, 'Displays from 24 to 32 inches.', datetime('now','-120 days')); - - INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES - (1, 'SKU-WM-001', 'Wireless Mouse', - 'A comfortable ambidextrous wireless mouse with silent click switches, 2.4 GHz dongle and Bluetooth 5.0, a 1600 DPI optical sensor, and a 12-month battery life on a single AA battery.', - 29.99, 1, 150, 4.5, 0, '{"color":"Graphite","wireless":true,"dpi":1600}', datetime('now','-260 days'), datetime('now','-3 days')), - (2, 'SKU-MK-001', 'Mechanical Keyboard', - 'Hot-swappable tenkeyless board with brown switches, per-key RGB backlighting, PBT double-shot keycaps and a CNC aluminium case. USB-C with a detachable braided cable.', - 89.99, 4, 75, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true}', datetime('now','-250 days'), datetime('now','-20 days')), - (3, 'SKU-HUB-001', 'USB-C Hub', - 'Seven-port hub with 4K HDMI, 100 W power delivery passthrough, two USB-A 3.2 ports, SD/microSD slots and an aluminium body that stays cool.', - 34.99, 2, 200, 4.2, 0, '{"ports":"7-in-1","power_delivery":"100W"}', datetime('now','-240 days'), datetime('now','-2 days')), - (4, 'SKU-MN-001', '27" 4K Monitor', - '27-inch IPS panel with 3840x2160 resolution, 60 Hz refresh, 99% sRGB coverage, USB-C upstream with 90 W charging and a fully adjustable stand.', - 449.99, 5, 30, 4.6, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS"}', datetime('now','-230 days'), datetime('now','-15 days')), - (5, 'SKU-LS-001', 'Laptop Stand', - 'Foldable aluminium stand with six height positions, ventilated design and soft silicone pads. Fits laptops from 12 to 16 inches.', - 49.99, 2, 100, 3.9, 0, NULL, datetime('now','-220 days'), datetime('now','-40 days')), - (6, 'SKU-WC-001', 'Webcam 1080p', - 'Full HD webcam with a privacy shutter, dual noise-reducing microphones and autofocus. Works with every major video call app out of the box.', - 59.99, 1, 0, 4.0, 1, '{"resolution":"1920x1080","fps":30,"microphone":true}', datetime('now','-200 days'), datetime('now','-60 days')), - (7, 'SKU-DL-001', 'Desk Lamp LED', - 'Dimmable LED desk lamp with adjustable colour temperature from 2700 K to 6500 K, a flexible neck and a built-in USB charging port.', - 39.99, 3, 120, 4.3, 0, '{"color_temp":"2700-6500K","dimmable":true}', datetime('now','-190 days'), datetime('now','-9 days')), - (8, 'SKU-EC-001', 'Ergonomic Chair', - 'Breathable mesh back, adjustable lumbar support, 4D armrests and a gas lift rated for up to 150 kg. Assembles in under twenty minutes.', - 599.99, 3, 15, 4.7, 0, '{"material":"mesh","lumbar_support":true}', datetime('now','-180 days'), datetime('now','-30 days')), - (9, 'SKU-UC-001', 'USB-C Cable 2m', - 'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data transfer. Tested for 10,000 bends.', - 14.99, 2, 500, 4.1, 0, '{"length_m":2,"charging":"100W"}', datetime('now','-170 days'), datetime('now','-5 days')), - (10, 'SKU-MA-001', 'Monitor Arm', - 'Single monitor arm with gas spring, 75/100 mm VESA mount, and 360 degree rotation. Supports monitors up to 9 kg.', - 79.99, 3, 40, NULL, 0, '{"weight_capacity_kg":9,"vesa":"75/100"}', datetime('now','-160 days'), datetime('now','-12 days')); - - INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, zip, country, is_primary, created_at) VALUES - (1, 1, 'Home', '100 Market Street', 'San Francisco', '94105', 'USA', 1, datetime('now','-200 days')), - (2, 1, 'Work', '200 Mission Street', 'San Francisco', '94105', 'USA', 0, datetime('now','-180 days')), - (3, 2, 'Home', '300 Lakeshore Drive', 'Austin', '78701', 'USA', 1, datetime('now','-150 days')), - (4, 3, 'Home', '400 Maple Avenue', 'Seattle', '98101', 'USA', 1, datetime('now','-120 days')), - (5, 4, 'Home', '500 Park Boulevard', 'New York', '10001', 'USA', 1, datetime('now','-90 days')), - (6, 5, 'Home', '600 Cedar Lane', 'Denver', '80202', 'USA', 1, datetime('now','-60 days')), - (7, 3, 'Cabin', '700 Pine Road', 'Bend', '97701', 'USA', 0, datetime('now','-30 days')), - (8, 5, 'Work', '800 Pearl Street', 'Denver', '80202', 'USA', 0, datetime('now','-7 days')); - - INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at) VALUES - (1, 1, 1, 94.97, 'completed', 'Please leave the package at the front desk.', datetime('now','-40 days'), datetime('now','-38 days'), datetime('now','-38 days')), - (2, 2, 3, 499.98, 'pending', NULL, datetime('now','-3 days'), datetime('now','-2 hours'), NULL), - (3, 3, 4, 59.99, 'completed', NULL, datetime('now','-20 days'), datetime('now','-19 days'), datetime('now','-19 days')), - (4, 1, 2, 89.99, 'shipped', 'Gift wrap, please.', datetime('now','-2 days'), datetime('now','-1 day'), datetime('now','-1 day')), - (5, 4, 5, 689.96, 'processing', NULL, datetime('now','-1 day'), datetime('now','-5 hours'), NULL), - (6, 5, 6, 74.98, 'cancelled', 'Customer requested cancellation before dispatch.', datetime('now','-5 days'), datetime('now','-4 days'), NULL); - - INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price) VALUES - (1, 1, 2, 29.99), - (1, 3, 1, 34.99), - (2, 4, 1, 449.99), - (2, 5, 1, 49.99), - (3, 6, 1, 59.99), - (4, 2, 1, 89.99), - (5, 8, 1, 599.99), - (5, 1, 3, 29.99), - (6, 7, 1, 39.99), - (6, 3, 1, 34.99); - - -- 500 generated rows for pagination / virtualization / filter demos. - -- Guarded by NOT EXISTS so re-running the schema never duplicates rows. - WITH RECURSIVE seq(n) AS ( - SELECT 1 - UNION ALL - SELECT n + 1 FROM seq WHERE n < 500 - ) - INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at) - SELECT - CASE WHEN n % 9 = 0 THEN NULL ELSE (n % 5) + 1 END, - CASE n % 6 WHEN 0 THEN 'login' WHEN 1 THEN 'page_view' WHEN 2 THEN 'update' - WHEN 3 THEN 'create' WHEN 4 THEN 'delete' ELSE 'export' END, - CASE n % 4 WHEN 0 THEN 'order' WHEN 1 THEN 'product' WHEN 2 THEN 'user' ELSE 'report' END, - (n % 40) + 1, - CASE n % 5 WHEN 0 THEN 'info' WHEN 1 THEN 'info' WHEN 2 THEN 'warning' - WHEN 3 THEN 'error' ELSE 'critical' END, - CASE WHEN n % 7 = 0 THEN NULL - ELSE '{"page":"/demo","retries":' || (n % 3) || ',"row":' || n || '}' END, - (n * 37) % 2000, - datetime('now', printf('-%d minutes', n * 7)) - FROM seq - WHERE NOT EXISTS (SELECT 1 FROM audit_log); - - INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES - (1, 'logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452', length(X'89504E470D0A1A0A0000000D49484452'), 1, datetime('now','-10 days')), - (2, 'photo.jpg', 'image/jpeg', X'FFD8FFE000104A464946000101', length(X'FFD8FFE000104A464946000101'), 2, datetime('now','-9 days')), - (3, 'manual.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD3', length(X'255044462D312E340A25E2E3CFD3'), NULL, datetime('now','-5 days')), - (4, 'archive.zip', 'application/zip', NULL, 0, 3, datetime('now','-1 day')), - (5, 'report.csv', 'text/csv', X'69642C6E616D650A312C616C696365', length(X'69642C6E616D650A312C616C696365'), 1, datetime('now','-4 hours')); - - INSERT INTO page_views (url, session_id, user_agent, viewed_at) SELECT * FROM ( - VALUES - ('/products', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-23 hours')), - ('/products/1', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-22 hours')), - ('/cart', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')), - ('/checkout', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')), - ('/orders', 'sess-003', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/127.0', datetime('now','-12 hours')), - ('/products/8', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')), - ('/login', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')), - ('/settings', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-5 hours')), - ('/categories/electronics', 'sess-005', 'curl/8.4.0', datetime('now','-3 hours')), - ('/products/4', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-2 hours')), - ('/checkout', 'sess-006', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-1 hour')), - ('/orders', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-30 minutes')) - ) WHERE NOT EXISTS (SELECT 1 FROM page_views); - - INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES - ('site_name', 'Gridline Demo Store', datetime('now','-30 days')), - ('maintenance_mode', 'false', datetime('now','-2 days')), - ('max_cart_items', '50', datetime('now','-14 days')), - ('currency', 'USD', datetime('now','-30 days')); - "# - .to_string() + include_str!("demo_schema.sql").to_string() } #[cfg(test)] #[path = "demo.test.rs"] -mod tests; \ No newline at end of file +mod tests; diff --git a/src-tauri/src/commands/demo.test.rs b/src-tauri/src/commands/demo.test.rs index 956df55..c3b7b2c 100644 --- a/src-tauri/src/commands/demo.test.rs +++ b/src-tauri/src/commands/demo.test.rs @@ -1,4 +1,5 @@ use super::*; +use crate::commands::db_viewer::build_sqlite_data_select; /// Open an in-memory SQLite database seeded with the demo schema. fn seed_demo() -> rusqlite::Connection { @@ -41,7 +42,10 @@ fn demo_schema_creates_expected_objects() { ]; assert_eq!( objects, - expected.into_iter().map(|(n, t)| (n.to_string(), t.to_string())).collect::>(), + expected + .into_iter() + .map(|(n, t)| (n.to_string(), t.to_string())) + .collect::>(), "demo schema must contain exactly the expected tables + view" ); } @@ -49,18 +53,30 @@ fn demo_schema_creates_expected_objects() { #[test] fn demo_schema_seeds_expected_rows() { let conn = seed_demo(); - assert_eq!(count(&conn, "users"), 5); - assert_eq!(count(&conn, "categories"), 5); - assert_eq!(count(&conn, "products"), 10); - assert_eq!(count(&conn, "addresses"), 8); - assert_eq!(count(&conn, "orders"), 6); - assert_eq!(count(&conn, "order_items"), 10); - assert_eq!(count(&conn, "audit_log"), 500, "audit_log must seed 500 rows for pagination/virtualization demos"); - assert_eq!(count(&conn, "files"), 5); - assert_eq!(count(&conn, "page_views"), 12); - assert_eq!(count(&conn, "app_settings"), 4); - assert_eq!(count(&conn, "marketing_campaigns"), 0, "marketing_campaigns must stay empty to demo the Empty Table change"); - assert_eq!(count(&conn, "order_summary"), 6, "view must return one row per order"); + assert_eq!(count(&conn, "users"), 20); + assert_eq!(count(&conn, "categories"), 6); + assert_eq!(count(&conn, "products"), 24); + assert_eq!(count(&conn, "addresses"), 23); + assert_eq!(count(&conn, "orders"), 50); + assert_eq!(count(&conn, "order_items"), 105); + assert_eq!( + count(&conn, "audit_log"), + 500, + "audit_log must seed 500 rows for pagination/virtualization demos" + ); + assert_eq!(count(&conn, "files"), 8); + assert_eq!(count(&conn, "page_views"), 100); + assert_eq!(count(&conn, "app_settings"), 6); + assert_eq!( + count(&conn, "marketing_campaigns"), + 0, + "marketing_campaigns must stay empty to demo the Empty Table change" + ); + assert_eq!( + count(&conn, "order_summary"), + 50, + "view must return one row per order" + ); } #[test] @@ -69,10 +85,10 @@ fn demo_schema_is_idempotent() { // Re-running the full schema (e.g. on a fresh file after a partial seed) // must not duplicate rows. conn.execute_batch(&get_demo_schema()).unwrap(); - assert_eq!(count(&conn, "users"), 5); + assert_eq!(count(&conn, "users"), 20); assert_eq!(count(&conn, "audit_log"), 500); - assert_eq!(count(&conn, "page_views"), 12); - assert_eq!(count(&conn, "products"), 10); + assert_eq!(count(&conn, "page_views"), 100); + assert_eq!(count(&conn, "products"), 24); } #[test] @@ -81,7 +97,10 @@ fn demo_schema_sets_user_version() { let v: i64 = conn .query_row("PRAGMA user_version", [], |r| r.get(0)) .unwrap(); - assert_eq!(v, DEMO_SCHEMA_VERSION, "demo file must stamp PRAGMA user_version for upgrade detection"); + assert_eq!( + v, DEMO_SCHEMA_VERSION, + "demo file must stamp PRAGMA user_version for upgrade detection" + ); } #[test] @@ -89,7 +108,11 @@ fn demo_json_columns_hold_valid_json() { let conn = seed_demo(); // Every non-NULL value in a `json`-declared column must parse as JSON so // the grid's JSON popover can format it. - for (table, column) in [("users", "preferences"), ("products", "attributes"), ("audit_log", "details")] { + for (table, column) in [ + ("users", "preferences"), + ("products", "attributes"), + ("audit_log", "details"), + ] { let mut stmt = conn .prepare(&format!( "SELECT {column} FROM {table} WHERE {column} IS NOT NULL" @@ -100,7 +123,10 @@ fn demo_json_columns_hold_valid_json() { .unwrap() .filter_map(|r| r.ok()) .collect(); - assert!(!values.is_empty(), "{table}.{column} should have non-null values"); + assert!( + !values.is_empty(), + "{table}.{column} should have non-null values" + ); for v in &values { assert!( serde_json::from_str::(v).is_ok(), @@ -110,7 +136,11 @@ fn demo_json_columns_hold_valid_json() { } // And the declared type must be lowercase `json` so the frontend's // `data_type === "json"` check triggers the JSON cell popover. - for (table, column) in [("users", "preferences"), ("products", "attributes"), ("audit_log", "details")] { + for (table, column) in [ + ("users", "preferences"), + ("products", "attributes"), + ("audit_log", "details"), + ] { let dt: String = conn .query_row( &format!("SELECT type FROM pragma_table_info('{table}') WHERE name = '{column}'"), @@ -118,7 +148,10 @@ fn demo_json_columns_hold_valid_json() { |r| r.get(0), ) .unwrap(); - assert_eq!(dt, "json", "{table}.{column} must be declared lowercase json"); + assert_eq!( + dt, "json", + "{table}.{column} must be declared lowercase json" + ); } } @@ -127,16 +160,88 @@ fn demo_view_is_queryable() { let conn = seed_demo(); let mut stmt = conn.prepare("SELECT order_id, customer_name, item_count, order_total, status FROM order_summary ORDER BY order_id").unwrap(); let rows: Vec<(i64, String, i64, f64, String)> = stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))) + .query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }) .unwrap() .filter_map(|r| r.ok()) .collect(); - assert_eq!(rows.len(), 6); + assert_eq!(rows.len(), 50); let first = &rows[0]; assert_eq!(first.0, 1); - assert_eq!(first.1, "Alice Johnson"); - assert_eq!(first.2, 2, "order 1 should have 2 line items"); - assert_eq!(first.4, "completed"); + assert_eq!(first.1, "Sarah Chen"); + assert_eq!(first.4, "processing"); + // order 1 is populated by generated line items; just ensure it has at least one. + assert!(first.2 >= 1, "order 1 should have at least one line item"); +} + +#[test] +fn demo_view_loads_through_table_data_path() { + // Regression: a view tab used to fail with "no such column: rowid" because + // the data SELECT appended the rowid locator (views have no PK → has_pk + // false → locator appended → views expose no rowid). This mirrors the + // SQLite branch of get_table_data exactly. + let conn = seed_demo(); + let is_view: bool = conn + .query_row( + "SELECT type = 'view' FROM sqlite_master WHERE name = ?1 AND type IN ('table', 'view')", + ["order_summary"], + |r| r.get::<_, bool>(0), + ) + .unwrap(); + assert!(is_view, "order_summary must be a view"); + + let mut pragma_stmt = conn.prepare("PRAGMA table_info('order_summary')").unwrap(); + let col_meta: Vec<(String, bool)> = pragma_stmt + .query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, bool>(5)?)) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + let has_pk = col_meta.iter().any(|(_, pk)| *pk); + assert!(!has_pk, "views report no PK from PRAGMA table_info"); + + let visible_names: Vec = col_meta.iter().map(|(n, _)| n.clone()).collect(); + let data_query = format!( + "{} WHERE 1=1 LIMIT 50 OFFSET 0", + build_sqlite_data_select("order_summary", &visible_names, !has_pk && !is_view) + ); + assert!( + !data_query.contains("rowid"), + "view data SELECT must not select rowid; got: {}", + data_query + ); + let mut stmt = conn.prepare(&data_query).unwrap(); + let col_count = stmt.column_count(); + let rows: Vec> = stmt + .query_map([], |row| { + let mut vals = Vec::new(); + for i in 0..col_count { + vals.push(row.get::<_, rusqlite::types::Value>(i)?); + } + Ok(vals) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + let expected: i64 = conn + .query_row("SELECT COUNT(*) FROM \"main\".\"order_summary\"", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!( + rows.len() as i64, + expected, + "view data query must return every view row (count = {expected})" + ); + assert!(!rows.is_empty(), "seeded view must not be empty"); } #[test] @@ -146,7 +251,9 @@ fn demo_foreign_keys_are_consistent() { let violations: Vec<(String, i64, String, i64)> = conn .prepare("PRAGMA foreign_key_check") .unwrap() - .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) .unwrap() .filter_map(|r| r.ok()) .collect(); @@ -178,14 +285,24 @@ fn demo_tables_exercise_key_constraint_shapes() { .filter_map(|r| r.ok()) .collect(); assert_eq!( - composite.iter().map(|(n, _)| n.as_str()).collect::>(), + composite + .iter() + .map(|(n, _)| n.as_str()) + .collect::>(), vec!["order_id", "product_id"] ); // page_views: no primary key at all (rowid row-locator editing demo). let pk_cols: i64 = conn - .query_row("SELECT COUNT(*) FROM pragma_table_info('page_views') WHERE pk > 0", [], |r| r.get(0)) + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('page_views') WHERE pk > 0", + [], + |r| r.get(0), + ) .unwrap(); - assert_eq!(pk_cols, 0, "page_views must have no PK so the rowid locator kicks in"); + assert_eq!( + pk_cols, 0, + "page_views must have no PK so the rowid locator kicks in" + ); // app_settings: TEXT primary key. let text_pk: String = conn .query_row( @@ -216,20 +333,34 @@ fn demo_file_is_recreated_when_stale() { // Stale file: old version, old schema. let stale = rusqlite::Connection::open(&path).unwrap(); - stale.execute_batch("PRAGMA user_version = 1; CREATE TABLE legacy (id INTEGER PRIMARY KEY);") + stale + .execute_batch("PRAGMA user_version = 1; CREATE TABLE legacy (id INTEGER PRIMARY KEY);") .unwrap(); drop(stale); ensure_demo_file(&path).unwrap(); let conn = rusqlite::Connection::open(&path).unwrap(); - let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap(); - assert_eq!(v, DEMO_SCHEMA_VERSION, "stale demo file must be recreated with the current version"); - assert_eq!(count(&conn, "users"), 5, "recreated file must be fully seeded"); + let v: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + v, DEMO_SCHEMA_VERSION, + "stale demo file must be recreated with the current version" + ); + assert_eq!( + count(&conn, "users"), + 20, + "recreated file must be fully seeded" + ); drop(conn); // Current file: ensure_demo_file must not touch it. ensure_demo_file(&path).unwrap(); let conn = rusqlite::Connection::open(&path).unwrap(); - assert_eq!(count(&conn, "users"), 5, "current demo file must not be reseeded"); + assert_eq!( + count(&conn, "users"), + 20, + "current demo file must not be reseeded" + ); // Regenerate flow: the file is deleted entirely, then recreated from // scratch with the current schema (what `regenerate_demo_db` does). @@ -237,10 +368,12 @@ fn demo_file_is_recreated_when_stale() { std::fs::remove_file(&path).unwrap(); ensure_demo_file(&path).unwrap(); let conn = rusqlite::Connection::open(&path).unwrap(); - let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap(); + let v: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); assert_eq!(v, DEMO_SCHEMA_VERSION); - assert_eq!(count(&conn, "users"), 5); + assert_eq!(count(&conn, "users"), 20); assert_eq!(count(&conn, "audit_log"), 500); std::fs::remove_dir_all(&dir).ok(); -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/demo_schema.sql b/src-tauri/src/commands/demo_schema.sql new file mode 100644 index 0000000..4569fbf --- /dev/null +++ b/src-tauri/src/commands/demo_schema.sql @@ -0,0 +1,492 @@ +PRAGMA user_version = 3; + +-- ═══════════════════════════════════════════════════════════════════════ +-- Gridline Demo (SQLite) — realistic e-commerce dataset +-- +-- Designed to exercise every SQLite-aware Gridline feature: +-- • PK/FK/composite-PK/self-FK metadata +-- • JSON cells + JSON popover +-- • BLOBs +-- • CHECK / UNIQUE constraints, defaults +-- • indexes +-- • views +-- • an empty table (Empty Table change demo) +-- • a no-PK table (rowid row-locator editing) +-- • a TEXT primary key +-- • a 500-row table for pagination / virtualization / filtering +-- • nullable columns, long text, smart-sort tiers +-- ═══════════════════════════════════════════════════════════════════════ + +-- ── Core: users ───────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('admin','moderator','customer','guest')), + phone TEXT, + birth_date TEXT, + bio TEXT, + preferences json, + balance REAL NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + last_login_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Core: categories (self-referencing FK) ────────────────────────────── +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES categories(id), + slug TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0, + description TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Core: products ────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + price REAL NOT NULL CHECK (price >= 0), + category_id INTEGER REFERENCES categories(id), + stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0), + rating REAL CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)), + discontinued INTEGER NOT NULL DEFAULT 0, + attributes json, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Core: addresses (1:N from users, FK dropdown editor) ──────────────── +CREATE TABLE IF NOT EXISTS addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + label TEXT NOT NULL DEFAULT 'Home', + street TEXT NOT NULL, + city TEXT NOT NULL, + state TEXT, + zip TEXT, + country TEXT NOT NULL DEFAULT 'USA', + is_primary INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Core: orders (FKs -> users and addresses; CHECK status) ───────────── +CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + shipping_address_id INTEGER REFERENCES addresses(id), + total REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled','refunded')), + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + shipped_at TEXT +); + +-- ── Core: order_items (composite PRIMARY KEY; CHECK) ─────────────────── +CREATE TABLE IF NOT EXISTS order_items ( + order_id INTEGER NOT NULL REFERENCES orders(id), + product_id INTEGER NOT NULL REFERENCES products(id), + quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), + unit_price REAL NOT NULL, + PRIMARY KEY (order_id, product_id) +); + +-- ── Big table for pagination / virtualization / filtering (500 rows) ── +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + severity TEXT NOT NULL DEFAULT 'info' + CHECK (severity IN ('info','warning','error','critical')), + details json, + duration_ms INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── BLOB demo ─────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + mime_type TEXT NOT NULL, + content BLOB, + size_bytes INTEGER NOT NULL, + uploaded_by INTEGER REFERENCES users(id), + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── No primary key on purpose: exercises the rowid row-locator editing ── +CREATE TABLE IF NOT EXISTS page_views ( + url TEXT NOT NULL, + session_id TEXT NOT NULL, + user_agent TEXT, + viewed_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Non-integer (TEXT) primary key ───────────────────────────────────── +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Deliberately empty: exercises the Empty Table change + empty state ── +CREATE TABLE IF NOT EXISTS marketing_campaigns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + budget REAL, + starts_at TEXT, + ends_at TEXT, + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft','active','paused','completed','cancelled')) +); + +-- ── Read-only view for the query editor / ER diagram ─────────────────── +CREATE VIEW IF NOT EXISTS order_summary AS +SELECT + o.id AS order_id, + u.name AS customer_name, + COUNT(oi.product_id) AS item_count, + o.total AS order_total, + o.status, + o.created_at +FROM orders o +JOIN users u ON u.id = o.user_id +LEFT JOIN order_items oi ON oi.order_id = o.id +GROUP BY o.id, u.name, o.total, o.status, o.created_at; + +-- ── Indexes (surface in the copied DDL) ───────────────────────────────── +CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); +CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at); +CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at); + +-- ═══════════════════════════════════════════════════════════════════════ +-- Seed data +-- ═══════════════════════════════════════════════════════════════════════ + +-- ── Categories ────────────────────────────────────────────────────────── +INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES +(1, 'Electronics', NULL, 'electronics', 1, 'Computers, phones, and peripherals.', datetime('now','-400 days')), +(2, 'Accessories', NULL, 'accessories', 2, 'Cables, cases, hubs, and add-ons.', datetime('now','-400 days')), +(3, 'Office', NULL, 'office', 3, 'Furniture, lighting, and desk essentials.', datetime('now','-400 days')), +(4, 'Furniture', NULL, 'furniture', 4, 'Chairs, desks, shelves, and storage.', datetime('now','-380 days')), +(5, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-300 days')), +(6, 'Monitors', 1, 'monitors', 2, 'External displays and monitor arms.', datetime('now','-300 days')); + +-- ── Users: a realistic mix of admins, moderators, customers, and guests ─── +INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES +(1, 'Sarah Chen', 'sarah.chen@acme.dev', 'admin', '+1-415-555-0101', '1990-04-12', + 'Founding engineer at Acme Dev. Manages the catalog, reviews flagged orders, and keeps the demo data looking sharp.', + '{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}', + 1240.75, 1, datetime('now','-35 minutes'), datetime('now','-540 days'), datetime('now','-35 minutes')), +(2, 'Marcus Johnson', 'marcus.j@pixelforge.studio', 'moderator', '+1-512-555-0192', '1985-11-30', + 'Customer-success lead and part-time photographer. Moderates reviews, handles refunds, and tracks the weekly audit log.', + '{"theme":"system","notifications":{"email":true,"push":false},"locale":"en-GB","timezone":"America/Chicago"}', + 48.20, 1, datetime('now','-6 hours'), datetime('now','-510 days'), datetime('now','-6 hours')), +(3, 'Emily Rodriguez', 'emily.r@techbloom.io', 'customer', '+1-206-555-0143', '1993-07-19', + 'Remote frontend developer based in Seattle. Buys desk accessories in bulk and leaves detailed product reviews.', + '{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}', + 315.50, 1, datetime('now','-2 days'), datetime('now','-480 days'), datetime('now','-2 days')), +(4, 'David Kim', 'david.kim@outlook.com', 'customer', '+1-212-555-0178', '1988-03-04', + 'Freelance sound engineer in New York. Orders AV gear and cables regularly for his home studio.', + '{"theme":"dark","notifications":{"email":false,"push":true},"locale":"ko-KR","timezone":"America/New_York"}', + 0.00, 1, datetime('now','-4 days'), datetime('now','-450 days'), datetime('now','-4 days')), +(5, 'Aisha Patel', 'aisha.patel@horizonlabs.co', 'customer', '+1-303-555-0165', '1996-02-08', + 'Data analyst and ergonomic-chair evangelist. Currently outfitting a standing-desk setup for her team.', + '{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Denver"}', + 892.10, 1, datetime('now','-12 hours'), datetime('now','-420 days'), datetime('now','-12 hours')), +(6, 'James O’Brien', 'james.obrien@fastmail.com', 'customer', '+1-617-555-0134', '1991-09-23', + 'DevOps contractor. Bought a monitor arm and never looked back. Always asks for gift receipts.', + NULL, + 67.99, 1, datetime('now','-1 day'), datetime('now','-390 days'), datetime('now','-1 day')), +(7, 'Yuki Tanaka', 'yuki.tanaka@me.com', 'customer', '+1-650-555-0189', '1994-05-17', + 'Product designer at a fintech startup. Switches between light and dark mode depending on the weather.', + '{"theme":"system","notifications":{"email":true,"push":true},"locale":"ja-JP","timezone":"America/Los_Angeles"}', + 150.00, 0, datetime('now','-28 days'), datetime('now','-360 days'), datetime('now','-28 days')), +(8, 'Olivia Müller', 'olivia.mueller@werkstatt.de', 'customer', '+49-30-555-0921', '1987-12-01', + 'Berlin-based architect. Orders office lamps and cable organizers for her co-working space.', + '{"theme":"light","notifications":{"email":true,"push":false},"locale":"de-DE","timezone":"Europe/Berlin"}', + 540.30, 1, datetime('now','-5 days'), datetime('now','-330 days'), datetime('now','-5 days')), +(9, 'Carlos Rivera', 'carlos.rivera@openmail.net', 'customer', '+1-305-555-0156', '1992-08-30', + 'Miami-based content creator. Replaced his entire streaming setup through the store last quarter.', + '{"theme":"dark","notifications":{"email":false,"push":false},"locale":"es-MX","timezone":"America/New_York"}', + 28.50, 1, datetime('now','-3 hours'), datetime('now','-300 days'), datetime('now','-3 hours')), +(10, 'Priya Sharma', 'priya.sharma@nimbus.team', 'customer', '+1-408-555-0127', '1995-01-14', + 'Engineering manager at Nimbus. Buys team gear in batches and expects invoices by email.', + '{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-IN","timezone":"America/Los_Angeles"}', + 2100.00, 1, datetime('now','-50 minutes'), datetime('now','-270 days'), datetime('now','-50 minutes')), +(11, 'Liam Thompson', 'liam.t@rivermail.com', 'guest', NULL, '1999-06-05', + 'University student window-shopping for a first mechanical keyboard. Has not completed a purchase yet.', + '{"theme":"system","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/New_York"}', + 0.00, 1, datetime('now','-7 days'), datetime('now','-240 days'), datetime('now','-7 days')), +(12, 'Sofia Andersen', 'sofia.andersen@nordic.design', 'customer', '+46-8-555-0733', '1989-10-28', + 'Scandinavian design consultant. Values minimal packaging and fast, trackable shipping.', + '{"theme":"light","notifications":{"email":true,"push":false},"locale":"sv-SE","timezone":"Europe/Stockholm"}', + 175.80, 1, datetime('now','-10 hours'), datetime('now','-210 days'), datetime('now','-10 hours')), +(13, 'Benjamin Wright', 'ben.wright@compose.ly', 'customer', '+1-503-555-0198', '1993-04-11', + 'Music producer and software tinkerer. Always adds a USB-C cable to every order just in case.', + '{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}', + 430.25, 1, datetime('now','-18 hours'), datetime('now','-180 days'), datetime('now','-18 hours')), +(14, 'Hannah Lee', 'hannah.lee@cloudscale.io', 'admin', '+1-206-555-0204', '1990-07-22', + 'Platform reliability lead. Monitors the audit log and keeps the demo app settings up to date.', + '{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}', + 3500.00, 1, datetime('now','-15 minutes'), datetime('now','-150 days'), datetime('now','-15 minutes')), +(15, 'Noah Fischer', 'noah.fischer@bytehaus.at', 'customer', '+43-1-555-0817', '1997-03-15', + 'Vienna-based indie game dev. Needs low-latency peripherals and a quiet mechanical keyboard.', + '{"theme":"system","notifications":{"email":false,"push":true},"locale":"de-AT","timezone":"Europe/Vienna"}', + 95.00, 1, datetime('now','-2 days'), datetime('now','-120 days'), datetime('now','-2 days')), +(16, 'Grace Okafor', 'grace.okafor@lift.ng', 'customer', '+234-1-555-0294', '1986-11-09', + 'Remote team lead in Lagos. Orders standing desks and monitor arms for distributed teammates.', + '{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-NG","timezone":"Africa/Lagos"}', + 1280.00, 1, datetime('now','-9 hours'), datetime('now','-90 days'), datetime('now','-9 hours')), +(17, 'Ethan Brooks', 'ethan.brooks@nullsecurity.dev', 'customer', '+1-720-555-0112', '1994-12-30', + 'Security researcher with a dry sense of humor. Tests the demo with suspiciously long JSON blobs.', + '{"theme":"dark","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/Denver"}', + 12.49, 1, datetime('now','-5 days'), datetime('now','-60 days'), datetime('now','-5 days')), +(18, 'Mia Rossi', 'mia.rossi@artigiano.it', 'customer', '+39-02-555-0366', '1991-05-03', + 'Graphic designer from Milan. Cares a lot about color-accurate monitors and clean desk lighting.', + '{"theme":"light","notifications":{"email":true,"push":true},"locale":"it-IT","timezone":"Europe/Rome"}', + 725.60, 1, datetime('now','-1 day'), datetime('now','-30 days'), datetime('now','-1 day')), +(19, 'Alexander Petrov', 'alex.petrov@polycode.ru', 'customer', '+7-495-555-0412', '1984-08-18', + 'Backend engineer in Moscow. Maintains a home lab and buys networking accessories in pairs.', + '{"theme":"system","notifications":{"email":true,"push":false},"locale":"ru-RU","timezone":"Europe/Moscow"}', + 199.00, 0, datetime('now','-45 days'), datetime('now','-15 days'), datetime('now','-45 days')), +(20, 'Zoe Williams', 'zoe.williams@greenleaf.org', 'moderator', '+1-510-555-0285', '1992-02-25', + 'Sustainability coordinator. Reviews product descriptions and pushes for paperless invoices.', + '{"theme":"light","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Los_Angeles"}', + 88.00, 1, datetime('now','-6 hours'), datetime('now','-7 days'), datetime('now','-6 hours')); + +-- ── Products: realistic SKUs, descriptions, and attributes ────────────── +INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES +(1, 'GL-MSE-001', 'Gridline Wireless Mouse', 'Ergonomic wireless mouse with silent switches, 2.4 GHz and Bluetooth 5.0, adjustable 800-1600 DPI, and a 12-month battery life.', 34.99, 1, 142, 4.6, 0, '{"color":"Graphite","wireless":true,"dpi_max":1600,"buttons":5}', datetime('now','-360 days'), datetime('now','-3 days')), +(2, 'GL-KBD-001', 'Gridline Mechanical Keyboard', 'Hot-swappable TKL board with tactile brown switches, per-key RGB, PBT keycaps, and a USB-C braided cable.', 119.99, 5, 68, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true,"connection":"wired"}', datetime('now','-350 days'), datetime('now','-5 days')), +(3, 'GL-HUB-001', 'Gridline 7-in-1 USB-C Hub', 'Aluminium hub with 4K HDMI, 100 W power delivery, two USB-A 3.2 ports, SD/microSD slots, and a braided cable.', 44.99, 2, 215, 4.4, 0, '{"ports":7,"hdmi":"4K60","power_delivery":"100W","material":"aluminium"}', datetime('now','-340 days'), datetime('now','-2 days')), +(4, 'GL-MON-001', 'Gridline 27" 4K USB-C Monitor', '27-inch IPS panel, 3840×2160, 60 Hz, 99% sRGB, USB-C upstream with 90 W charging, fully adjustable stand.', 499.99, 6, 28, 4.7, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS","usb_c_power":90}', datetime('now','-330 days'), datetime('now','-8 days')), +(5, 'GL-STD-001', 'Gridline Aluminium Laptop Stand', 'Foldable aluminium stand with six height positions, ventilated design and silicone pads. Fits 12-16 inch laptops.', 54.99, 2, 108, 4.2, 0, '{"color":"Silver","max_height_mm":280,"foldable":true}', datetime('now','-320 days'), datetime('now','-10 days')), +(6, 'GL-CAM-001', 'Gridline 1080p Webcam', 'Full HD webcam with privacy shutter, dual noise-reducing mics, autofocus, and plug-and-play compatibility.', 69.99, 1, 0, 4.1, 1, '{"resolution":"1920x1080","fps":30,"microphone":true,"autofocus":true}', datetime('now','-310 days'), datetime('now','-60 days')), +(7, 'GL-LMP-001', 'Gridline LED Desk Lamp', 'Dimmable LED lamp with 2700-6500 K colour temperature, flexible neck, and a built-in USB-A charging port.', 42.99, 3, 134, 4.5, 0, '{"color_temp_k":"2700-6500","dimmable":true,"usb_port":true}', datetime('now','-300 days'), datetime('now','-6 days')), +(8, 'GL-CHR-001', 'Gridline Ergonomic Mesh Chair', 'Breathable mesh back, adjustable lumbar support, 4D armrests, and a gas lift rated up to 150 kg.', 649.99, 4, 12, 4.7, 0, '{"material":"mesh","lumbar_support":true,"max_weight_kg":150}', datetime('now','-290 days'), datetime('now','-15 days')), +(9, 'GL-CBL-001', 'Gridline Braided USB-C Cable 2 m', 'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data. Tested to 10,000 bends.', 16.99, 2, 480, 4.3, 0, '{"length_m":2,"charging_w":100,"data_speed":"USB 3.2","color":"Midnight"}', datetime('now','-280 days'), datetime('now','-4 days')), +(10, 'GL-ARM-001', 'Gridline Single Monitor Arm', 'Gas-spring monitor arm with 75/100 mm VESA, 360° rotation, and cable management. Supports up to 9 kg.', 89.99, 6, 36, 4.5, 0, '{"weight_capacity_kg":9,"vesa":"75/100","rotation":"360"}', datetime('now','-270 days'), datetime('now','-9 days')), +(11, 'GL-MSE-002', 'Gridline Pro Wireless Mouse', 'Premium wireless mouse with 4000 DPI sensor, USB-C rechargeable battery, and magnetic storage case.', 79.99, 1, 64, 4.7, 0, '{"color":"Pearl","wireless":true,"dpi_max":4000,"rechargeable":true}', datetime('now','-260 days'), datetime('now','-7 days')), +(12, 'GL-KBD-002', 'Gridline Compact Keyboard', '65% low-profile mechanical keyboard with red linear switches, white backlight, and Bluetooth/wired dual mode.', 94.99, 5, 91, 4.4, 0, '{"layout":"65%","switches":"red linear","backlit":true,"wireless":true}', datetime('now','-250 days'), datetime('now','-6 days')), +(13, 'GL-HUB-002', 'Gridline 10-in-1 Docking Station', 'Thunderbolt-compatible dock with dual 4K display support, 2.5 GbE, and 140 W power delivery.', 219.99, 2, 41, 4.6, 0, '{"ports":10,"ethernet":"2.5G","power_delivery":"140W","displays":2}', datetime('now','-240 days'), datetime('now','-3 days')), +(14, 'GL-MON-002', 'Gridline 32" Curved Monitor', '32-inch curved VA panel, 2560×1440, 165 Hz refresh rate, HDR400, and adaptive sync.', 429.99, 6, 19, 4.5, 0, '{"resolution":"2560x1440","refresh_hz":165,"panel":"VA","curved":true}', datetime('now','-230 days'), datetime('now','-11 days')), +(15, 'GL-DES-001', 'Gridline Standing Desk Frame', 'Electric sit-stand desk frame with dual motors, memory presets, and a quiet 45 dB lift mechanism.', 549.99, 4, 8, 4.6, 0, '{"width_adjustable":true,"memory_presets":4,"noise_db":45}', datetime('now','-220 days'), datetime('now','-12 days')), +(16, 'GL-KEY-001', 'Gridline Keycap Set', 'PBT dye-sub keycap set with 140 keys, compatible with most ANSI and ISO layouts.', 39.99, 5, 77, 4.3, 0, '{"keys":140,"material":"PBT","profile":"Cherry"}', datetime('now','-210 days'), datetime('now','-5 days')), +(17, 'GL-PAD-001', 'Gridline Desk Mat', 'Oversized felt desk mat with stitched edges and anti-slip base. 900 × 400 mm.', 29.99, 3, 156, 4.4, 0, '{"material":"felt","size_mm":"900x400","anti_slip":true}', datetime('now','-200 days'), datetime('now','-8 days')), +(18, 'GL-MIC-001', 'Gridline USB Microphone', 'Cardioid condenser mic with built-in pop filter, mute button, and 24-bit/96 kHz sampling.', 119.99, 1, 33, 4.5, 0, '{"pattern":"cardioid","sample_rate":"96kHz","bit_depth":24}', datetime('now','-190 days'), datetime('now','-9 days')), +(19, 'GL-LGT-001', 'Gridline Monitor Light Bar', 'Screen-hanging light bar with auto-dimming, warm/cool temperature control, and no glare on the panel.', 59.99, 3, 88, 4.2, 0, '{"mount":"screen_hanging","auto_dim":true,"color_temp_k":"3000-6500"}', datetime('now','-180 days'), datetime('now','-7 days')), +(20, 'GL-CBL-002', 'Gridline Magnetic Cable Trio', 'Set of three braided magnetic cables (USB-C, Lightning, Micro-USB) with one interchangeable tip dock.', 34.99, 2, 203, 4.0, 0, '{"cables":3,"tips":["USB-C","Lightning","Micro-USB"],"magnetic":true}', datetime('now','-170 days'), datetime('now','-6 days')), +(21, 'GL-BAG-001', 'Gridline Tech Pouch', 'Water-resistant tech pouch with elastic loops, mesh pockets, and a cable pass-through.', 44.99, 2, 119, 4.5, 0, '{"water_resistant":true,"compartments":8,"color":"Charcoal"}', datetime('now','-160 days'), datetime('now','-4 days')), +(22, 'GL-SPK-001', 'Gridline Desktop Speakers', 'Pair of powered bookshelf speakers with Bluetooth input, RCA/AUX, and solid wood enclosures.', 149.99, 1, 22, 4.3, 0, '{"pair":true,"inputs":["Bluetooth","RCA","AUX"],"enclosure":"wood"}', datetime('now','-150 days'), datetime('now','-10 days')), +(23, 'GL-TRP-001', 'Gridline Tripod Desk Lamp', 'Minimal tripod desk lamp with touch dimming, USB-C power, and a 360° rotating head.', 49.99, 3, 62, 4.1, 0, '{"base":"tripod","touch_dim":true,"power":"USB-C"}', datetime('now','-140 days'), datetime('now','-8 days')), +(24, 'GL-HDM-001', 'Gridline HDMI 2.1 Cable 1.5 m', 'Certified Ultra High Speed HDMI 2.1 cable supporting 8K@60 Hz and 4K@120 Hz, 48 Gbps.', 24.99, 2, 312, 4.4, 0, '{"length_m":1.5,"hdmi_version":"2.1","bandwidth_gbps":48}', datetime('now','-130 days'), datetime('now','-3 days')); + +-- ── Addresses: 1-3 realistic addresses per user ──────────────────────── +INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, state, zip, country, is_primary, created_at) VALUES +(1, 1, 'Home', '1234 Mission Street, Apt 42', 'San Francisco', 'CA', '94103', 'USA', 1, datetime('now','-500 days')), +(2, 1, 'Work', '555 Montgomery Street, Floor 8', 'San Francisco', 'CA', '94111', 'USA', 0, datetime('now','-480 days')), +(3, 2, 'Home', '2100 Barton Creek Blvd', 'Austin', 'TX', '78735', 'USA', 1, datetime('now','-490 days')), +(4, 3, 'Home', '4542 11th Avenue NE', 'Seattle', 'WA', '98105', 'USA', 1, datetime('now','-470 days')), +(5, 3, 'Office', '7200 Woodlawn Ave NE', 'Seattle', 'WA', '98115', 'USA', 0, datetime('now','-460 days')), +(6, 4, 'Home', '850 7th Avenue, Apt 12B', 'New York', 'NY', '10019', 'USA', 1, datetime('now','-440 days')), +(7, 5, 'Home', '1600 Glenarm Place', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-410 days')), +(8, 5, 'Warehouse', '4650 Paris Street', 'Denver', 'CO', '80239', 'USA', 0, datetime('now','-400 days')), +(9, 6, 'Home', '44 Prince Street', 'Boston', 'MA', '02113', 'USA', 1, datetime('now','-380 days')), +(10, 7, 'Home', '1080 Arastradero Road', 'Palo Alto', 'CA', '94304', 'USA', 1, datetime('now','-350 days')), +(11, 8, 'Office', 'Friedrichstraße 123', 'Berlin', 'Berlin', '10117', 'Germany', 1, datetime('now','-320 days')), +(12, 9, 'Home', '1500 Ocean Drive, Apt 805', 'Miami Beach', 'FL', '33139', 'USA', 1, datetime('now','-290 days')), +(13, 10, 'Work', '555 Ellis Street', 'Mountain View', 'CA', '94043', 'USA', 1, datetime('now','-260 days')), +(14, 11, 'Home', '1925 SE Hawthorne Blvd', 'Portland', 'OR', '97214', 'USA', 1, datetime('now','-230 days')), +(15, 12, 'Office', 'Birger Jarlsgatan 58', 'Stockholm', 'Stockholm', '111 45', 'Sweden', 1, datetime('now','-200 days')), +(16, 13, 'Home', '925 W 5th Avenue', 'Chicago', 'IL', '60642', 'USA', 1, datetime('now','-170 days')), +(17, 14, 'Home', '2211 7th Avenue', 'Seattle', 'WA', '98121', 'USA', 1, datetime('now','-140 days')), +(18, 15, 'Home', 'Opernring 5', 'Vienna', 'Vienna', '1010', 'Austria', 1, datetime('now','-110 days')), +(19, 16, 'Home', '12 Ikoyi Crescent', 'Lagos', 'Lagos', '101233', 'Nigeria', 1, datetime('now','-80 days')), +(20, 17, 'Home', '1600 Walnut Street, Unit 300', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-50 days')), +(21, 18, 'Home', 'Via Tortona 12', 'Milan', 'MI', '20144', 'Italy', 1, datetime('now','-25 days')), +(22, 19, 'Home', 'Ulitsa Bolshaya Dmitrovka 9', 'Moscow', 'Moscow', '125009', 'Russia', 1, datetime('now','-10 days')), +(23, 20, 'Home', '1900 Broadway', 'Oakland', 'CA', '94612', 'USA', 1, datetime('now','-5 days')); + +-- ── Files: realistic file attachments with BLOB headers ───────────────── +INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES +(1, 'gridline_logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452000001000000010008060000005C72A866000000017352474200AECE1CE90000000467414D410000B18F0BFC61050000', 14256, 1, datetime('now','-45 days')), +(2, 'product_photos.zip', 'application/zip', X'504B03040A00000000008B6B2D570000000000000000000000000800000070726F64756374732F504B0102001F000A00000000008B6B2D57000000000000000000000000080000000000000000000000A4810000000070726F64756374732F504B050600000000010001003A0000001A0000000000', 28934, 2, datetime('now','-42 days')), +(3, 'invoice_2026_001.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F4C696E656172697A65642031202F4C2031203E3E0A3E3E0A73747265616D0A0A42510A0A656E6473747265616D0A656E646F626A0A', 18432, 10, datetime('now','-38 days')), +(4, 'shipping_labels.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F54797065202F436174616C6F67202F50616765732031302020302020520A2F4F75746C696E65732032302020302020520A3E3E0A656E646F626A0A', 12288, 14, datetime('now','-30 days')), +(5, 'user_avatars.jpg', 'image/jpeg', X'FFD8FFE000104A46494600010100000100010000FFDB004300080606070605080707070909080A0C140D0C0B0B0C1912130F14311A1F1F1A1C232D', 8934, 1, datetime('now','-25 days')), +(6, 'q4_inventory.csv', 'text/csv', X'69642C6E616D652C73746F636B2C72657365727665640A312C47726E646C696E6520576972656C657373204D6F7573652C3134322C300A322C47726E646C696E65204D656368616E6963616C204B6579626F6172642C36382C350A', 5632, 14, datetime('now','-18 days')), +(7, 'backup.sql', 'application/sql', X'2D2D20477269646C696E652064656D6F206261636B75700A50524F4752416D757365725F76657273696F6E203D20333B0A435245415445205441424C45204946204E4F542045584953545320757365727320282E2E2E293B', 4096, 14, datetime('now','-7 days')), +(8, 'favicon.ico', 'image/x-icon', X'00000100010010100000000020006804000016000000280000001000000020000000010008', 4286, 1, datetime('now','-3 days')); + +-- ── App settings: TEXT primary key ────────────────────────────────────── +INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES +('site_name', 'Gridline Demo Store', datetime('now','-60 days')), +('maintenance_mode', 'false', datetime('now','-5 days')), +('max_cart_items', '50', datetime('now','-30 days')), +('currency', 'USD', datetime('now','-60 days')), +('default_shipping_country', 'USA', datetime('now','-20 days')), +('support_email', 'support@gridline.dev', datetime('now','-10 days')); + +-- ═══════════════════════════════════════════════════════════════════════ +-- Generated data: orders, line items, page views, audit log +-- ═══════════════════════════════════════════════════════════════════════ + +-- 50 realistic orders across the user base with varied statuses and dates. +WITH RECURSIVE order_seq(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM order_seq WHERE n < 50 +) +INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at) +SELECT + n, + -- Distribute orders across users 1-18; users 19 (inactive) and 20 (recent) get fewer. + CASE + WHEN n <= 35 THEN ((n - 1) % 18) + 1 + WHEN n <= 45 THEN ((n - 1) % 12) + 1 + ELSE ((n - 1) % 8) + 1 + END, + -- Pick a primary or secondary address for that user if one exists. + NULL, + 0, -- total recalculated from line items below + CASE n % 12 + WHEN 0 THEN 'pending' + WHEN 1 THEN 'processing' + WHEN 2 THEN 'shipped' + WHEN 3 THEN 'delivered' + WHEN 4 THEN 'completed' + WHEN 5 THEN 'cancelled' + WHEN 6 THEN 'refunded' + WHEN 7 THEN 'shipped' + WHEN 8 THEN 'processing' + WHEN 9 THEN 'completed' + WHEN 10 THEN 'pending' + ELSE 'delivered' + END, + CASE n % 8 + WHEN 0 THEN 'Please leave the package at the front desk.' + WHEN 1 THEN 'Gift wrap, please.' + WHEN 2 THEN 'Customer requested eco-friendly packaging.' + WHEN 3 THEN 'Ship after the 15th — office move in progress.' + WHEN 4 THEN NULL + WHEN 5 THEN 'Call before delivery.' + WHEN 6 THEN 'Authority to leave if not home.' + ELSE NULL + END, + datetime('now', printf('-%d days', 60 - (n % 58))), + datetime('now', printf('-%d days', 58 - (n % 56))), + CASE WHEN n % 12 IN (2,3,4,7,11) THEN datetime('now', printf('-%d days', 55 - (n % 53))) ELSE NULL END +FROM order_seq; + +-- Assign a realistic shipping address to each order from the user's address set. +UPDATE orders SET shipping_address_id = ( + SELECT a.id FROM addresses a + WHERE a.user_id = orders.user_id + ORDER BY a.is_primary DESC, a.id + LIMIT 1 +); + +-- 100+ order line items: 1-3 products per order. +WITH RECURSIVE line_seq(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM line_seq WHERE n < 105 +) +INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price) +SELECT + ((n - 1) % 50) + 1, + ((n - 1) % 24) + 1, + CASE n % 5 WHEN 0 THEN 3 WHEN 1 THEN 2 ELSE 1 END, + (SELECT price FROM products WHERE id = ((n - 1) % 24) + 1) +FROM line_seq; + +-- Recalculate order totals from line items. +UPDATE orders SET total = ( + SELECT ROUND(SUM(quantity * unit_price), 2) + FROM order_items + WHERE order_items.order_id = orders.id +); + +-- 100 page views with realistic user agents and URLs. +WITH RECURSIVE pv_seq(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM pv_seq WHERE n < 100 +) +INSERT INTO page_views (url, session_id, user_agent, viewed_at) +SELECT + CASE n % 10 + WHEN 0 THEN '/products' + WHEN 1 THEN '/products/' || ((n % 24) + 1) + WHEN 2 THEN '/cart' + WHEN 3 THEN '/checkout' + WHEN 4 THEN '/orders' + WHEN 5 THEN '/settings' + WHEN 6 THEN '/categories/electronics' + WHEN 7 THEN '/categories/office' + WHEN 8 THEN '/search?q=keyboard' + ELSE '/' + END, + 'sess-' || printf('%03d', (n % 30) + 1), + CASE n % 6 + WHEN 0 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' + WHEN 1 THEN 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' + WHEN 2 THEN 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' + WHEN 3 THEN 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36' + WHEN 4 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0' + ELSE 'Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' + END, + datetime('now', printf('-%d minutes', n * 13)) +FROM pv_seq +WHERE NOT EXISTS (SELECT 1 FROM page_views); + +-- 500 audit-log rows for pagination / virtualization / filtering demos. +WITH RECURSIVE audit_seq(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM audit_seq WHERE n < 500 +) +INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at) +SELECT + CASE WHEN n % 9 = 0 THEN NULL ELSE ((n - 1) % 18) + 1 END, + CASE n % 8 + WHEN 0 THEN 'login' + WHEN 1 THEN 'page_view' + WHEN 2 THEN 'update' + WHEN 3 THEN 'create' + WHEN 4 THEN 'delete' + WHEN 5 THEN 'export' + WHEN 6 THEN 'refund' + ELSE 'ship' + END, + CASE n % 5 + WHEN 0 THEN 'order' + WHEN 1 THEN 'product' + WHEN 2 THEN 'user' + WHEN 3 THEN 'address' + ELSE 'report' + END, + (n % 50) + 1, + CASE n % 6 + WHEN 0 THEN 'info' + WHEN 1 THEN 'info' + WHEN 2 THEN 'warning' + WHEN 3 THEN 'error' + WHEN 4 THEN 'critical' + ELSE 'warning' + END, + CASE WHEN n % 7 = 0 THEN NULL + ELSE '{"page":"' || + CASE n % 4 WHEN 0 THEN '/' WHEN 1 THEN '/products' WHEN 2 THEN '/checkout' ELSE '/orders' END || + '","retries":' || (n % 3) || ',"row":' || n || ',"region":"' || + CASE n % 5 WHEN 0 THEN 'us-east' WHEN 1 THEN 'us-west' WHEN 2 THEN 'eu-central' WHEN 3 THEN 'ap-south' ELSE 'sa-east' END || + '"}' + END, + (n * 37) % 2500, + datetime('now', printf('-%d minutes', n * 7)) +FROM audit_seq +WHERE NOT EXISTS (SELECT 1 FROM audit_log); \ No newline at end of file diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 53bb934..a1302ba 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -14,10 +14,7 @@ pub fn get_folders_inner(state: &Mutex) -> Result, String> { store.get_folders() } -pub fn create_folder_inner( - state: &Mutex, - input: FolderInput, -) -> Result { +pub fn create_folder_inner(state: &Mutex, input: FolderInput) -> Result { validate(&input)?; let store = state.lock().map_err(|e| e.to_string())?; store.create_folder(input) @@ -98,12 +95,15 @@ mod tests { #[test] fn create_folder_command_works() { let st = state(); - let folder = - create_folder_inner(&st, FolderInput { tag_ids: None, + let folder = create_folder_inner( + &st, + FolderInput { + tag_ids: None, name: "Work".into(), parent_id: None, - }) - .unwrap(); + }, + ) + .unwrap(); assert_eq!(get_folders_inner(&st).unwrap().len(), 1); assert_eq!(folder.name, "Work"); } @@ -113,7 +113,8 @@ mod tests { let st = state(); let result = create_folder_inner( &st, - FolderInput { tag_ids: None, + FolderInput { + tag_ids: None, name: "".into(), parent_id: None, }, @@ -124,13 +125,16 @@ mod tests { #[test] fn delete_folder_command_works() { let st = state(); - let folder = - create_folder_inner(&st, FolderInput { tag_ids: None, + let folder = create_folder_inner( + &st, + FolderInput { + tag_ids: None, name: "Work".into(), parent_id: None, - }) - .unwrap(); + }, + ) + .unwrap(); delete_folder_inner(&st, &folder.id).unwrap(); assert_eq!(get_folders_inner(&st).unwrap().len(), 0); } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 5a241a5..30a9121 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -33,7 +33,8 @@ pub struct ImportResult { #[allow(dead_code)] pub fn parse_import(json: &str) -> Result, String> { - let records: Vec = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {}", e))?; + let records: Vec = + serde_json::from_str(json).map_err(|e| format!("invalid JSON: {}", e))?; for (i, rec) in records.iter().enumerate() { if rec.name.as_deref().unwrap_or("").is_empty() { return Err(format!("record {}: name is required", i)); @@ -45,8 +46,12 @@ pub fn parse_import(json: &str) -> Result, String> { Ok(records) } -pub fn import_connections_inner(state: &Mutex, json: String) -> Result { - let records: Vec = serde_json::from_str(&json).map_err(|e| format!("invalid JSON: {}", e))?; +pub fn import_connections_inner( + state: &Mutex, + json: String, +) -> Result { + let records: Vec = + serde_json::from_str(&json).map_err(|e| format!("invalid JSON: {}", e))?; let store = state.lock().map_err(|e| e.to_string())?; let mut imported = 0usize; let mut skipped_records = Vec::new(); @@ -54,16 +59,25 @@ pub fn import_connections_inner(state: &Mutex, json: String) -> Result n.clone(), _ => { - skipped_records.push(SkippedRecord { index: i, reason: "missing or empty name".into() }); + skipped_records.push(SkippedRecord { + index: i, + reason: "missing or empty name".into(), + }); continue; } }; if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) { - skipped_records.push(SkippedRecord { index: i, reason: format!("invalid db_type: {}", rec.db_type) }); + skipped_records.push(SkippedRecord { + index: i, + reason: format!("invalid db_type: {}", rec.db_type), + }); continue; } if rec.host.is_empty() { - skipped_records.push(SkippedRecord { index: i, reason: "missing or empty host".into() }); + skipped_records.push(SkippedRecord { + index: i, + reason: "missing or empty host".into(), + }); continue; } let input = ConnectionInput { @@ -91,10 +105,17 @@ pub fn import_connections_inner(state: &Mutex, json: String) -> Result imported += 1, - Err(e) => skipped_records.push(SkippedRecord { index: i, reason: e }), + Err(e) => skipped_records.push(SkippedRecord { + index: i, + reason: e, + }), } } - Ok(ImportResult { imported, skipped: skipped_records.len(), skipped_records }) + Ok(ImportResult { + imported, + skipped: skipped_records.len(), + skipped_records, + }) } pub fn export_connections_inner(state: &Mutex) -> Result { @@ -105,7 +126,10 @@ pub fn export_connections_inner(state: &Mutex) -> Result } #[tauri::command] -pub fn import_connections(state: tauri::State, json: String) -> Result { +pub fn import_connections( + state: tauri::State, + json: String, +) -> Result { import_connections_inner(&state.db_store, json) } @@ -117,8 +141,8 @@ pub fn export_connections(state: tauri::State) -> Result std::sync::Mutex { let conn = rusqlite::Connection::open_in_memory().unwrap(); @@ -169,12 +193,25 @@ mod tests { fn export_connections_returns_json() { let st = state(); let _ = st.lock().unwrap().create_connection(ConnectionInput { - name: "A".into(), db_type: "postgresql".into(), host: "h".into(), - port: Some(5432), username: None, folder_id: None, - password: None, database: None, - ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None, - ssh_private_key_path: None, ssh_password: None, ssh_passphrase: None, - ssl_mode: None, ssl_ca_path: None, ssl_cert_path: None, ssl_key_path: None, + name: "A".into(), + db_type: "postgresql".into(), + host: "h".into(), + port: Some(5432), + username: None, + folder_id: None, + password: None, + database: None, + ssh_host: None, + ssh_port: None, + ssh_user: None, + ssh_auth_method: None, + ssh_private_key_path: None, + ssh_password: None, + ssh_passphrase: None, + ssl_mode: None, + ssl_ca_path: None, + ssl_cert_path: None, + ssl_key_path: None, environment: None, tag_ids: vec![], }); @@ -182,4 +219,4 @@ mod tests { assert!(json.contains("\"name\"")); assert!(json.contains("\"version\"")); } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/keychain.rs b/src-tauri/src/commands/keychain.rs index 508c51a..1467ef8 100644 --- a/src-tauri/src/commands/keychain.rs +++ b/src-tauri/src/commands/keychain.rs @@ -190,4 +190,4 @@ mod tests { assert_eq!(ssh_account("password", "c1"), "ssh_password:c1"); assert_eq!(ssh_account("passphrase", "c1"), "ssh_passphrase:c1"); } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d5a1c18..998f3f0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,13 +1,13 @@ +pub mod backup; pub mod connections; pub mod db_viewer; -pub mod folders; -pub mod tags; -pub mod settings; -pub mod import_export; -pub mod test_connection; -pub mod ssh; -pub mod keychain; pub mod demo; -pub mod backup; +pub mod folders; +pub mod import_export; +pub mod keychain; +pub mod query; pub mod schema_graph; -pub mod query; \ No newline at end of file +pub mod settings; +pub mod ssh; +pub mod tags; +pub mod test_connection; diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 64f95f5..1695f59 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -99,9 +99,7 @@ pub(crate) async fn execute_query_inner( 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) - } + 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(); @@ -205,16 +203,10 @@ async fn execute_pg_query( "SELECT * FROM ({}) AS _gridline_data LIMIT $1 OFFSET $2", trimmed ); - let wrapped_count = format!( - "SELECT COUNT(*) FROM ({}) AS _gridline_cnt", - 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 - { + 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. @@ -343,11 +335,7 @@ async fn execute_pg_raw( let ulimit = page_size as usize; let rows: Vec> = if uoffset < all_rows.len() { - all_rows - .into_iter() - .skip(uoffset) - .take(ulimit) - .collect() + all_rows.into_iter().skip(uoffset).take(ulimit).collect() } else { Vec::new() }; @@ -386,10 +374,7 @@ fn execute_sqlite_query( "SELECT * FROM ({}) AS _gridline_data LIMIT {} OFFSET {}", trimmed, page_size, off ); - let wrapped_count = format!( - "SELECT COUNT(*) FROM ({}) AS _gridline_cnt", - trimmed - ); + 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)) { @@ -478,11 +463,7 @@ fn execute_sqlite_raw( let ulimit = page_size as usize; let rows: Vec> = if uoffset < all_rows.len() { - all_rows - .into_iter() - .skip(uoffset) - .take(ulimit) - .collect() + all_rows.into_iter().skip(uoffset).take(ulimit).collect() } else { Vec::new() }; @@ -508,12 +489,8 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value { 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())) - } + 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, } } @@ -580,7 +557,12 @@ pub(crate) fn update_saved_query_inner( folder: Option, ) -> Result<(), String> { let store = db_store.lock().map_err(|e| e.to_string())?; - store.update_saved_query(&id, name.as_deref(), query_text.as_deref(), folder.as_deref()) + store.update_saved_query( + &id, + name.as_deref(), + query_text.as_deref(), + folder.as_deref(), + ) } pub(crate) fn delete_saved_query_inner( @@ -671,7 +653,13 @@ pub async fn update_saved_query( patch: UpdateSavedQueryPatch, state: State<'_, crate::AppState>, ) -> Result<(), String> { - update_saved_query_inner(&state.db_store, id, patch.name, patch.query_text, patch.folder) + update_saved_query_inner( + &state.db_store, + id, + patch.name, + patch.query_text, + patch.folder, + ) } #[tauri::command] @@ -768,9 +756,13 @@ mod tests { 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(); + 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); @@ -780,9 +772,13 @@ mod tests { 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(); + 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); @@ -963,4 +959,4 @@ mod tests { _ => panic!("Expected Sqlite handle"), } } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/schema_graph.rs b/src-tauri/src/commands/schema_graph.rs index 13724b0..b1386e5 100644 --- a/src-tauri/src/commands/schema_graph.rs +++ b/src-tauri/src/commands/schema_graph.rs @@ -1,6 +1,6 @@ #[allow(unused_imports)] use crate::db::pool::DbHandle; -use crate::models::db_viewer::{SchemaGraph, TableNode, GraphColumn, Relationship}; +use crate::models::db_viewer::{GraphColumn, Relationship, SchemaGraph, TableNode}; use std::collections::HashMap; use tauri::State; @@ -76,7 +76,8 @@ WHERE n.nspname = $1 AND c.relkind IN ('r', 'v', 'p') AND a.attnum > 0 AND NOT a.attisdropped -ORDER BY c.relname, a.attnum"#.to_string() +ORDER BY c.relname, a.attnum"# + .to_string() } /// Infer relationship cardinality from constraint metadata. @@ -203,14 +204,19 @@ fn build_sqlite_schema_graph( schema: &str, ) -> Result { if schema != "main" { - return Err(format!("SQLite only supports schema 'main', got: {}", schema)); + return Err(format!( + "SQLite only supports schema 'main', got: {}", + schema + )); } let mut stmt = conn .prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name") .map_err(|e| e.to_string())?; let table_rows: Vec<(String, String)> = stmt - .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); @@ -222,19 +228,27 @@ fn build_sqlite_schema_graph( let pragma_sql = format!("PRAGMA table_info('{}')", table_name); let mut ps = conn.prepare(&pragma_sql).map_err(|e| e.to_string())?; let col_meta: Vec<(String, String, bool, bool)> = ps - .query_map([], |row| Ok(( - row.get::<_, String>(1)?, row.get::<_, String>(2)?, - row.get::<_, bool>(3)?, row.get::<_, bool>(5)?, - ))) + .query_map([], |row| { + Ok(( + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, bool>(3)?, + row.get::<_, bool>(5)?, + )) + }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); let fk_sql = format!("PRAGMA foreign_key_list('{}')", table_name); let fk_cols: HashMap = if let Ok(mut fs) = conn.prepare(&fk_sql) { - fs.query_map([], |row| Ok(( - row.get::<_, String>(3)?, row.get::<_, String>(2)?, row.get::<_, String>(4)?, - ))) + fs.query_map([], |row| { + Ok(( + row.get::<_, String>(3)?, + row.get::<_, String>(2)?, + row.get::<_, String>(4)?, + )) + }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .map(|(col, ref_t, ref_c)| (col, (ref_t, ref_c))) @@ -243,35 +257,51 @@ fn build_sqlite_schema_graph( HashMap::new() }; - let columns: Vec = col_meta.iter().map(|(name, dtype, _nn, is_pk)| { - let fk = fk_cols.get(name); - let is_fk = fk.is_some(); - let fk_ref = fk.map(|(t, c)| ("main".into(), t.clone(), c.clone())); - if let Some((ref_t, ref_c)) = fk { - relationships.push(Relationship { - source_schema: "main".into(), source_table: table_name.clone(), - source_column: name.clone(), - target_schema: "main".into(), target_table: ref_t.clone(), - target_column: ref_c.clone(), - cardinality: infer_cardinality(*is_pk, false, !_nn, false), - }); - } - GraphColumn { - name: name.clone(), - data_type: if dtype.is_empty() { "TEXT".into() } else { dtype.clone() }, - is_pk: *is_pk, is_fk, is_unique: *is_pk, - is_nullable: !_nn, - fk_ref: fk_ref.map(|(s, t, c)| (s, t, c)), - } - }).collect(); + let columns: Vec = col_meta + .iter() + .map(|(name, dtype, _nn, is_pk)| { + let fk = fk_cols.get(name); + let is_fk = fk.is_some(); + let fk_ref = fk.map(|(t, c)| ("main".into(), t.clone(), c.clone())); + if let Some((ref_t, ref_c)) = fk { + relationships.push(Relationship { + source_schema: "main".into(), + source_table: table_name.clone(), + source_column: name.clone(), + target_schema: "main".into(), + target_table: ref_t.clone(), + target_column: ref_c.clone(), + cardinality: infer_cardinality(*is_pk, false, !_nn, false), + }); + } + GraphColumn { + name: name.clone(), + data_type: if dtype.is_empty() { + "TEXT".into() + } else { + dtype.clone() + }, + is_pk: *is_pk, + is_fk, + is_unique: *is_pk, + is_nullable: !_nn, + fk_ref: fk_ref.map(|(s, t, c)| (s, t, c)), + } + }) + .collect(); tables.push(TableNode { - name: table_name.clone(), schema: "main".into(), - table_type: table_type.to_uppercase(), columns, + name: table_name.clone(), + schema: "main".into(), + table_type: table_type.to_uppercase(), + columns, }); } - Ok(SchemaGraph { tables, relationships }) + Ok(SchemaGraph { + tables, + relationships, + }) } #[tauri::command] @@ -302,7 +332,10 @@ pub async fn get_schema_graph( .collect(); let (tables, relationships) = parse_pg_schema_rows(&json_rows); - Ok(SchemaGraph { tables, relationships }) + Ok(SchemaGraph { + tables, + relationships, + }) } Some(DbHandle::Sqlite(conn)) => build_sqlite_schema_graph(conn, &schema), None => Err("Connection not found".into()), @@ -352,17 +385,30 @@ mod tests { fn build_pg_schema_graph_query_is_parameterized() { let sql = build_pg_schema_graph_query("public"); // Must use $1 for schema parameter (parameterized) - assert!(sql.contains("$1"), "query should use $1 placeholder; got: {}", sql); + assert!( + sql.contains("$1"), + "query should use $1 placeholder; got: {}", + sql + ); // Must not interpolate schema name directly in a potentially unsafe way - assert!(!sql.contains("'public'"), "query should not use literal 'public'"); + assert!( + !sql.contains("'public'"), + "query should not use literal 'public'" + ); } #[test] fn build_pg_schema_graph_query_queries_columns() { let sql = build_pg_schema_graph_query("myschema"); assert!(sql.contains("pg_catalog.pg_class"), "should query pg_class"); - assert!(sql.contains("pg_catalog.pg_attribute"), "should query pg_attribute"); - assert!(sql.contains("pg_catalog.pg_constraint"), "should include constraint info"); + assert!( + sql.contains("pg_catalog.pg_attribute"), + "should query pg_attribute" + ); + assert!( + sql.contains("pg_catalog.pg_constraint"), + "should include constraint info" + ); } #[test] @@ -397,34 +443,66 @@ mod tests { let rows: Vec> = vec![ // users.id (PK) vec![ - serde_json::json!("users"), serde_json::json!("public"), serde_json::json!("BASE TABLE"), - serde_json::json!("id"), serde_json::json!("integer"), serde_json::json!("NO"), - serde_json::json!(1), serde_json::json!(true), serde_json::json!(false), - serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null, + serde_json::json!("users"), + serde_json::json!("public"), + serde_json::json!("BASE TABLE"), + serde_json::json!("id"), + serde_json::json!("integer"), + serde_json::json!("NO"), + serde_json::json!(1), + serde_json::json!(true), + serde_json::json!(false), + serde_json::Value::Null, + serde_json::Value::Null, + serde_json::Value::Null, serde_json::json!(true), ], // users.email (non-key) vec![ - serde_json::json!("users"), serde_json::json!("public"), serde_json::json!("BASE TABLE"), - serde_json::json!("email"), serde_json::json!("text"), serde_json::json!("NO"), - serde_json::json!(2), serde_json::json!(false), serde_json::json!(false), - serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null, + serde_json::json!("users"), + serde_json::json!("public"), + serde_json::json!("BASE TABLE"), + serde_json::json!("email"), + serde_json::json!("text"), + serde_json::json!("NO"), + serde_json::json!(2), + serde_json::json!(false), + serde_json::json!(false), + serde_json::Value::Null, + serde_json::Value::Null, + serde_json::Value::Null, serde_json::json!(true), ], // orders.id (PK) vec![ - serde_json::json!("orders"), serde_json::json!("public"), serde_json::json!("BASE TABLE"), - serde_json::json!("id"), serde_json::json!("integer"), serde_json::json!("NO"), - serde_json::json!(1), serde_json::json!(true), serde_json::json!(false), - serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null, + serde_json::json!("orders"), + serde_json::json!("public"), + serde_json::json!("BASE TABLE"), + serde_json::json!("id"), + serde_json::json!("integer"), + serde_json::json!("NO"), + serde_json::json!(1), + serde_json::json!(true), + serde_json::json!(false), + serde_json::Value::Null, + serde_json::Value::Null, + serde_json::Value::Null, serde_json::json!(true), ], // orders.user_id (FK → users.id) vec![ - serde_json::json!("orders"), serde_json::json!("public"), serde_json::json!("BASE TABLE"), - serde_json::json!("user_id"), serde_json::json!("integer"), serde_json::json!("NO"), - serde_json::json!(2), serde_json::json!(false), serde_json::json!(true), - serde_json::json!("public"), serde_json::json!("users"), serde_json::json!("id"), + serde_json::json!("orders"), + serde_json::json!("public"), + serde_json::json!("BASE TABLE"), + serde_json::json!("user_id"), + serde_json::json!("integer"), + serde_json::json!("NO"), + serde_json::json!(2), + serde_json::json!(false), + serde_json::json!(true), + serde_json::json!("public"), + serde_json::json!("users"), + serde_json::json!("id"), serde_json::json!(false), ], ]; @@ -456,4 +534,4 @@ mod tests { assert!(tables.is_empty()); assert!(relationships.is_empty()); } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 280c271..522d1c8 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -18,7 +18,11 @@ pub fn get_settings(state: tauri::State) -> Result, key: String, value: String) -> Result<(), String> { +pub fn update_setting( + state: tauri::State, + key: String, + value: String, +) -> Result<(), String> { update_setting_inner(&state.db_store, &key, &value) } @@ -55,4 +59,4 @@ mod tests { update_setting_inner(&st, "accent_color", "#EF4444").unwrap(); assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444"); } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs index ef5ced8..590d3ff 100644 --- a/src-tauri/src/commands/ssh.rs +++ b/src-tauri/src/commands/ssh.rs @@ -165,13 +165,16 @@ impl TunnelBackend for Ssh2Backend { .map_err(|e| format!("connect ssh host: {e}"))?; let mut session = Session::new().map_err(|e| format!("ssh session: {e}"))?; session.set_tcp_stream(tcp); - session.handshake().map_err(|e| format!("ssh handshake: {e}"))?; + session + .handshake() + .map_err(|e| format!("ssh handshake: {e}"))?; match cfg.auth_method.as_str() { "key" => { - let path = cfg.private_key_path.as_deref().ok_or_else(|| { - "private_key_path required for key auth".to_string() - })?; + let path = cfg + .private_key_path + .as_deref() + .ok_or_else(|| "private_key_path required for key auth".to_string())?; session .userauth_pubkey_file(&cfg.user, None, std::path::Path::new(path), passphrase) .map_err(|e| format!("ssh key auth: {e}"))?; @@ -420,4 +423,4 @@ mod tests { mgr.close_all(); assert_eq!(mgr.active_count(), 0); } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/tags.rs b/src-tauri/src/commands/tags.rs index 18bae4b..71b79f2 100644 --- a/src-tauri/src/commands/tags.rs +++ b/src-tauri/src/commands/tags.rs @@ -25,11 +25,7 @@ pub fn delete_tag_inner(state: &Mutex, id: &str) -> Result<(), String> { store.delete_tag(id) } -pub fn update_tag_inner( - state: &Mutex, - id: String, - input: TagInput, -) -> Result { +pub fn update_tag_inner(state: &Mutex, id: String, input: TagInput) -> Result { validate(&input)?; let store = state.lock().map_err(|e| e.to_string())?; store.update_tag(&id, input) @@ -62,8 +58,8 @@ pub fn update_tag( #[cfg(test)] mod tests { use super::*; - use crate::store::Store; use crate::models::TagInput; + use crate::store::Store; fn state() -> std::sync::Mutex { let conn = rusqlite::Connection::open_in_memory().unwrap(); @@ -74,7 +70,14 @@ mod tests { #[test] fn create_tag_command_works() { let st = state(); - let tag = create_tag_inner(&st, TagInput { name: "prod".into(), color: "#ef4444".into() }).unwrap(); + let tag = create_tag_inner( + &st, + TagInput { + name: "prod".into(), + color: "#ef4444".into(), + }, + ) + .unwrap(); assert_eq!(get_tags_inner(&st).unwrap().len(), 1); assert_eq!(tag.name, "prod"); } @@ -82,7 +85,13 @@ mod tests { #[test] fn create_tag_rejects_long_name() { let st = state(); - let result = create_tag_inner(&st, TagInput { name: "x".repeat(51), color: "#fff".into() }); + let result = create_tag_inner( + &st, + TagInput { + name: "x".repeat(51), + color: "#fff".into(), + }, + ); assert!(result.is_err()); } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/test_connection.rs b/src-tauri/src/commands/test_connection.rs index 4565977..7df38f2 100644 --- a/src-tauri/src/commands/test_connection.rs +++ b/src-tauri/src/commands/test_connection.rs @@ -126,8 +126,7 @@ pub fn validate_test_input(config: &DbConfig) -> Option { Some(p) if (1..=65535).contains(&p) => {} _ => { return Some( - "port must be an integer between 1 and 65535 for this db_type" - .to_string(), + "port must be an integer between 1 and 65535 for this db_type".to_string(), ); } } @@ -207,10 +206,7 @@ fn close_probe_tunnel(ssh: &SshManager, key: Option<&str>) { /// Dispatches to the appropriate type-specific connection test based on /// `config.db_type`. Returns a `TestConnectionResult` indicating success /// or failure with a sanitized error message. -pub async fn test_database_connection( - config: &DbConfig, - ssh: &SshManager, -) -> TestConnectionResult { +pub async fn test_database_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult { // Validate input first if let Some(err) = validate_test_input(config) { return TestConnectionResult { @@ -304,8 +300,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti let result = match tls { None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await, Some(cc) => { - let connector = - tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone()); + let connector = tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone()); crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await } }; @@ -405,11 +400,10 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne Ok(pool) => { close_probe_tunnel(ssh, target.tunnel_key.as_deref()); // Best-effort server version; None if the query fails. - let server_version = - sqlx::query_scalar::<_, String>("SELECT VERSION()") - .fetch_one(&pool) - .await - .ok(); + let server_version = sqlx::query_scalar::<_, String>("SELECT VERSION()") + .fetch_one(&pool) + .await + .ok(); let latency_ms = Some(start.elapsed().as_millis() as u64); pool.close().await; TestConnectionResult { @@ -441,9 +435,7 @@ fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult { Ok(conn) => { // Best-effort server version; None if the query fails. let server_version = conn - .query_row("SELECT sqlite_version()", [], |r| { - r.get::<_, String>(0) - }) + .query_row("SELECT sqlite_version()", [], |r| r.get::<_, String>(0)) .ok(); TestConnectionResult { ok: true, @@ -611,9 +603,18 @@ mod tests { fn test_connection_sanitizes_error() { let msg = "connection failed: password=secret123 user=admin"; let sanitized = sanitize_error(msg); - assert!(!sanitized.contains("secret123"), "should not leak password value"); - assert!(!sanitized.contains("admin"), "should not leak username value"); - assert!(!sanitized.contains("password="), "should remove password= pattern"); + assert!( + !sanitized.contains("secret123"), + "should not leak password value" + ); + assert!( + !sanitized.contains("admin"), + "should not leak username value" + ); + assert!( + !sanitized.contains("password="), + "should remove password= pattern" + ); assert!(!sanitized.contains("user="), "should remove user= pattern"); } @@ -722,4 +723,4 @@ mod tests { "sqlite with any port should be accepted" ); } -} \ No newline at end of file +} diff --git a/src-tauri/src/db/introspection.rs b/src-tauri/src/db/introspection.rs index 6aeabde..fd2df69 100644 --- a/src-tauri/src/db/introspection.rs +++ b/src-tauri/src/db/introspection.rs @@ -430,20 +430,31 @@ mod tests { #[test] fn pg_indexes_query_is_parameterized_and_joins() { let sql = pg_indexes_query("public"); - assert!(sql.contains("$1"), "schema must be parameterized; got: {}", sql); + assert!( + sql.contains("$1"), + "schema must be parameterized; got: {}", + sql + ); assert!( sql.contains("pg_indexes") || sql.contains("pg_index"), "should query pg_index; got: {}", sql ); - assert!(sql.contains("pg_get_indexdef"), "should include index definition"); + assert!( + sql.contains("pg_get_indexdef"), + "should include index definition" + ); assert!(sql.contains("indisunique"), "should include uniqueness"); } #[test] fn pg_constraints_query_filters_check_unique_exclusion() { let sql = pg_constraints_query("public"); - assert!(sql.contains("$1"), "schema must be parameterized; got: {}", sql); + assert!( + sql.contains("$1"), + "schema must be parameterized; got: {}", + sql + ); assert!( sql.contains("pg_constraint"), "should query pg_constraint; got: {}", @@ -453,7 +464,10 @@ mod tests { assert!(sql.contains("'c'"), "should filter CHECK ('c')"); assert!(sql.contains("'u'"), "should filter UNIQUE ('u')"); assert!(sql.contains("'x'"), "should filter EXCLUSION ('x')"); - assert!(sql.contains("pg_get_constraintdef"), "should include definition"); + assert!( + sql.contains("pg_get_constraintdef"), + "should include definition" + ); } #[test] @@ -610,4 +624,4 @@ mod tests { let sql = pg_extensions_query(); assert!(sql.contains("pg_extension")); } -} \ No newline at end of file +} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index dc42bdd..3b72578 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1,6 +1,6 @@ -pub mod pool; pub mod introspection; +pub mod pool; pub mod tls; #[allow(unused_imports)] -pub use pool::{ConnectionPoolManager, DbConfig, DbHandle}; \ No newline at end of file +pub use pool::{ConnectionPoolManager, DbConfig, DbHandle}; diff --git a/src-tauri/src/db/pool.rs b/src-tauri/src/db/pool.rs index c23bc87..bec30c9 100644 --- a/src-tauri/src/db/pool.rs +++ b/src-tauri/src/db/pool.rs @@ -250,22 +250,48 @@ mod tests { #[test] fn db_config_ssh_config_is_none_when_no_host() { - let cfg = DbConfig { db_type: "PostgreSQL".into(), host: "h".into(), port: Some(5432), - username: None, password: None, database: None, ssl_mode: None, ssl_ca_path: None, - ssl_cert_path: None, ssl_key_path: None, ssh_host: None, ssh_port: None, ssh_user: None, - ssh_auth_method: None, ssh_password: None, ssh_private_key_path: None, ssh_passphrase: None, + let cfg = DbConfig { + db_type: "PostgreSQL".into(), + host: "h".into(), + port: Some(5432), + username: None, + password: None, + database: None, + ssl_mode: None, + ssl_ca_path: None, + ssl_cert_path: None, + ssl_key_path: None, + ssh_host: None, + ssh_port: None, + ssh_user: None, + ssh_auth_method: None, + ssh_password: None, + ssh_private_key_path: None, + ssh_passphrase: None, }; assert!(cfg.ssh_config().is_none()); } #[test] fn db_config_ssh_config_builds_from_flat_fields() { - let cfg = DbConfig { db_type: "PostgreSQL".into(), host: "db".into(), port: Some(5432), - username: None, password: None, database: None, ssl_mode: None, ssl_ca_path: None, - ssl_cert_path: None, ssl_key_path: None, - ssh_host: Some("jump".into()), ssh_port: Some(2222), ssh_user: Some("u".into()), - ssh_auth_method: Some("password".into()), ssh_password: Some("pw".into()), - ssh_private_key_path: None, ssh_passphrase: None, + let cfg = DbConfig { + db_type: "PostgreSQL".into(), + host: "db".into(), + port: Some(5432), + username: None, + password: None, + database: None, + ssl_mode: None, + ssl_ca_path: None, + ssl_cert_path: None, + ssl_key_path: None, + ssh_host: Some("jump".into()), + ssh_port: Some(2222), + ssh_user: Some("u".into()), + ssh_auth_method: Some("password".into()), + ssh_password: Some("pw".into()), + ssh_private_key_path: None, + ssh_passphrase: None, }; let s = cfg.ssh_config().expect("ssh config present"); assert_eq!(s.host, "jump"); @@ -339,7 +365,10 @@ mod tests { manager.register("d", DbHandle::Sqlite(conn_d)); assert_eq!(manager.pools().len(), 3); - assert!(manager.contains("a"), "'a' was recently accessed, should survive"); + assert!( + manager.contains("a"), + "'a' was recently accessed, should survive" + ); assert!(!manager.contains("b"), "'b' is LRU and should be evicted"); assert!(manager.contains("c")); assert!(manager.contains("d")); @@ -385,4 +414,4 @@ mod tests { manager.set_max_pools(1); assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]); } -} \ No newline at end of file +} diff --git a/src-tauri/src/db/tls.rs b/src-tauri/src/db/tls.rs index 807c242..cd34fe4 100644 --- a/src-tauri/src/db/tls.rs +++ b/src-tauri/src/db/tls.rs @@ -113,14 +113,13 @@ fn load_client_identity( ); } let cb = std::fs::read(cert_path).map_err(|e| format!("read cert: {e}"))?; - let certs: Vec> = rustls_pemfile::certs(&mut std::io::BufReader::new( - cb.as_slice(), - )) - .collect::, _>>() - .map_err(|e| format!("parse cert: {e}"))? - .into_iter() - .map(|c| c.into_owned()) - .collect(); + let certs: Vec> = + rustls_pemfile::certs(&mut std::io::BufReader::new(cb.as_slice())) + .collect::, _>>() + .map_err(|e| format!("parse cert: {e}"))? + .into_iter() + .map(|c| c.into_owned()) + .collect(); if certs.is_empty() { return Err("no client certificates parsed".into()); } @@ -179,29 +178,37 @@ mod tests { #[test] fn tls_decision_maps_modes() { assert!(matches!(tls_decision(None), TlsDecision::Disable)); - assert!(matches!(tls_decision(Some("disable")), TlsDecision::Disable)); - assert!(matches!(tls_decision(Some("require")), TlsDecision::Require)); - assert!(matches!(tls_decision(Some("verify-ca")), TlsDecision::Verify)); - assert!(matches!(tls_decision(Some("verify-full")), TlsDecision::Verify)); + assert!(matches!( + tls_decision(Some("disable")), + TlsDecision::Disable + )); + assert!(matches!( + tls_decision(Some("require")), + TlsDecision::Require + )); + assert!(matches!( + tls_decision(Some("verify-ca")), + TlsDecision::Verify + )); + assert!(matches!( + tls_decision(Some("verify-full")), + TlsDecision::Verify + )); assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable)); } #[test] fn build_tls_disable_returns_none() { - assert!( - build_tls_config(TlsDecision::Disable, None, None, None) - .unwrap() - .is_none() - ); + assert!(build_tls_config(TlsDecision::Disable, None, None, None) + .unwrap() + .is_none()); } #[test] fn build_tls_require_returns_some_without_files() { - assert!( - build_tls_config(TlsDecision::Require, None, None, None) - .unwrap() - .is_some() - ); + assert!(build_tls_config(TlsDecision::Require, None, None, None) + .unwrap() + .is_some()); } #[test] @@ -214,8 +221,13 @@ mod tests { #[test] fn build_tls_client_cert_missing_key_errors() { // cert set without key - let err = build_tls_config(TlsDecision::Require, None, Some("/nonexistent/cert.pem"), None) - .unwrap_err(); + let err = build_tls_config( + TlsDecision::Require, + None, + Some("/nonexistent/cert.pem"), + None, + ) + .unwrap_err(); assert!(err.to_lowercase().contains("cert") || err.to_lowercase().contains("key")); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e1c1c38..dd93baf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,16 +2,16 @@ // of runtime usage, producing expected dead_code/unused warnings during development. #![allow(dead_code)] +mod commands; mod db; mod models; mod store; -mod commands; -use std::sync::{Arc, Mutex as StdMutex}; -use tauri::Manager; -use store::Store; use commands::ssh::{Ssh2Backend, SshTunnelManager}; use db::pool::ConnectionPoolManager; +use std::sync::{Arc, Mutex as StdMutex}; +use store::Store; +use tauri::Manager; pub struct AppState { pub db_store: StdMutex, @@ -19,7 +19,10 @@ pub struct AppState { pub ssh_manager: StdMutex, } -use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup, schema_graph, query}; +use commands::{ + backup, connections, db_viewer, demo, folders, import_export, keychain, query, schema_graph, + settings, tags, +}; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] diff --git a/src-tauri/src/models/backup.rs b/src-tauri/src/models/backup.rs index 468100c..b2616d7 100644 --- a/src-tauri/src/models/backup.rs +++ b/src-tauri/src/models/backup.rs @@ -97,4 +97,4 @@ mod tests { assert!(json.contains("dump")); assert!(json.contains("completed")); } -} \ No newline at end of file +} diff --git a/src-tauri/src/models/connection.rs b/src-tauri/src/models/connection.rs index 2c1d10c..5b96899 100644 --- a/src-tauri/src/models/connection.rs +++ b/src-tauri/src/models/connection.rs @@ -91,19 +91,31 @@ mod tests { assert_eq!(deserialized.port, Some(5432)); assert_eq!(deserialized.username, Some("admin".to_string())); assert_eq!(deserialized.folder_id, Some("folder1".to_string())); - assert_eq!(deserialized.tag_ids, vec!["tag1".to_string(), "tag2".to_string()]); + assert_eq!( + deserialized.tag_ids, + vec!["tag1".to_string(), "tag2".to_string()] + ); assert_eq!(deserialized.password, Some("secret123".to_string())); assert_eq!(deserialized.database, Some("mydb".to_string())); - assert_eq!(deserialized.ssh_host, Some("jumphost.example.com".to_string())); + assert_eq!( + deserialized.ssh_host, + Some("jumphost.example.com".to_string()) + ); assert_eq!(deserialized.ssh_port, Some(2222)); assert_eq!(deserialized.ssh_user, Some("tunnel".to_string())); assert_eq!(deserialized.ssh_auth_method, Some("Key".to_string())); - assert_eq!(deserialized.ssh_private_key_path, Some("/path/to/key".to_string())); + assert_eq!( + deserialized.ssh_private_key_path, + Some("/path/to/key".to_string()) + ); assert_eq!(deserialized.ssh_password, Some("ssh-pw".to_string())); assert_eq!(deserialized.ssh_passphrase, Some("passphrase".to_string())); assert_eq!(deserialized.ssl_mode, Some("require".to_string())); assert_eq!(deserialized.ssl_ca_path, Some("/path/to/ca".to_string())); - assert_eq!(deserialized.ssl_cert_path, Some("/path/to/cert".to_string())); + assert_eq!( + deserialized.ssl_cert_path, + Some("/path/to/cert".to_string()) + ); assert_eq!(deserialized.ssl_key_path, Some("/path/to/key".to_string())); } @@ -136,22 +148,40 @@ mod tests { }; let json = serde_json::to_string(&conn).unwrap(); - assert!(!json.contains("password"), "Connection JSON should not contain password field"); + assert!( + !json.contains("password"), + "Connection JSON should not contain password field" + ); } #[test] fn connection_serializes_favorite_field() { let conn = Connection { - id: "x".into(), name: "n".into(), db_type: "postgresql".into(), - host: "h".into(), port: Some(5432), username: None, database: None, - folder_id: None, keychain_ref: None, environment: None, - ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None, - ssh_private_key_path: None, ssl_mode: None, ssl_ca_path: None, - ssl_cert_path: None, ssl_key_path: None, tag_ids: vec![], + id: "x".into(), + name: "n".into(), + db_type: "postgresql".into(), + host: "h".into(), + port: Some(5432), + username: None, + database: None, + folder_id: None, + keychain_ref: None, + environment: None, + ssh_host: None, + ssh_port: None, + ssh_user: None, + ssh_auth_method: None, + ssh_private_key_path: None, + ssl_mode: None, + ssl_ca_path: None, + ssl_cert_path: None, + ssl_key_path: None, + tag_ids: vec![], favorite: true, - created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), + created_at: "2024-01-01T00:00:00Z".into(), + updated_at: "2024-01-01T00:00:00Z".into(), }; let json = serde_json::to_string(&conn).unwrap(); assert!(json.contains("\"favorite\":true")); } -} \ No newline at end of file +} diff --git a/src-tauri/src/models/db_viewer.rs b/src-tauri/src/models/db_viewer.rs index 9129858..570bca8 100644 --- a/src-tauri/src/models/db_viewer.rs +++ b/src-tauri/src/models/db_viewer.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; pub struct FilterRule { pub id: String, pub column: String, - pub operator: String, // "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull" + pub operator: String, // "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull" pub value: String, } @@ -14,7 +14,7 @@ pub struct FilterRule { pub struct SortRule { pub id: String, pub column: String, - pub order: String, // "asc" | "desc" + pub order: String, // "asc" | "desc" } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -347,11 +347,13 @@ mod tests { fn change_drop_and_empty_roundtrip() { let drop: Change = serde_json::from_value(serde_json::json!({ "type": "drop_table", "id": "d", "schema": "public", "table": "t" - })).unwrap(); + })) + .unwrap(); assert_eq!(drop.id(), "d"); let empty: Change = serde_json::from_value(serde_json::json!({ "type": "empty_table", "id": "e", "schema": "public", "table": "t" - })).unwrap(); + })) + .unwrap(); assert_eq!(empty.id(), "e"); } @@ -376,9 +378,15 @@ mod tests { #[test] fn column_info_has_editability_fields() { let c = ColumnInfo { - name: "id".into(), data_type: "integer".into(), is_nullable: false, - is_pk: true, is_fk: false, fk_ref: None, default_value: None, - editable: false, is_generated: false, + name: "id".into(), + data_type: "integer".into(), + is_nullable: false, + is_pk: true, + is_fk: false, + fk_ref: None, + default_value: None, + editable: false, + is_generated: false, }; let json = serde_json::to_string(&c).unwrap(); assert!(json.contains("\"editable\":false")); @@ -513,7 +521,10 @@ mod tests { let json = serde_json::to_string(&graph).unwrap(); assert!(json.contains("users"), "should contain table name"); - assert!(json.contains("orders"), "should contain relationship source table"); + assert!( + json.contains("orders"), + "should contain relationship source table" + ); assert!(json.contains("1:N"), "should contain cardinality"); assert!(json.contains("is_pk"), "should contain is_pk field"); assert!(json.contains("is_fk"), "should contain is_fk field"); @@ -552,7 +563,10 @@ mod tests { fk_ref: None, }; let json = serde_json::to_string(&col_none).unwrap(); - assert!(json.contains("null"), "fk_ref=None should serialize as null"); + assert!( + json.contains("null"), + "fk_ref=None should serialize as null" + ); // fk_ref = Some(...) let col_some = GraphColumn { @@ -569,4 +583,4 @@ mod tests { assert!(json.contains("users"), "should contain referenced table"); assert!(json.contains("id"), "should contain referenced column"); } -} \ No newline at end of file +} diff --git a/src-tauri/src/models/folder.rs b/src-tauri/src/models/folder.rs index aa4cf73..27aa392 100644 --- a/src-tauri/src/models/folder.rs +++ b/src-tauri/src/models/folder.rs @@ -15,4 +15,4 @@ pub struct FolderInput { pub name: String, pub parent_id: Option, pub tag_ids: Option>, -} \ No newline at end of file +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 8c2525c..94a9ad4 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -3,15 +3,15 @@ pub mod connection; pub mod db_viewer; pub mod folder; pub mod recent; +pub mod settings; pub mod ssh; pub mod tag; -pub mod settings; pub use connection::{Connection, ConnectionInput}; -pub use recent::RecentConnection; #[allow(unused_imports)] pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo}; pub use folder::{Folder, FolderInput}; +pub use recent::RecentConnection; pub use settings::Settings; pub use ssh::SshConfig; -pub use tag::{Tag, TagInput}; \ No newline at end of file +pub use tag::{Tag, TagInput}; diff --git a/src-tauri/src/models/recent.rs b/src-tauri/src/models/recent.rs index 75d1eb9..b37e064 100644 --- a/src-tauri/src/models/recent.rs +++ b/src-tauri/src/models/recent.rs @@ -4,4 +4,4 @@ use serde::{Deserialize, Serialize}; pub struct RecentConnection { pub connection_id: String, pub opened_at: String, -} \ No newline at end of file +} diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs index e51cb1e..927345e 100644 --- a/src-tauri/src/models/settings.rs +++ b/src-tauri/src/models/settings.rs @@ -19,4 +19,4 @@ pub struct Settings { pub editor_word_wrap: String, pub editor_minimap: bool, pub editor_tab_size: i64, -} \ No newline at end of file +} diff --git a/src-tauri/src/models/ssh.rs b/src-tauri/src/models/ssh.rs index d181b90..f5eb063 100644 --- a/src-tauri/src/models/ssh.rs +++ b/src-tauri/src/models/ssh.rs @@ -15,12 +15,7 @@ pub struct SshConfig { impl SshConfig { /// Create a new `SshConfig` with the required fields. - pub fn new( - host: String, - port: u16, - user: String, - auth_method: String, - ) -> Self { + pub fn new(host: String, port: u16, user: String, auth_method: String) -> Self { SshConfig { host, port, @@ -41,4 +36,4 @@ impl SshConfig { pub fn is_valid(&self) -> bool { !self.host.is_empty() && self.port >= 1 && !self.user.is_empty() } -} \ No newline at end of file +} diff --git a/src-tauri/src/models/tag.rs b/src-tauri/src/models/tag.rs index 35654d6..32365ee 100644 --- a/src-tauri/src/models/tag.rs +++ b/src-tauri/src/models/tag.rs @@ -12,4 +12,4 @@ pub struct Tag { pub struct TagInput { pub name: String, pub color: String, -} \ No newline at end of file +} diff --git a/src-tauri/src/store/migrations.rs b/src-tauri/src/store/migrations.rs index 2c9404b..21206be 100644 --- a/src-tauri/src/store/migrations.rs +++ b/src-tauri/src/store/migrations.rs @@ -15,14 +15,10 @@ const CONNECTION_COLUMNS_V2: &[(&str, &str)] = &[ ]; /// New columns added since version 2. -const CONNECTION_COLUMNS_V3: &[(&str, &str)] = &[ - ("environment", "TEXT"), -]; +const CONNECTION_COLUMNS_V3: &[(&str, &str)] = &[("environment", "TEXT")]; /// New columns added in version 7. -const CONNECTION_COLUMNS_V7: &[(&str, &str)] = &[ - ("favorite", "INTEGER NOT NULL DEFAULT 0"), -]; +const CONNECTION_COLUMNS_V7: &[(&str, &str)] = &[("favorite", "INTEGER NOT NULL DEFAULT 0")]; pub fn run_migrations(conn: &Connection) -> Result<(), String> { conn.execute_batch( @@ -113,11 +109,8 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> { } // Record the migration. - conn.execute( - "INSERT INTO schema_version (version) VALUES (2)", - [], - ) - .map_err(|e| e.to_string())?; + conn.execute("INSERT INTO schema_version (version) VALUES (2)", []) + .map_err(|e| e.to_string())?; } if current_ver < 3 { @@ -141,11 +134,8 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> { } } - conn.execute( - "INSERT INTO schema_version (version) VALUES (3)", - [], - ) - .map_err(|e| e.to_string())?; + conn.execute("INSERT INTO schema_version (version) VALUES (3)", []) + .map_err(|e| e.to_string())?; } // v4: backup_history @@ -164,14 +154,12 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> { size_bytes INTEGER, started_at TEXT NOT NULL, completed_at TEXT - );" - ).map_err(|e| e.to_string())?; - - conn.execute( - "INSERT INTO schema_version (version) VALUES (4)", - [], + );", ) .map_err(|e| e.to_string())?; + + conn.execute("INSERT INTO schema_version (version) VALUES (4)", []) + .map_err(|e| e.to_string())?; } // v5: query_history @@ -189,14 +177,12 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> { 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)", - [], + 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())?; } // v6: query history favorites + saved queries @@ -214,14 +200,12 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> { FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_queries_connection ON queries(connection_id); - CREATE INDEX IF NOT EXISTS idx_queries_folder ON queries(folder);" - ).map_err(|e| e.to_string())?; - - conn.execute( - "INSERT INTO schema_version (version) VALUES (6)", - [], + CREATE INDEX IF NOT EXISTS idx_queries_folder ON queries(folder);", ) .map_err(|e| e.to_string())?; + + conn.execute("INSERT INTO schema_version (version) VALUES (6)", []) + .map_err(|e| e.to_string())?; } // v7: connection favorites + recent_connections @@ -250,14 +234,12 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> { "CREATE TABLE IF NOT EXISTS recent_connections ( connection_id TEXT PRIMARY KEY REFERENCES connections(id) ON DELETE CASCADE, opened_at TEXT NOT NULL - );" - ).map_err(|e| e.to_string())?; - - conn.execute( - "INSERT INTO schema_version (version) VALUES (7)", - [], + );", ) .map_err(|e| e.to_string())?; + + conn.execute("INSERT INTO schema_version (version) VALUES (7)", []) + .map_err(|e| e.to_string())?; } Ok(()) @@ -325,9 +307,7 @@ mod tests { // 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(); + let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap(); rows.filter_map(|r| r.ok()).collect() }; assert!(columns.contains(&"id".to_string())); @@ -356,9 +336,17 @@ mod tests { rusqlite::params![conn_id], ).unwrap(); // Delete connection — should cascade - conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap(); + 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)) + .query_row( + "SELECT COUNT(*) FROM query_history WHERE connection_id = ?1", + rusqlite::params![conn_id], + |r| r.get(0), + ) .unwrap(); assert_eq!(count, 0); } @@ -370,9 +358,7 @@ mod tests { // Verify the favorite column exists 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(); + let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap(); rows.filter_map(|r| r.ok()).collect() }; assert!( @@ -402,13 +388,23 @@ mod tests { // Column check let columns: Vec = { let mut stmt = conn.prepare("PRAGMA table_info(queries)").unwrap(); - let rows = stmt - .query_map([], |row| row.get::<_, String>(1)) - .unwrap(); + let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap(); rows.filter_map(|r| r.ok()).collect() }; - for c in &["id", "connection_id", "name", "query_text", "folder", "created_at", "updated_at"] { - assert!(columns.contains(&c.to_string()), "Expected queries table to have column: {}", c); + for c in &[ + "id", + "connection_id", + "name", + "query_text", + "folder", + "created_at", + "updated_at", + ] { + assert!( + columns.contains(&c.to_string()), + "Expected queries table to have column: {}", + c + ); } } @@ -433,16 +429,32 @@ mod tests { [], ).unwrap(); // Delete connection — should cascade the non-NULL row - conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap(); + conn.execute( + "DELETE FROM connections WHERE id = ?1", + rusqlite::params![conn_id], + ) + .unwrap(); let count_scoped: i64 = conn - .query_row("SELECT COUNT(*) FROM queries WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0)) + .query_row( + "SELECT COUNT(*) FROM queries WHERE connection_id = ?1", + rusqlite::params![conn_id], + |r| r.get(0), + ) .unwrap(); - assert_eq!(count_scoped, 0, "Scoped saved query should be cascade-deleted"); + assert_eq!( + count_scoped, 0, + "Scoped saved query should be cascade-deleted" + ); // NULL-saved query survives let count_global: i64 = conn - .query_row("SELECT COUNT(*) FROM queries WHERE id = 'q2'", [], |r| r.get(0)) + .query_row("SELECT COUNT(*) FROM queries WHERE id = 'q2'", [], |r| { + r.get(0) + }) .unwrap(); - assert_eq!(count_global, 1, "Global saved query (connection_id NULL) should survive"); + assert_eq!( + count_global, 1, + "Global saved query (connection_id NULL) should survive" + ); } #[test] @@ -456,7 +468,10 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(count, 1, "Schema version 6 should be recorded after v6 migration"); + assert_eq!( + count, 1, + "Schema version 6 should be recorded after v6 migration" + ); } #[test] @@ -477,9 +492,13 @@ mod tests { "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(); - let fav: i64 = conn.query_row( - "SELECT favorite FROM connections WHERE id = ?1", rusqlite::params![conn_id], |r| r.get(0), - ).unwrap(); + let fav: i64 = conn + .query_row( + "SELECT favorite FROM connections WHERE id = ?1", + rusqlite::params![conn_id], + |r| r.get(0), + ) + .unwrap(); assert_eq!(fav, 0, "favorite defaults to 0"); } @@ -506,9 +525,17 @@ mod tests { "INSERT INTO recent_connections (connection_id, opened_at) VALUES (?1, datetime('now'))", rusqlite::params![conn_id], ).unwrap(); - conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap(); + conn.execute( + "DELETE FROM connections WHERE id = ?1", + rusqlite::params![conn_id], + ) + .unwrap(); let count: i64 = conn - .query_row("SELECT COUNT(*) FROM recent_connections WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0)) + .query_row( + "SELECT COUNT(*) FROM recent_connections WHERE connection_id = ?1", + rusqlite::params![conn_id], + |r| r.get(0), + ) .unwrap(); assert_eq!(count, 0); } @@ -522,4 +549,4 @@ mod tests { .unwrap(); assert_eq!(ver, 7); } -} \ No newline at end of file +} diff --git a/src-tauri/src/store/mod.rs b/src-tauri/src/store/mod.rs index 5decfef..48f4545 100644 --- a/src-tauri/src/store/mod.rs +++ b/src-tauri/src/store/mod.rs @@ -27,8 +27,15 @@ const MAX_NAME_LEN: usize = 200; const MAX_FOLDER_LEN: usize = 100; const MAX_QUERY_TEXT_LEN: usize = 1_048_576; // 1 MB -const ALLOWED_EDITOR_FONTS: &[&str] = - &["Space Mono", "Fira Code", "Menlo", "Monaco", "Consolas", "JetBrains Mono", "monospace"]; +const ALLOWED_EDITOR_FONTS: &[&str] = &[ + "Space Mono", + "Fira Code", + "Menlo", + "Monaco", + "Consolas", + "JetBrains Mono", + "monospace", +]; impl Store { pub fn from_connection(conn: SqliteConnection) -> Self { @@ -50,7 +57,9 @@ impl Store { pub fn get_folders(&self) -> Result, String> { let conn = self.conn.lock().map_err(|e| e.to_string())?; let mut stmt = conn - .prepare("SELECT id, name, parent_id, created_at, updated_at FROM folders ORDER BY name") + .prepare( + "SELECT id, name, parent_id, created_at, updated_at FROM folders ORDER BY name", + ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { @@ -402,7 +411,11 @@ impl Store { Ok(()) } - pub fn update_connection(&self, id: &str, input: ConnectionInput) -> Result { + pub fn update_connection( + &self, + id: &str, + input: ConnectionInput, + ) -> Result { let conn = self.conn.lock().map_err(|e| e.to_string())?; let now = Self::now(); conn.execute( @@ -416,13 +429,17 @@ impl Store { ], ).map_err(|e| e.to_string())?; // Update tags - conn.execute("DELETE FROM connection_tags WHERE connection_id = ?1", params![id]) - .map_err(|e| e.to_string())?; + conn.execute( + "DELETE FROM connection_tags WHERE connection_id = ?1", + params![id], + ) + .map_err(|e| e.to_string())?; for tag_id in &input.tag_ids { conn.execute( "INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)", params![id, tag_id], - ).map_err(|e| e.to_string())?; + ) + .map_err(|e| e.to_string())?; } Ok(Connection { id: id.to_string(), @@ -459,10 +476,7 @@ impl Store { .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - )) + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map_err(|e| e.to_string())?; for r in rows.filter_map(|r| r.ok()) { @@ -490,9 +504,7 @@ impl Store { default_ports.insert("redis".to_string(), Some(6379i64)); default_ports.insert("sqlite".to_string(), None); if let Some(ports_json) = map.get("default_ports") { - if let Ok(parsed) = - serde_json::from_str::>>(ports_json) - { + if let Ok(parsed) = serde_json::from_str::>>(ports_json) { default_ports = parsed; } } @@ -627,18 +639,19 @@ impl Store { 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 { - ( + 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, favorite 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 { - ( + } else { + ( "SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at, favorite 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 @@ -656,7 +669,8 @@ impl Store { }) }) .map_err(|e| e.to_string())?; - rows.collect::, _>>().map_err(|e| e.to_string()) + rows.collect::, _>>() + .map_err(|e| e.to_string()) } /// Delete all query history rows, optionally filtered by `connection_id`. @@ -733,20 +747,22 @@ impl Store { connection_id: Option<&str>, ) -> Result, String> { let conn = self.conn.lock().map_err(|e| e.to_string())?; - let (sql, params_vec): (String, Vec>) = - if let Some(cid) = connection_id { - ( + let (sql, params_vec): (String, Vec>) = if let Some(cid) = + connection_id + { + ( "SELECT id, connection_id, name, query_text, folder, created_at, updated_at FROM queries WHERE connection_id = ?1 ORDER BY updated_at DESC".to_string(), vec![Box::new(cid.to_string())], ) - } else { - ( + } else { + ( "SELECT id, connection_id, name, query_text, folder, created_at, updated_at FROM queries ORDER BY updated_at DESC".to_string(), vec![], ) - }; + }; let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; - let refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); + let refs: Vec<&dyn rusqlite::types::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); let rows = stmt .query_map(rusqlite::params_from_iter(&refs), |row| { Ok(SavedQueryRow { @@ -760,7 +776,8 @@ impl Store { }) }) .map_err(|e| e.to_string())?; - rows.collect::, _>>().map_err(|e| e.to_string()) + rows.collect::, _>>() + .map_err(|e| e.to_string()) } pub fn update_saved_query( @@ -802,11 +819,9 @@ impl Store { idx += 1; } - let sql = format!( - "UPDATE queries SET {} WHERE id = ?{idx}", - sets.join(", "), - ); - let mut all_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let sql = format!("UPDATE queries SET {} WHERE id = ?{idx}", sets.join(", "),); + let mut all_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); let id_param: Box = Box::new(id.to_string()); all_refs.push(id_param.as_ref()); @@ -864,7 +879,8 @@ mod tests { fn create_and_get_folder() { let store = fresh_store(); let folder = store - .create_folder(FolderInput { tag_ids: None, + .create_folder(FolderInput { + tag_ids: None, name: "Work".into(), parent_id: None, }) @@ -880,13 +896,15 @@ mod tests { fn create_nested_folders() { let store = fresh_store(); let parent = store - .create_folder(FolderInput { tag_ids: None, + .create_folder(FolderInput { + tag_ids: None, name: "root".into(), parent_id: None, }) .unwrap(); let child = store - .create_folder(FolderInput { tag_ids: None, + .create_folder(FolderInput { + tag_ids: None, name: "child".into(), parent_id: Some(parent.id.clone()), }) @@ -996,7 +1014,8 @@ mod tests { fn delete_folder_sets_connection_folder_null() { let store = fresh_store(); let folder = store - .create_folder(FolderInput { tag_ids: None, + .create_folder(FolderInput { + tag_ids: None, name: "f".into(), parent_id: None, }) @@ -1078,10 +1097,7 @@ mod tests { assert_eq!(settings.font_size, "medium"); assert!(settings.confirm_before_delete); assert_eq!(settings.accent_color, "#2563EB"); - assert_eq!( - settings.default_ports.get("postgresql"), - Some(&Some(5432)) - ); + assert_eq!(settings.default_ports.get("postgresql"), Some(&Some(5432))); } #[test] @@ -1140,10 +1156,7 @@ mod tests { Some("/home/user/.ssh/id_rsa") ); assert_eq!(got[0].ssl_mode.as_deref(), Some("verify-full")); - assert_eq!( - got[0].ssl_ca_path.as_deref(), - Some("/etc/ssl/certs/ca.pem") - ); + assert_eq!(got[0].ssl_ca_path.as_deref(), Some("/etc/ssl/certs/ca.pem")); assert_eq!( got[0].ssl_cert_path.as_deref(), Some("/etc/ssl/certs/client-cert.pem") @@ -1186,21 +1199,53 @@ mod tests { // First insert store - .insert_query_history("h1", &conn.id, "SELECT 1", Some(10), Some(5), "success", None) + .insert_query_history( + "h1", + &conn.id, + "SELECT 1", + Some(10), + Some(5), + "success", + None, + ) .unwrap(); // Consecutive identical — should UPDATE, not INSERT store - .insert_query_history("h2", &conn.id, "SELECT 1", Some(20), Some(8), "success", None) + .insert_query_history( + "h2", + &conn.id, + "SELECT 1", + Some(20), + Some(8), + "success", + None, + ) .unwrap(); // There should still be 1 row (not 2), with updated stats let rows = store.get_query_history(Some(&conn.id), 10, 0).unwrap(); - assert_eq!(rows.len(), 1, "Consecutive identical queries should dedup to one row"); - assert_eq!(rows[0].execution_time_ms, Some(20), "Stats should update after dedup"); + assert_eq!( + rows.len(), + 1, + "Consecutive identical queries should dedup to one row" + ); + assert_eq!( + rows[0].execution_time_ms, + Some(20), + "Stats should update after dedup" + ); assert_eq!(rows[0].id, "h1", "Original ID should persist after dedup"); // Different query — should INSERT a new row store - .insert_query_history("h3", &conn.id, "SELECT 2", Some(5), Some(0), "success", None) + .insert_query_history( + "h3", + &conn.id, + "SELECT 2", + Some(5), + Some(0), + "success", + None, + ) .unwrap(); let rows2 = store.get_query_history(Some(&conn.id), 10, 0).unwrap(); assert_eq!(rows2.len(), 2, "Different query should create a new row"); @@ -1253,8 +1298,14 @@ mod tests { assert_eq!(rows.len(), 500, "Should be pruned to 500 rows"); // Oldest rows (ph0..ph9) should be pruned; most recent (ph509) kept let all_ids: Vec = rows.iter().map(|r| r.id.clone()).collect(); - assert!(!all_ids.contains(&"ph0".to_string()), "Oldest rows should be pruned"); - assert!(all_ids.contains(&"ph509".to_string()), "Most recent rows should be kept"); + assert!( + !all_ids.contains(&"ph0".to_string()), + "Oldest rows should be pruned" + ); + assert!( + all_ids.contains(&"ph509".to_string()), + "Most recent rows should be kept" + ); } #[test] @@ -1286,7 +1337,15 @@ mod tests { }) .unwrap(); store - .insert_query_history("fh1", &conn.id, "SELECT 1", Some(5), Some(1), "success", None) + .insert_query_history( + "fh1", + &conn.id, + "SELECT 1", + Some(5), + Some(1), + "success", + None, + ) .unwrap(); let rows = store.get_query_history(Some(&conn.id), 10, 0).unwrap(); assert_eq!(rows.len(), 1); @@ -1322,7 +1381,15 @@ mod tests { }) .unwrap(); store - .insert_query_history("ft1", &conn.id, "SELECT 1", Some(5), Some(1), "success", None) + .insert_query_history( + "ft1", + &conn.id, + "SELECT 1", + Some(5), + Some(1), + "success", + None, + ) .unwrap(); // Toggle on @@ -1532,11 +1599,16 @@ mod tests { crate::store::migrations::run_migrations(&conn).unwrap(); let store = Store::from_connection(conn); store.update_setting("editor_font_size", "abc").unwrap(); - store.update_setting("editor_font_family", "Comic Sans").unwrap(); + store + .update_setting("editor_font_family", "Comic Sans") + .unwrap(); store.update_setting("editor_word_wrap", "weird").unwrap(); let s = store.get_settings().unwrap(); assert_eq!(s.editor_font_size, 13, "garbage -> default"); - assert_eq!(s.editor_font_family, "Space Mono", "disallowed font -> default"); + assert_eq!( + s.editor_font_family, "Space Mono", + "disallowed font -> default" + ); assert_eq!(s.editor_word_wrap, "off", "invalid wrap -> default"); } -} \ No newline at end of file +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 562cce6..eab9209 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Gridline", - "version": "0.5.0", + "version": "0.6.0", "identifier": "com.adrianbonpin.gridline", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/components/db-viewer/DbViewerScreen.test.tsx b/src/components/db-viewer/DbViewerScreen.test.tsx index 9f983f6..a895b1d 100644 --- a/src/components/db-viewer/DbViewerScreen.test.tsx +++ b/src/components/db-viewer/DbViewerScreen.test.tsx @@ -148,12 +148,12 @@ describe("DbViewerScreen", () => { const staged = useDbViewerStore.getState().changesQueue[0]; expect(staged?.table).toBe("users"); expect(staged?.schema).toBe("public"); - // grid cell shows the optimistic value + the pending dot (2nd match is the queue panel diff) + // grid cell shows the optimistic value + the pending outline (2nd match is the queue panel diff) await waitFor(() => { expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2); }); - expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument(); - // committing the change clears the pending dot but keeps the value until refetch + expect(screen.getByTestId("pending-cell")).toBeInTheDocument(); + // committing the change clears the pending outline but keeps the value until refetch act(() => { useDbViewerStore .getState() @@ -162,7 +162,7 @@ describe("DbViewerScreen", () => { ); }); await waitFor(() => { - expect(screen.queryByTestId("pending-edit-dot")).toBeNull(); + expect(screen.queryByTestId("pending-cell")).toBeNull(); }); expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2); // clearing the queue clears the optimistic display diff --git a/src/components/db-viewer/DbViewerScreen.tsx b/src/components/db-viewer/DbViewerScreen.tsx index 8a9de6b..87ee797 100644 --- a/src/components/db-viewer/DbViewerScreen.tsx +++ b/src/components/db-viewer/DbViewerScreen.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react"; -import { ChevronDown, ChevronUp, Table2, Terminal } from "lucide-react"; +import { ChevronDown, ChevronUp, Table2, Terminal, AlertCircle } from "lucide-react"; import { format as formatSql } from "sql-formatter"; import { TooltipProvider } from "../ui/Tooltip"; import { DbViewerSidebar } from "./DbViewerSidebar"; @@ -217,6 +217,19 @@ export function DbViewerScreen({ t.table_type === "MATERIALIZED VIEW", ) : false; + // Regular views (SQLite + PG) are read-only like matviews: they expose no + // row locator (no ctid/rowid) and cannot be modified via SQLite, so hide + // all data-modifying affordances for them as well. + const isView = + activeTab && activeTab.tabType === "table" + ? tables.some( + (t) => + t.schema === activeTab.schema && + t.name === activeTab.table && + t.table_type === "VIEW", + ) + : false; + const readOnlyTable = isMatview || isView; const setTabData = useDbViewerStore((s) => s.setTabData); const setTabError = useDbViewerStore((s) => s.setTabError); @@ -1105,7 +1118,7 @@ const onQueriesPanelResizeStart = useCallback( ) } variant="query" - isMatview={isMatview} + isMatview={readOnlyTable} /> )}
@@ -1120,9 +1133,9 @@ const onQueriesPanelResizeStart = useCallback( dbType={currentConnection?.db_type ?? "postgresql"} tabType={activeTab?.tabType ?? "table"} getLocator={getLocator} - onStageEdit={isMatview ? undefined : handleStageEdit} + onStageEdit={readOnlyTable ? undefined : handleStageEdit} onOpenRowDetail={handleOpenRowDetail} - readOnly={isMatview} + readOnly={readOnlyTable} enumValues={editorOptions?.enums} fkOptions={editorOptions?.fks} fkPlaceholders={editorOptions?.fkPlaceholders} @@ -1217,6 +1230,17 @@ const onQueriesPanelResizeStart = useCallback( ) : ( <> + {activeTab?.error && ( +
+ + + {activeTab.error} + +
+ )} {activeTab?.data && ( setSelectedRows(new Set()) } - isMatview={isMatview} + isMatview={readOnlyTable} /> )}
@@ -1273,9 +1297,9 @@ const onQueriesPanelResizeStart = useCallback( dbType={currentConnection?.db_type ?? "postgresql"} tabType={activeTab?.tabType ?? "table"} getLocator={getLocator} - onStageEdit={isMatview ? undefined : handleStageEdit} + onStageEdit={readOnlyTable ? undefined : handleStageEdit} onOpenRowDetail={handleOpenRowDetail} - readOnly={isMatview} + readOnly={readOnlyTable} enumValues={editorOptions?.enums} fkOptions={editorOptions?.fks} fkPlaceholders={editorOptions?.fkPlaceholders} diff --git a/src/components/db-viewer/FkPreviewPopover.tsx b/src/components/db-viewer/FkPreviewPopover.tsx index 37df6a1..6b48693 100644 --- a/src/components/db-viewer/FkPreviewPopover.tsx +++ b/src/components/db-viewer/FkPreviewPopover.tsx @@ -3,8 +3,8 @@ import { createPortal } from "react-dom"; import { Key, X, ExternalLink, Loader2 } from "lucide-react"; import * as cmd from "../../lib/commands"; import type { QueryResult } from "../../lib/types"; -import { abbreviateType } from "../../lib/utils"; import { useDbViewerStore } from "../../stores/dbViewerStore"; +import { DataTypeIcon } from "../ui/DataTypeIcon"; interface FkPreviewPopoverProps { connectionId: string; @@ -168,7 +168,7 @@ export function FkPreviewPopover({
)} {data && data.rows.length > 0 && ( - +
{data.columns.map((col, ci) => { const cell = data.rows[0][ci]; @@ -178,20 +178,22 @@ export function FkPreviewPopover({ key={col.name} className="border-b border-border last:border-0 hover:bg-surface/30" > - -
-
+
+
{col.is_pk && } {col.is_fk && } {col.name} - - {abbreviateType(col.data_type)} - +
+ {isNull ? ( NULL ) : ( diff --git a/src/components/db-viewer/RestorePage.tsx b/src/components/db-viewer/RestorePage.tsx index cdd9e08..4c434f4 100644 --- a/src/components/db-viewer/RestorePage.tsx +++ b/src/components/db-viewer/RestorePage.tsx @@ -239,14 +239,21 @@ export function RestorePage({ connectionId }: RestorePageProps) { {/* Clean toggle */} -