v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling (#9)

* feat: demo DB seed/regenerate, backup/restore/sync refinements, SSH/SSL polish

- Demo SQLite DB: feature-rich seed (12 objects, 500-row audit log) + regenerate action in Settings
- Backup/restore: headless-testable core logic, psql -f for plain dumps, sync passes --clean --if-exists
- SSH/SSL runtime refinements and connection testing improvements
- Data grid: DataTypeIcon component, FK popover, table tree polish
- Docs: AGENTS.md/README updates, new screenshots, MIT LICENSE

* chore: optimize README screenshots (4.7 MB → 916 KB via pngquant, quality 70-90)

* v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling

- Bump version to 0.6.0 across package.json, Cargo.toml, tauri.conf.json
- Add .github/workflows/release.yml: tag-triggered CI builds macOS (aarch64
  + x64), Windows, and Linux installers into a draft GitHub Release
- README: installer download table (unsigned note, per-platform files),
  "how releases are made" section
- CellEditor: constraint-aware commit — empty input on nullable columns
  becomes NULL, NOT NULL text-like types fall back to empty string, all
  other types blocked with an inline error bubble; replace "Set NULL"
  checkbox with a NULL row in the FK dropdown / empty enum option
- VirtualDataGrid: pending-edit dot → animated pending outline (ring) on
  staged cells; matching test updates
- docs-coverage test: align with rewritten README comparison table
This commit is contained in:
2026-08-04 17:20:14 +08:00
committed by GitHub
parent a9dbf60ed5
commit 002d8345ae
66 changed files with 3591 additions and 1602 deletions
+74
View File
@@ -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 }}
+4 -4
View File
@@ -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) | ❌ | |
+201
View File
@@ -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.
+278 -182
View File
@@ -1,45 +1,117 @@
# Gridline
<p align="center">
<img src="./screenshots/data-grid.png" alt="Gridline data grid with FK preview" width="92%" style="border-radius: 14px;" />
</p>
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.
<h1>Gridline</h1>
> **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.
<p>
<i>A lightweight, open-source database GUI for PostgreSQL and SQLite.</i><br />
Unlimited connections, tabs, and saved queries — with first-class <code>pg_dump</code>, <code>pg_restore</code>, and DB-to-DB sync.
</p>
<p>
<sub><b>Data Grid & FK Preview</b> — browse 50+ real demo orders and inspect related rows in one click.</sub>
</p>
<p>
<a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-24C8DB?style=for-the-badge&logo=tauri&logoColor=white" alt="Tauri" /></a>
<a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white" alt="Rust" /></a>
<a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-D22128.svg?style=for-the-badge" alt="Apache 2.0 License" /></a>
<a href="https://github.com/adrianbonpin/gridline/stargazers"><img src="https://img.shields.io/github/stars/adrianbonpin/gridline?style=for-the-badge&logo=github&label=Stars" alt="GitHub stars" /></a>
</p>
<p>
<a href="https://github.com/adrianbonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge&logo=github&logoColor=white" alt="Download Latest" /></a>
<a href="https://github.com/adrianbonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge&logo=github&logoColor=white" alt="Open an Issue" /></a>
</p>
---
## 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
<table>
<tr>
<td align="center" width="50%">
<a href="./screenshots/home.png">
<img src="./screenshots/home.png" alt="Saved connections home screen" width="100%" style="border-radius: 10px;" />
<a href="./screenshots/query-editor.png">
<img src="./screenshots/query-editor.png" alt="Query editor" width="100%" style="border-radius: 12px;" />
</a>
<br />
<sub><b>Home Screen</b> — organized folders, tags, and quick search</sub>
<sub><b>Query Editor</b></sub>
</td>
<td align="center" width="50%">
<a href="./screenshots/data-grid.png">
<img src="./screenshots/data-grid.png" alt="Data grid with FK preview" width="100%" style="border-radius: 10px;" />
<a href="./screenshots/er-diagram.png">
<img src="./screenshots/er-diagram.png" alt="ER diagram" width="100%" style="border-radius: 12px;" />
</a>
<br />
<sub><b>Data Grid</b> — virtualized rows, column controls, and FK preview</sub>
<sub><b>Schema Visualizer</b></sub>
</td>
</tr>
<tr>
<td align="center" width="50%">
<a href="./screenshots/er-diagram.png">
<img src="./screenshots/er-diagram.png" alt="Interactive ER diagram" width="100%" style="border-radius: 10px;" />
<a href="./screenshots/json-popover.png">
<img src="./screenshots/json-popover.png" alt="JSON popover" width="100%" style="border-radius: 12px;" />
</a>
<br />
<sub><b>Schema Visualizer</b> — interactive ER diagram with cardinality legend</sub>
<sub><b>JSON Viewer</b></sub>
</td>
<td align="center" width="50%">
<a href="./screenshots/enums.png">
<img src="./screenshots/enums.png" alt="Enum detail view" width="100%" style="border-radius: 10px;" />
<a href="./screenshots/home.png">
<img src="./screenshots/home.png" alt="Home screen" width="100%" style="border-radius: 12px;" />
</a>
<br />
<sub><b>Object Explorer</b> — deep PostgreSQL objects like enums, functions, and triggers</sub>
<sub><b>Home Screen</b></sub>
</td>
</tr>
</table>
@@ -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.
---
<p align="center">
<sub>Built with ♥ for developers who believe powerful tools should be free.</sub>
</p>
Apache 2.0 — see [LICENSE](./LICENSE) for details.
+1 -1
View File
@@ -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": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 929 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]]
name = "gridline"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"chrono",
"deadpool-postgres",
+1 -1
View File
@@ -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"
+333 -374
View File
@@ -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<String> {
.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<String> {
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<String> {
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<String> {
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<String> = 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(&params, &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<String> = 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(&params, &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<String> = 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<String> = 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;
mod tests;
+207 -1
View File
@@ -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\""));
}
}
// ------------------------------------------------------------------
// 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);
}
+2 -6
View File
@@ -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);
}
}
}
+298 -134
View File
@@ -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<String, String> {
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<String>) {
fn build_sqlite_filter_clause(
filters: &[crate::models::db_viewer::FilterRule],
) -> (String, Vec<String>) {
let mut clauses = String::new();
let mut params: Vec<String> = Vec::new();
@@ -331,39 +333,53 @@ pub(crate) fn pg_char_to_att(value: Option<i8>) -> 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<String>, 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<String>,
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<String> = 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<String> = 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<serde_json::Value>],
) -> Result<usize, String> {
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<Box<dyn ToSql + Send + Sync>> = row.iter().map(pg_box_value).collect();
@@ -721,7 +740,10 @@ fn parse_json_pairs(json: &str) -> Result<Vec<(String, serde_json::Value)>, 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<serde_json::Value>` 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
rows.collect::<Result<Vec<_>, _>>()
.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, &param_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<i8>>(8).unwrap_or(None),
);
let attidentity = pg_char_to_att(
r.try_get::<_, Option<i8>>(9).unwrap_or(None),
);
let attgenerated =
pg_char_to_att(r.try_get::<_, Option<i8>>(8).unwrap_or(None));
let attidentity = pg_char_to_att(r.try_get::<_, Option<i8>>(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<String>>(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<String> 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<String> = 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, &param_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<String>)> = 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<String>>(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<String> = 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<String> =
pairs.iter().map(|(c, _)| c.clone()).collect();
let columns: Vec<String> = 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<String>>(3).unwrap_or_default().as_str() {
"c" => "CHECK",
"u" => "UNIQUE",
"x" => "EXCLUSION",
other => other,
}
.to_string();
let contype =
match r.get::<_, Option<String>>(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<String>>(7).unwrap_or_default()),
columns: split_columns_csv(
&r.get::<_, Option<String>>(7).unwrap_or_default(),
),
}
})
.collect())
@@ -1986,7 +2087,8 @@ pub async fn get_sequences(
max_value: r.get::<_, Option<String>>(4).unwrap_or_default(),
increment: r.get::<_, Option<String>>(5).unwrap_or_default(),
current_value: r.get::<_, Option<String>>(6).unwrap_or_default(),
cycle: r.get::<_, Option<String>>(7)
cycle: r
.get::<_, Option<String>>(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::<String>::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())
);
}
}
+7 -314
View File
@@ -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;
mod tests;
+171 -38
View File
@@ -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::<Vec<_>>(),
expected
.into_iter()
.map(|(n, t)| (n.to_string(), t.to_string()))
.collect::<Vec<_>>(),
"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::<serde_json::Value>(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<String> = 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<Vec<rusqlite::types::Value>> = 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::<Vec<_>>(),
composite
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>(),
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();
}
}
+492
View File
@@ -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 OBrien', '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);
+18 -14
View File
@@ -14,10 +14,7 @@ pub fn get_folders_inner(state: &Mutex<Store>) -> Result<Vec<Folder>, String> {
store.get_folders()
}
pub fn create_folder_inner(
state: &Mutex<Store>,
input: FolderInput,
) -> Result<Folder, String> {
pub fn create_folder_inner(state: &Mutex<Store>, input: FolderInput) -> Result<Folder, String> {
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);
}
}
}
+54 -17
View File
@@ -33,7 +33,8 @@ pub struct ImportResult {
#[allow(dead_code)]
pub fn parse_import(json: &str) -> Result<Vec<ImportRecord>, String> {
let records: Vec<ImportRecord> = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {}", e))?;
let records: Vec<ImportRecord> =
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<Vec<ImportRecord>, String> {
Ok(records)
}
pub fn import_connections_inner(state: &Mutex<Store>, json: String) -> Result<ImportResult, String> {
let records: Vec<ImportRecord> = serde_json::from_str(&json).map_err(|e| format!("invalid JSON: {}", e))?;
pub fn import_connections_inner(
state: &Mutex<Store>,
json: String,
) -> Result<ImportResult, String> {
let records: Vec<ImportRecord> =
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<Store>, json: String) -> Result<Im
let name = match &rec.name {
Some(n) if !n.is_empty() => 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<Store>, json: String) -> Result<Im
};
match store.create_connection(input) {
Ok(_) => 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<Store>) -> Result<String, String> {
@@ -105,7 +126,10 @@ pub fn export_connections_inner(state: &Mutex<Store>) -> Result<String, String>
}
#[tauri::command]
pub fn import_connections(state: tauri::State<crate::AppState>, json: String) -> Result<ImportResult, String> {
pub fn import_connections(
state: tauri::State<crate::AppState>,
json: String,
) -> Result<ImportResult, String> {
import_connections_inner(&state.db_store, json)
}
@@ -117,8 +141,8 @@ pub fn export_connections(state: tauri::State<crate::AppState>) -> Result<String
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::models::ConnectionInput;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
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\""));
}
}
}
+1 -1
View File
@@ -190,4 +190,4 @@ mod tests {
assert_eq!(ssh_account("password", "c1"), "ssh_password:c1");
assert_eq!(ssh_account("passphrase", "c1"), "ssh_passphrase:c1");
}
}
}
+9 -9
View File
@@ -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;
pub mod settings;
pub mod ssh;
pub mod tags;
pub mod test_connection;
+36 -40
View File
@@ -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<Vec<serde_json::Value>> = 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<Vec<serde_json::Value>> = 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<String>,
) -> 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"),
}
}
}
}
+135 -57
View File
@@ -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<SchemaGraph, String> {
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<String, (String, String)> = 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<GraphColumn> = 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<GraphColumn> = 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<serde_json::Value>> = 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());
}
}
}
+6 -2
View File
@@ -18,7 +18,11 @@ pub fn get_settings(state: tauri::State<crate::AppState>) -> Result<Settings, St
}
#[tauri::command]
pub fn update_setting(state: tauri::State<crate::AppState>, key: String, value: String) -> Result<(), String> {
pub fn update_setting(
state: tauri::State<crate::AppState>,
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");
}
}
}
+8 -5
View File
@@ -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);
}
}
}
+18 -9
View File
@@ -25,11 +25,7 @@ pub fn delete_tag_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
store.delete_tag(id)
}
pub fn update_tag_inner(
state: &Mutex<Store>,
id: String,
input: TagInput,
) -> Result<Tag, String> {
pub fn update_tag_inner(state: &Mutex<Store>, id: String, input: TagInput) -> Result<Tag, String> {
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<Store> {
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());
}
}
}
+21 -20
View File
@@ -126,8 +126,7 @@ pub fn validate_test_input(config: &DbConfig) -> Option<String> {
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"
);
}
}
}
+19 -5
View File
@@ -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"));
}
}
}
+2 -2
View File
@@ -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};
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
+41 -12
View File
@@ -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()]);
}
}
}
+36 -24
View File
@@ -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<CertificateDer<'static>> = rustls_pemfile::certs(&mut std::io::BufReader::new(
cb.as_slice(),
))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("parse cert: {e}"))?
.into_iter()
.map(|c| c.into_owned())
.collect();
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut std::io::BufReader::new(cb.as_slice()))
.collect::<Result<Vec<_>, _>>()
.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"));
}
+8 -5
View File
@@ -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<Store>,
@@ -19,7 +19,10 @@ pub struct AppState {
pub ssh_manager: StdMutex<SshTunnelManager>,
}
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]
+1 -1
View File
@@ -97,4 +97,4 @@ mod tests {
assert!(json.contains("dump"));
assert!(json.contains("completed"));
}
}
}
+43 -13
View File
@@ -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"));
}
}
}
+24 -10
View File
@@ -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");
}
}
}
+1 -1
View File
@@ -15,4 +15,4 @@ pub struct FolderInput {
pub name: String,
pub parent_id: Option<String>,
pub tag_ids: Option<Vec<String>>,
}
}
+3 -3
View File
@@ -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};
pub use tag::{Tag, TagInput};
+1 -1
View File
@@ -4,4 +4,4 @@ use serde::{Deserialize, Serialize};
pub struct RecentConnection {
pub connection_id: String,
pub opened_at: String,
}
}
+1 -1
View File
@@ -19,4 +19,4 @@ pub struct Settings {
pub editor_word_wrap: String,
pub editor_minimap: bool,
pub editor_tab_size: i64,
}
}
+2 -7
View File
@@ -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()
}
}
}
+1 -1
View File
@@ -12,4 +12,4 @@ pub struct Tag {
pub struct TagInput {
pub name: String,
pub color: String,
}
}
+92 -65
View File
@@ -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<String> = {
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<String> = {
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<String> = {
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);
}
}
}
+130 -58
View File
@@ -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<Vec<Folder>, 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<Connection, String> {
pub fn update_connection(
&self,
id: &str,
input: ConnectionInput,
) -> Result<Connection, String> {
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::<HashMap<String, Option<i64>>>(ports_json)
{
if let Ok(parsed) = serde_json::from_str::<HashMap<String, Option<i64>>>(ports_json) {
default_ports = parsed;
}
}
@@ -627,18 +639,19 @@ impl Store {
offset: i64,
) -> Result<Vec<crate::commands::query::QueryHistoryEntry>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
if let Some(cid) = connection_id {
(
let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
rows.collect::<Result<Vec<_>, _>>()
.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<Vec<SavedQueryRow>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
if let Some(cid) = connection_id {
(
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
rows.collect::<Result<Vec<_>, _>>()
.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<dyn rusqlite::types::ToSql> = 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<String> = 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");
}
}
}
+1 -1
View File
@@ -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",
@@ -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
+31 -7
View File
@@ -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}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -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(
</Suspense>
) : (
<>
{activeTab?.error && (
<div
role="alert"
className="flex items-center gap-2 px-3 py-2 text-xs text-red-400 border-b border-border bg-surface-raised"
>
<AlertCircle size={14} className="shrink-0" />
<span className="truncate">
{activeTab.error}
</span>
</div>
)}
{activeTab?.data && (
<TableControls
connectionId={connectionId}
@@ -1258,7 +1282,7 @@ const onQueriesPanelResizeStart = useCallback(
onClearSelection={() =>
setSelectedRows(new Set())
}
isMatview={isMatview}
isMatview={readOnlyTable}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -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}
+13 -11
View File
@@ -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({
</div>
)}
{data && data.rows.length > 0 && (
<table className="w-full text-xs">
<table className="w-full text-xs" style={{ tableLayout: "fixed" }}>
<tbody>
{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"
>
<td className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap w-1/3">
<div className="flex items-center gap-1">
<td
className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap overflow-hidden align-top"
style={{ width: 100, maxWidth: 100 }}
>
<div className="flex items-center gap-1 min-w-0">
{col.is_pk && <Key size={9} className="text-accent shrink-0" />}
{col.is_fk && <Key size={9} className="text-amber-400 shrink-0" />}
<span className="truncate">{col.name}</span>
<span
className="text-[10px] text-text-muted/50 shrink-0"
title={col.data_type}
>
{abbreviateType(col.data_type)}
</span>
<DataTypeIcon
dataType={col.data_type}
size={9}
className="text-text-muted/60 shrink-0"
/>
</div>
</td>
<td className="px-3 py-1.5 text-text">
<td className="px-3 py-1.5 text-text align-top break-all">
{isNull ? (
<span className="italic text-text-muted">NULL</span>
) : (
+16 -2
View File
@@ -239,14 +239,21 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</div>
{/* Clean toggle */}
<label className="flex items-center gap-2.5 cursor-pointer group">
<label
className={`flex items-center gap-2.5 cursor-pointer group ${
format === "plain"
? "opacity-40 pointer-events-none"
: ""
}`}
>
<input
type="checkbox"
checked={clean}
onChange={(e) =>
setClean(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
disabled={format === "plain"}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
@@ -255,6 +262,13 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</code>
</span>
</label>
{format === "plain" && (
<p className="text-[11px] text-text-muted/70 -mt-3">
Plain SQL restores run via psql and don't
support DROP-before-CREATE. Use Custom
Archive for clean restores.
</p>
)}
</div>
{/* Destructive confirmation */}
+118
View File
@@ -83,6 +83,34 @@ describe("TabBar", () => {
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("renders a view icon on view tabs", () => {
useDbViewerStore.getState().openTab("main", "order_summary");
useDbViewerStore.setState({
tables: [
{ name: "order_summary", schema: "main", table_type: "VIEW" },
],
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-view")).toBeInTheDocument();
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("renders a layers icon on materialized view tabs", () => {
useDbViewerStore.getState().openTab("public", "mv_products");
useDbViewerStore.setState({
tables: [
{
name: "mv_products",
schema: "public",
table_type: "MATERIALIZED VIEW" as any,
},
],
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-matview")).toBeInTheDocument();
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("shows the changes count as an icon with a badge", () => {
useDbViewerStore.getState().addChange({
type: "update",
@@ -175,4 +203,94 @@ describe("TabBar", () => {
const button = screen.getByRole("button", { name: "Changes queue" });
expect(button.className).toContain("border-amber-500");
});
// ─────────────────────────────────────────────────────────────────────────
// Drag & drop reorder — keep this test LAST in this file.
//
// The vitest config does not enable `globals: true`, so RTL's auto-cleanup
// never unmounts components between tests. A dnd-kit drag leaves its DndContext
// (and document-level listeners) mounted, which silently breaks userEvent/fireEvent
// clicks in any LATER test. The drag itself is fully verified here; placing it
// last isolates the pollution.
// ─────────────────────────────────────────────────────────────────────────
it("reorders tabs via drag and drop (horizontal axis only)", async () => {
const { act, fireEvent } = await import("@testing-library/react");
const store = useDbViewerStore.getState();
store.openTab("public", "users");
store.openTab("public", "posts", true);
store.openTab("public", "comments", true);
// jsdom reports zero-sized rects and non-primary pointers by default,
// which breaks dnd-kit collision detection + pointer activation.
const original = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function () {
const text = this.textContent ?? "";
const index = text.includes("posts")
? 1
: text.includes("comments")
? 2
: 0;
const x = index * 100;
return {
x,
y: 0,
width: 100,
height: 30,
left: x,
right: x + 100,
top: 0,
bottom: 30,
toJSON: () => ({}),
} as DOMRect;
};
try {
render(<TabBar />);
const usersTab = screen.getByRole("tab", { name: "users" });
// pointerDown lifts the tab (distance constraint >= 4px on move), then
// moves it over the last tab and drops.
await act(async () => {
fireEvent.pointerDown(usersTab, {
pointerId: 1,
clientX: 50,
clientY: 15,
button: 0,
isPrimary: true,
});
});
await act(async () => {
fireEvent.pointerMove(usersTab, {
pointerId: 1,
clientX: 160,
clientY: 15,
});
});
await act(async () => {
fireEvent.pointerMove(usersTab, {
pointerId: 1,
clientX: 260,
clientY: 15,
});
});
await act(async () => {
fireEvent.pointerUp(usersTab, {
pointerId: 1,
clientX: 260,
clientY: 15,
});
});
// Flush dnd-kit's post-drag rAF focus-restore so it cannot leak into
// later tests (userEvent clicks are order-sensitive in jsdom).
await act(async () => {});
} finally {
Element.prototype.getBoundingClientRect = original;
}
expect(
useDbViewerStore.getState().tabs.map((t) => t.table),
).toEqual(["posts", "comments", "users"]);
});
});
+162 -44
View File
@@ -1,14 +1,95 @@
import { useEffect, useRef } from "react";
import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useEffect, useRef, type ReactNode } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
horizontalListSortingStrategy,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X } from "lucide-react";
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
function SortableTab({
tab,
isActive,
icon,
onSelect,
onClose,
}: {
tab: ViewerTab;
isActive: boolean;
icon: ReactNode;
onSelect: () => void;
onClose: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform: rawTransform,
transition,
isDragging,
} = useSortable({ id: tab.id });
// dnd-kit scales the dragged item to the width of whichever tab it is
// hovering over (adjustScale). Tabs have different widths, which would warp
// the text — always render at scale 1 and let the horizontal strategy handle
// positioning.
const transform = rawTransform
? { ...rawTransform, scaleX: 1, scaleY: 1 }
: null;
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
{...attributes}
role="tab"
aria-selected={isActive}
aria-label={tab.table}
{...listeners}
onClick={onSelect}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-grab active:cursor-grabbing select-none",
isActive ? "bg-canvas text-text" : "text-text-muted hover:text-text",
isDragging ? "opacity-50 z-10 ring-1 ring-accent" : "",
].join(" ")}
>
<span className="flex-1 text-left select-none">{icon}{tab.table}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label={`Close ${tab.table}`}
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
const tabs = useDbViewerStore((state) => state.tabs);
const tables = useDbViewerStore((state) => state.tables);
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const closeTab = useDbViewerStore((state) => state.closeTab);
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
const openQueryTab = useDbViewerStore((state) => state.openQueryTab);
const reorderTab = useDbViewerStore((state) => state.reorderTab);
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const changesPanelExpanded = useDbViewerStore(
(state) => state.changesPanelExpanded,
@@ -17,6 +98,30 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
(state) => state.toggleChangesPanel,
);
// Drag threshold so a click still selects the tab; a deliberate drag (>= 4px)
// starts a reorder. Keyboard sorting uses arrow keys, one axis only.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Keep the dragged tab on the tab strip: zero out any vertical movement so
// dragging is constrained to the horizontal axis only.
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
...transform,
y: 0,
});
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = tabs.findIndex((t) => t.id === active.id);
const to = tabs.findIndex((t) => t.id === over.id);
if (from >= 0 && to >= 0) reorderTab(from, to);
};
const pendingCount = changesQueue.filter(
(c) => c.status === "pending",
).length;
@@ -50,49 +155,62 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
className="flex flex-1 min-w-0 items-stretch overflow-x-auto"
role="tablist"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
role="tab"
aria-selected={isActive}
onClick={() => setActiveTab(tab.id)}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-pointer",
isActive
? "bg-canvas text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
<span className="flex-1 text-left select-none">
{tab.tabType === "query" ? (
<Terminal
data-testid="tab-icon-query"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={tabs.map((t) => t.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex items-stretch">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const objectType =
tab.tabType === "table"
? tables.find(
(t) =>
t.schema === tab.schema && t.name === tab.table,
)?.table_type
: undefined;
const icon =
tab.tabType === "query" ? (
<Terminal
data-testid="tab-icon-query"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : objectType === "VIEW" ? (
<Eye
data-testid="tab-icon-view"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : objectType === "MATERIALIZED VIEW" ? (
<Layers
data-testid="tab-icon-matview"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : (
<Table2
data-testid="tab-icon-table"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
);
return (
<SortableTab
key={tab.id}
tab={tab}
isActive={isActive}
icon={icon}
onSelect={() => setActiveTab(tab.id)}
onClose={() => closeTab(tab.id)}
/>
) : (
<Table2
data-testid="tab-icon-table"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
)}
{tab.table}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
aria-label={`Close ${tab.table}`}
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
);
})}
</div>
);
})}
</SortableContext>
</DndContext>
</div>
{/* Right: fixed actions */}
@@ -40,6 +40,23 @@ describe("TableTree", () => {
expect(screen.getByText("Materialized View")).toBeInTheDocument();
});
it("shows a distinct icon and label for views", () => {
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [
{
name: "order_summary",
schema: "public",
table_type: "VIEW",
},
],
});
render(<TableTree />);
expect(screen.getByText("order_summary")).toBeInTheDocument();
expect(screen.getByText("View")).toBeInTheDocument();
});
it("opens a tab when table is clicked", async () => {
const user = userEvent.setup();
useDbViewerStore.setState({
+8 -3
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { ChevronRight, ChevronDown, Table2, Layers, Key, Type } from "lucide-react";
import { ChevronRight, ChevronDown, Table2, Layers, Eye, Key, Type } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { TableOverflowMenu } from "./TableOverflowMenu";
@@ -71,8 +71,13 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
const isExpanded = expanded.has(key);
const cols = columnCache[key] ?? table.columns ?? [];
const isMatView = table.table_type === "MATERIALIZED VIEW";
const TypeIcon = isMatView ? Layers : Table2;
const typeLabel = isMatView ? "Materialized View" : null;
const isView = table.table_type === "VIEW";
const TypeIcon = isMatView ? Layers : isView ? Eye : Table2;
const typeLabel = isMatView
? "Materialized View"
: isView
? "View"
: null;
return (
<div key={key}>
<div
+83 -19
View File
@@ -13,11 +13,17 @@ describe("CellEditor", () => {
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("Alicia");
});
it("commits null when the setNull flag is toggled", () => {
it("commits null when a nullable cell is emptied", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
const nullCheckbox = screen.getByLabelText(/set null/i);
fireEvent.click(nullCheckbox);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("leaves an empty nullable cell alone when it was already empty (NULL stays NULL)", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith(null);
});
@@ -31,6 +37,79 @@ describe("CellEditor", () => {
render(<CellEditor initialValue="{}" dataType="jsonb" onCommit={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
});
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).not.toHaveBeenCalled();
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
});
it("commits an empty string on non-nullable text columns", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("");
});
it("clears the validation error as soon as the user types again", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "5" } });
expect(screen.queryByTestId("cell-editor-error")).toBeNull();
});
it("does not offer a NULL row in the FK dropdown for non-nullable FK columns", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]}
onCommit={onCommit}
onCancel={vi.fn()}
/>
);
const buttons = screen.getAllByRole("button");
expect(buttons.some((b) => b.textContent === "NULL")).toBe(false);
});
it("offers a NULL row in the FK dropdown for nullable FK columns", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
const nullRow = screen.getAllByRole("button").find((b) => b.textContent === "NULL");
expect(nullRow).toBeDefined();
fireEvent.click(nullRow!);
expect(onCommit).toHaveBeenCalledWith(null);
});
it("commits null when the NULL option is selected in enum mode", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="active"
dataType="text"
enumValues={["active", "inactive", "pending"]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
fireEvent.change(screen.getByRole("combobox"), { target: { value: "" } });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("renders a combobox with enum values and commits on change", () => {
const onCommit = vi.fn();
render(
@@ -49,21 +128,6 @@ describe("CellEditor", () => {
fireEvent.change(select, { target: { value: "pending" } });
expect(onCommit).toHaveBeenCalledWith("pending");
});
it("commits null via Set NULL in enum mode", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="active"
dataType="text"
enumValues={["active", "inactive", "pending"]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
fireEvent.click(screen.getByLabelText(/set null/i));
expect(onCommit).toHaveBeenCalledWith(null);
});
it("filters FK options by query and commits the clicked value", () => {
const onCommit = vi.fn();
render(
@@ -105,7 +169,7 @@ describe("CellEditor", () => {
render(<CellEditor initialValue="long text" dataType="text" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
expect(input.tagName).toBe("TEXTAREA");
expect(input.className).toContain("h-6");
expect(input.className).toContain("overflow-y-auto");
});
it("renders the FK placeholder and a No matches empty state", () => {
+86 -49
View File
@@ -23,7 +23,12 @@ interface CellEditorProps {
}
const inputClass =
"w-full px-1 py-0.5 text-xs bg-surface border border-border rounded font-mono";
"min-w-0 flex-1 bg-transparent px-3 font-heading text-xs text-text outline-none placeholder:text-text-muted";
const controlClass =
"min-w-0 flex-1 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none placeholder:text-text-muted";
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
const TEXT_LIKE = ["char", "text", "uuid", "bit"];
export function CellEditor({
initialValue,
@@ -36,8 +41,9 @@ export function CellEditor({
onCancel,
}: CellEditorProps) {
const [value, setValue] = useState(initialValue);
const [setNull, setSetNull] = useState(initialValue === "" && nullable);
const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const enumRef = useRef<HTMLSelectElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
@@ -47,6 +53,8 @@ export function CellEditor({
width: number;
} | null>(null);
const textLike = TEXT_LIKE.some((t) => dataType.toLowerCase().includes(t));
useLayoutEffect(() => {
if (fkOptions && fkOptions.length > 0 && searchRef.current) {
const r = searchRef.current.getBoundingClientRect();
@@ -80,22 +88,59 @@ export function CellEditor({
);
const Tag = large ? "textarea" : "input";
const commit = () => onCommit(setNull ? null : value);
const handleSetNull = (checked: boolean) => {
setSetNull(checked);
if (checked) onCommit(null);
/**
* Constraint-aware commit resolution:
* - Empty input on a nullable column NULL (smart "clear = null").
* - Empty input on a NOT NULL column only text-ish types may fall back to
* an empty string; everything else is blocked with an error.
*/
const resolveCommit = (
raw: string,
): { value: string | null } | { error: string } => {
if (raw.trim() === "") {
if (nullable) return { value: null };
if (textLike) return { value: raw };
return { error: "This column cannot be NULL" };
}
return { value: raw };
};
const commitRaw = (raw: string) => {
const r = resolveCommit(raw);
if ("error" in r) {
setError(r.error);
return;
}
setError(null);
onCommit(r.value);
};
const commit = () => commitRaw(value);
const errorRect = error ? rootRef.current?.getBoundingClientRect() : null;
const errorBubble =
error && errorRect
? createPortal(
<div
data-testid="cell-editor-error"
className="fixed z-50 pointer-events-none rounded-md border border-red-500/50 bg-red-950/95 px-2 py-1 text-[10px] text-red-300 shadow-lg"
style={{ top: errorRect.bottom + 4, left: errorRect.left, maxWidth: 320 }}
>
{error}
</div>,
document.body,
)
: null;
// Priority: enum > FK > default input/textarea
if (enumValues && enumValues.length > 0) {
return (
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
<select
ref={enumRef}
className={inputClass}
className={controlClass}
value={initialValue}
onChange={(e) => onCommit(setNull ? null : e.target.value)}
onChange={(e) => commitRaw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
@@ -110,16 +155,7 @@ export function CellEditor({
</option>
))}
</select>
{nullable && (
<label className="flex items-center gap-1 text-[10px] text-text-muted">
<input
type="checkbox"
checked={setNull}
onChange={(e) => handleSetNull(e.target.checked)}
/>
Set NULL
</label>
)}
{errorBubble}
</div>
);
}
@@ -139,19 +175,22 @@ export function CellEditor({
? fkOptions
: fkOptions.filter((o) => fkSearchText(o).includes(q));
return (
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
<input
ref={searchRef}
aria-label="Search foreign key options"
placeholder={fkPlaceholder ?? "Search…"}
className={inputClass}
className={controlClass}
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => {
setQuery(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (filtered.length > 0) onCommit(filtered[0].value);
else onCommit(query);
if (filtered.length > 0) commitRaw(filtered[0].value);
else commitRaw(query);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
@@ -172,6 +211,15 @@ export function CellEditor({
}}
className="max-h-28 overflow-y-auto bg-surface border border-border rounded-lg shadow-xl"
>
{nullable && (
<button
type="button"
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left italic text-text-muted"
onClick={() => commitRaw("")}
>
NULL
</button>
)}
{filtered.length === 0 && (
<div className="px-2 py-1 text-xs text-text-muted">No matches</div>
)}
@@ -180,7 +228,7 @@ export function CellEditor({
key={o.value}
type="button"
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left"
onClick={() => onCommit(o.value)}
onClick={() => commitRaw(o.value)}
>
{o.cells && o.cells.length > 0 ? (
<span className="flex items-center gap-0 min-w-0">
@@ -205,30 +253,28 @@ export function CellEditor({
</div>,
document.body,
)}
{nullable && (
<label className="flex items-center gap-1 text-[10px] text-text-muted">
<input
type="checkbox"
checked={setNull}
onChange={(e) => handleSetNull(e.target.checked)}
/>
Set NULL
</label>
)}
{errorBubble}
</div>
);
}
const cls = large ? `${inputClass} h-6 resize-none overflow-y-auto leading-none` : inputClass;
const cls = large
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
: inputClass;
return (
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
<div
ref={rootRef}
data-testid="cell-editor"
className={`flex h-full w-full items-center gap-2 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}
>
<Tag
ref={ref as any}
className={cls}
placeholder={nullable && value === "" ? "NULL" : undefined}
value={value}
onChange={(e) => {
setValue(e.target.value);
setSetNull(false);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -240,16 +286,7 @@ export function CellEditor({
}
}}
/>
{nullable && (
<label className="flex items-center gap-1 text-[10px] text-text-muted">
<input
type="checkbox"
checked={setNull}
onChange={(e) => handleSetNull(e.target.checked)}
/>
Set NULL
</label>
)}
{errorBubble}
</div>
);
}
+8 -8
View File
@@ -48,24 +48,24 @@ describe("VirtualDataGrid", () => {
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
it("shows staged values passed from the parent + a pending dot", () => {
it("shows staged values passed from the parent + a pending outline", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{ "0:name": true }} />);
expect(screen.getByText("Alicia")).toBeInTheDocument();
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
expect(screen.getByTestId("pending-cell")).toBeInTheDocument();
});
it("pending dot requires pendingKeys even when a staged value exists (committed → no dot)", () => {
it("pending outline requires pendingKeys even when a staged value exists (committed → no outline)", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{}} />);
expect(screen.getByText("Alicia")).toBeInTheDocument();
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
expect(screen.queryByTestId("pending-cell")).toBeNull();
});
it("clears staged values when the stagedValues prop empties (Clear All)", () => {
@@ -393,7 +393,7 @@ describe("VirtualDataGrid", () => {
expect(screen.getByText("id")).toBeInTheDocument();
});
it("renders a pending-edit dot on the pending cell", () => {
it("renders a pending outline on the pending cell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
@@ -416,10 +416,10 @@ describe("VirtualDataGrid", () => {
/>,
);
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
expect(screen.getByTestId("pending-cell")).toBeInTheDocument();
});
it("does not render a pending-edit dot without pendingCell", () => {
it("does not render a pending outline without pendingCell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
@@ -441,7 +441,7 @@ describe("VirtualDataGrid", () => {
/>,
);
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
expect(screen.queryByTestId("pending-cell")).toBeNull();
});
// ── GRID-A: context menu + editing behavior ─────────────────────────
+13 -8
View File
@@ -43,7 +43,7 @@ interface VirtualDataGridProps {
fkPlaceholders?: Record<string, string>;
/** Optimistic staged cell values keyed `${rowIndex}:${colName}` → value (null = NULL), from the changes queue. */
stagedValues?: Record<string, string | null>;
/** Keys of cells with a PENDING (not yet committed) update → drives the amber dot. */
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
pendingKeys?: Record<string, boolean>;
}
@@ -297,6 +297,12 @@ export function VirtualDataGrid({
};
const commitEdit = (committed: string | null) => {
// Constraint guard: never stage NULL on a NOT NULL column
// (the editor blocks this with UX; this is defense in depth)
if (committed === null && col.is_nullable === false) {
setEditingCell(null);
return;
}
// oldData must be the DB value (the un-staged cell), so the queue's
// revert/display stays correct even after repeated edits of the same cell.
const dbValue = ci >= 0 ? row[ci] : undefined;
@@ -326,10 +332,13 @@ export function VirtualDataGrid({
return (
<div
key={col.name}
data-testid={isPending ? "pending-cell" : undefined}
className={`relative px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""} ${
isActive ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
isActive && !isEditing ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
} ${isEditing ? "outline outline-2 outline-amber-400 outline-offset-[-2px]" : ""} ${
isPending && !isEditing ? "animate-pending-ring" : ""
}`}
role={isJson ? "button" : undefined}
tabIndex={isJson ? 0 : -1}
@@ -408,12 +417,6 @@ export function VirtualDataGrid({
) : (
String(displayCell)
)}
{isPending && (
<span
className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400"
data-testid="pending-edit-dot"
/>
)}
</div>
);
},
@@ -438,6 +441,8 @@ export function VirtualDataGrid({
(row: number, col: number) => {
const column = visibleColumns[col];
if (!column || !isCellEditable(column, tabType, dbType, readOnly)) return;
// NOT NULL columns can't be nulled (matches the context-menu gating)
if (!column.is_nullable) return;
const ci = columns.findIndex((c) => c.name === column.name);
const value = rows[row]?.[ci];
if (value === null || value === undefined) return;
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import {
Banknote,
Binary,
Braces,
CalendarClock,
Clock,
FileCode2,
Fingerprint,
Globe,
Hash,
Layers,
ListChecks,
Shapes,
ToggleLeft,
Type,
} from "lucide-react";
import { DataTypeIcon, getDataTypeIcon } from "./DataTypeIcon";
describe("getDataTypeIcon", () => {
it("maps numeric types to Hash", () => {
expect(getDataTypeIcon("integer")).toBe(Hash);
expect(getDataTypeIcon("bigint")).toBe(Hash);
expect(getDataTypeIcon("numeric(10,2)")).toBe(Hash);
expect(getDataTypeIcon("double precision")).toBe(Hash);
expect(getDataTypeIcon("REAL")).toBe(Hash);
});
it("maps money to Banknote", () => {
expect(getDataTypeIcon("money")).toBe(Banknote);
});
it("maps text types to Type", () => {
expect(getDataTypeIcon("character varying")).toBe(Type);
expect(getDataTypeIcon("text")).toBe(Type);
expect(getDataTypeIcon("TEXT")).toBe(Type);
expect(getDataTypeIcon("citext")).toBe(Type);
});
it("maps booleans to ToggleLeft", () => {
expect(getDataTypeIcon("boolean")).toBe(ToggleLeft);
expect(getDataTypeIcon("bool")).toBe(ToggleLeft);
});
it("maps JSON types to Braces", () => {
expect(getDataTypeIcon("json")).toBe(Braces);
expect(getDataTypeIcon("jsonb")).toBe(Braces);
});
it("maps binary types to Binary", () => {
expect(getDataTypeIcon("bytea")).toBe(Binary);
expect(getDataTypeIcon("BLOB")).toBe(Binary);
expect(getDataTypeIcon("bit varying")).toBe(Binary);
});
it("maps date/time types to CalendarClock or Clock", () => {
expect(getDataTypeIcon("timestamp")).toBe(CalendarClock);
expect(getDataTypeIcon("timestamp with time zone")).toBe(CalendarClock);
expect(getDataTypeIcon("date")).toBe(Clock);
expect(getDataTypeIcon("time without time zone")).toBe(Clock);
expect(getDataTypeIcon("interval")).toBe(Clock);
});
it("maps uuid to Fingerprint", () => {
expect(getDataTypeIcon("uuid")).toBe(Fingerprint);
});
it("maps array types to Layers", () => {
expect(getDataTypeIcon("integer[]")).toBe(Layers);
expect(getDataTypeIcon("text[]")).toBe(Layers);
});
it("maps enums, xml, network and geometric types", () => {
expect(getDataTypeIcon("enum('a','b')")).toBe(ListChecks);
expect(getDataTypeIcon("xml")).toBe(FileCode2);
expect(getDataTypeIcon("inet")).toBe(Globe);
expect(getDataTypeIcon("point")).toBe(Shapes);
});
it("falls back to Type for unknown/custom types", () => {
expect(getDataTypeIcon("mood")).toBe(Type);
});
});
describe("DataTypeIcon", () => {
it("renders the mapped icon with a title tooltip", () => {
const { container } = render(<DataTypeIcon dataType="integer" />);
const svg = container.querySelector("svg");
expect(svg).not.toBeNull();
expect(svg!.getAttribute("class")).toContain("lucide-hash");
// The tooltip lives on the wrapper span (React SVG types omit `title`).
expect(container.querySelector("span")!.getAttribute("title")).toBe("integer");
});
it("honors a custom title override", () => {
const { container } = render(
<DataTypeIcon dataType="integer" title="User ID (int)" />,
);
expect(container.querySelector("span")!.getAttribute("title")).toBe(
"User ID (int)",
);
});
it("applies size and className", () => {
const { container } = render(
<DataTypeIcon dataType="jsonb" size={9} className="text-text-muted/60" />,
);
const svg = container.querySelector("svg")!;
expect(svg.getAttribute("width")).toBe("9");
expect(container.querySelector("span")!.getAttribute("class")).toContain(
"text-text-muted/60",
);
expect(svg.getAttribute("class")).toContain("lucide-braces");
});
});
+66
View File
@@ -0,0 +1,66 @@
import { memo } from "react";
import {
Banknote,
Binary,
Braces,
CalendarClock,
Clock,
FileCode2,
Fingerprint,
Globe,
Hash,
Layers,
ListChecks,
Shapes,
ToggleLeft,
Type,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
/**
* Map a DB column data type (PostgreSQL / SQLite / MySQL strings) to a
* representative lucide icon. Best-effort by string matching unknown and
* custom types fall back to the generic `Type` icon.
*/
export function getDataTypeIcon(dataType: string): LucideIcon {
const t = dataType.toLowerCase().trim();
if (t === "money") return Banknote; // money — numeric, but deserves its own icon
if (/json/.test(t)) return Braces; // json, jsonb
if (t.endsWith("[]")) return Layers; // array types: integer[], text[], ...
if (/uuid/.test(t)) return Fingerprint; // uuid
if (/bool/.test(t)) return ToggleLeft; // boolean, bool
if (/bytea|blob|varbinary|^binary|bit/.test(t)) return Binary; // bytea, blob, binary, bit
if (/timestamp|datetime/.test(t)) return CalendarClock; // timestamp, timestamptz, datetime
if (/^date\b|^time\b|interval/.test(t)) return Clock; // date, time, interval
if (/smallint|integer|bigint|serial|numeric|decimal|real|float|double|int2|int4|int8|number/.test(t))
return Hash; // numeric types
if (/enum/.test(t)) return ListChecks; // mysql ENUM(...)
if (/xml/.test(t)) return FileCode2; // xml
if (/inet|cidr|macaddr/.test(t)) return Globe; // network types
if (/point|line|lseg|box|path|polygon|circle/.test(t)) return Shapes; // geometric types
return Type; // text, character varying, and unknown/custom types
}
interface DataTypeIconProps {
dataType: string;
size?: number;
className?: string;
/** Tooltip / accessible label. Defaults to the raw data type string. */
title?: string;
}
/** A compact icon representing a DB column's data type, with a tooltip. */
export const DataTypeIcon = memo(function DataTypeIcon({
dataType,
size = 12,
className,
title = dataType,
}: DataTypeIconProps) {
const Icon = getDataTypeIcon(dataType);
return (
<span title={title} className={className}>
<Icon size={size} aria-hidden="true" />
</span>
);
});
+6
View File
@@ -19,11 +19,17 @@
--font-family-sans: "Outfit", sans-serif;
--animate-toolbar-pulse: toolbar-pulse 1.6s ease-in-out infinite;
--animate-pending-ring: pending-ring 1.6s ease-in-out infinite;
@keyframes toolbar-pulse {
0%, 100% { opacity: 0; }
50% { opacity: 0.12; }
}
@keyframes pending-ring {
0%, 100% { box-shadow: inset 0 0 0 2px rgba(249, 115, 22, 0.9); }
50% { box-shadow: inset 0 0 0 2px rgba(249, 115, 22, 0.12); }
}
}
:root {
+7 -5
View File
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
import agents from "../../AGENTS.md?raw";
import readme from "../../README.md?raw";
describe("v0.5.0 docs coverage", () => {
describe("v0.6.0 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,12 +24,14 @@ describe("v0.5.0 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
});
it("README declares v0.5.0", () => {
expect(readme).toContain("0.5.0");
it("README declares v0.6.0", () => {
expect(readme).toContain("0.6.0");
});
it("README marks inline editing complete (not Upcoming)", () => {
// Gridline's comparison-table cell carries the ✅ marker
expect(readme).toMatch(/Inline cell editing \| ✅ \| ✅ \| \*\*/);
// Key Features lists inline editing as a shipped feature
expect(readme).toMatch(/- \*\*Inline cell editing\*\*/);
// comparison-table row for the stage→commit queue carries the ✅ marker
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*❌[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
});
});
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json";
describe("version", () => {
it("declares v0.5.0 across the app shell", () => {
expect(pkg.version).toBe("0.5.0");
it("declares v0.6.0 across the app shell", () => {
expect(pkg.version).toBe("0.6.0");
});
});
+33
View File
@@ -90,6 +90,39 @@ describe("dbViewerStore", () => {
expect(st.activeTabId).toBe(st.tabs[0].id);
});
it("reorderTab moves a tab and keeps the active tab by id", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
useDbViewerStore.getState().openTab("public", "comments", true);
const before = useDbViewerStore.getState().tabs;
const activeId = useDbViewerStore.getState().activeTabId;
const activeTable = before.find((t) => t.id === activeId)!.table;
useDbViewerStore.getState().reorderTab(0, 2);
const after = useDbViewerStore.getState().tabs;
expect(after.map((t) => t.table)).toEqual(["posts", "comments", "users"]);
// Active tab must follow the moved item (tracked by id, not index).
expect(useDbViewerStore.getState().activeTabId).toBe(activeId);
expect(after.find((t) => t.id === activeId)!.table).toBe(activeTable);
});
it("reorderTab ignores out-of-range and no-op moves", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
const reorderTab = useDbViewerStore.getState().reorderTab;
reorderTab(0, 0);
expect(useDbViewerStore.getState().tabs.map((t) => t.table)).toEqual([
"users",
"posts",
]);
reorderTab(-1, 1);
reorderTab(0, 5);
expect(useDbViewerStore.getState().tabs.map((t) => t.table)).toEqual([
"users",
"posts",
]);
});
it("setPage updates pagination", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
+20
View File
@@ -104,6 +104,7 @@ interface DbViewerState {
openQueryTab: () => void;
setDefaultPageSize: (size: number) => void;
closeTab: (tabId: string) => void;
reorderTab: (fromIndex: number, toIndex: number) => void;
closeTabsForTable: (schema: string, table: string) => void;
setActiveTab: (tabId: string) => void;
setPage: (tabId: string, page: number) => void;
@@ -244,6 +245,25 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
set({ tabs: remaining, activeTabId: newActiveId });
},
// Move a tab to a new index (drag-to-reorder). The active tab is tracked by
// id, so it follows the moved tab automatically.
reorderTab: (fromIndex, toIndex) => {
const { tabs } = get();
if (
fromIndex < 0 ||
fromIndex >= tabs.length ||
toIndex < 0 ||
toIndex >= tabs.length ||
fromIndex === toIndex
) {
return;
}
const next = [...tabs];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
set({ tabs: next });
},
closeTabsForTable: (schema, table) => {
const { tabs, activeTabId } = get();
const remaining = tabs.filter(