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
@@ -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 }}
|
||||
@@ -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) | ❌ | |
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
| :--- | :--- | :--- |
|
||||
| **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 |
|
||||
| :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------- |
|
||||
| **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](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 |
|
||||
| **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 |
|
||||
| :--- | :--- | :--- |
|
||||
| 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,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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 929 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 662 KiB |
|
Before Width: | Height: | Size: 892 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 586 KiB After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 162 KiB |
@@ -1783,7 +1783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridline"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
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(¶ms, &options);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pg_restore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pg_restore(
|
||||
connection_id: String,
|
||||
@@ -173,10 +351,7 @@ pub async fn pg_restore(
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let conn = {
|
||||
let store = state
|
||||
.db_store
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
connections
|
||||
.into_iter()
|
||||
@@ -184,105 +359,30 @@ pub async fn pg_restore(
|
||||
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
|
||||
};
|
||||
|
||||
let password = crate::commands::keychain::get_connection_password_internal(
|
||||
&app_handle,
|
||||
&connection_id,
|
||||
)
|
||||
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(¶ms, &options);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// db_sync (pg_dump | pg_restore via Unix pipe)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_sync(
|
||||
options: SyncOptions,
|
||||
@@ -293,10 +393,7 @@ pub async fn db_sync(
|
||||
|
||||
// Get both connections from store
|
||||
let (source_conn, target_conn) = {
|
||||
let store = state
|
||||
.db_store
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
|
||||
let src = connections
|
||||
@@ -325,190 +422,52 @@ pub async fn db_sync(
|
||||
};
|
||||
|
||||
// Get passwords
|
||||
let src_password = crate::commands::keychain::get_connection_password_internal(
|
||||
&app_handle,
|
||||
&source_conn.id,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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
|
||||
let source = PgConnParams::new(
|
||||
source_conn.host.clone(),
|
||||
source_conn.port.unwrap_or(5432),
|
||||
source_conn
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into());
|
||||
let src_database = source_conn
|
||||
.unwrap_or_else(|| "postgres".into()),
|
||||
source_conn
|
||||
.database
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into());
|
||||
.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
|
||||
let target = PgConnParams::new(
|
||||
target_conn.host.clone(),
|
||||
target_conn.port.unwrap_or(5432),
|
||||
target_conn
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into());
|
||||
let tgt_database = target_conn
|
||||
.unwrap_or_else(|| "postgres".into()),
|
||||
target_conn
|
||||
.database
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into());
|
||||
.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)
|
||||
|
||||
@@ -186,3 +186,209 @@ fn backup_progress_event_failed() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¶m_refs)
|
||||
.await
|
||||
@@ -1140,6 +1161,18 @@ pub async fn get_table_data(
|
||||
};
|
||||
let total_rows: i64 = count_row.get(0);
|
||||
|
||||
// Views expose no `ctid`; detect them so no row-locator is appended
|
||||
// to the data SELECT and columns stay read-only.
|
||||
let is_view: bool = client
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.tables \
|
||||
WHERE table_schema = $1 AND table_name = $2 AND table_type = 'VIEW')",
|
||||
&[&schema, &table],
|
||||
)
|
||||
.await
|
||||
.map(|r| r.get::<_, bool>(0))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Get column info with FK detection and enum type names
|
||||
let col_query = r#"SELECT
|
||||
c.column_name,
|
||||
@@ -1203,12 +1236,9 @@ ORDER BY c.ordinal_position"#;
|
||||
// pg_attribute.attgenerated/attidentity are PG's internal
|
||||
// "char" type (OID 18) → tokio-postgres delivers i8, not
|
||||
// String; deserializing as String panics. Convert safely.
|
||||
let attgenerated = pg_char_to_att(
|
||||
r.try_get::<_, Option<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, ¶m_refs)
|
||||
.await
|
||||
@@ -1290,18 +1354,35 @@ 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))
|
||||
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())?
|
||||
};
|
||||
|
||||
@@ -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,7 +1999,8 @@ 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() {
|
||||
let contype =
|
||||
match r.get::<_, Option<String>>(3).unwrap_or_default().as_str() {
|
||||
"c" => "CHECK",
|
||||
"u" => "UNIQUE",
|
||||
"x" => "EXCLUSION",
|
||||
@@ -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,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,316 +152,10 @@ 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)]
|
||||
|
||||
@@ -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,9 +368,11 @@ 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();
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
PRAGMA user_version = 3;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Gridline Demo (SQLite) — realistic e-commerce dataset
|
||||
--
|
||||
-- Designed to exercise every SQLite-aware Gridline feature:
|
||||
-- • PK/FK/composite-PK/self-FK metadata
|
||||
-- • JSON cells + JSON popover
|
||||
-- • BLOBs
|
||||
-- • CHECK / UNIQUE constraints, defaults
|
||||
-- • indexes
|
||||
-- • views
|
||||
-- • an empty table (Empty Table change demo)
|
||||
-- • a no-PK table (rowid row-locator editing)
|
||||
-- • a TEXT primary key
|
||||
-- • a 500-row table for pagination / virtualization / filtering
|
||||
-- • nullable columns, long text, smart-sort tiers
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── Core: users ─────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('admin','moderator','customer','guest')),
|
||||
phone TEXT,
|
||||
birth_date TEXT,
|
||||
bio TEXT,
|
||||
preferences json,
|
||||
balance REAL NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_login_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: categories (self-referencing FK) ──────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES categories(id),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: products ──────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sku TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price REAL NOT NULL CHECK (price >= 0),
|
||||
category_id INTEGER REFERENCES categories(id),
|
||||
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
|
||||
rating REAL CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)),
|
||||
discontinued INTEGER NOT NULL DEFAULT 0,
|
||||
attributes json,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: addresses (1:N from users, FK dropdown editor) ────────────────
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
label TEXT NOT NULL DEFAULT 'Home',
|
||||
street TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
state TEXT,
|
||||
zip TEXT,
|
||||
country TEXT NOT NULL DEFAULT 'USA',
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Core: orders (FKs -> users and addresses; CHECK status) ─────────────
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
shipping_address_id INTEGER REFERENCES addresses(id),
|
||||
total REAL NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled','refunded')),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
shipped_at TEXT
|
||||
);
|
||||
|
||||
-- ── Core: order_items (composite PRIMARY KEY; CHECK) ───────────────────
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
unit_price REAL NOT NULL,
|
||||
PRIMARY KEY (order_id, product_id)
|
||||
);
|
||||
|
||||
-- ── Big table for pagination / virtualization / filtering (500 rows) ──
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
severity TEXT NOT NULL DEFAULT 'info'
|
||||
CHECK (severity IN ('info','warning','error','critical')),
|
||||
details json,
|
||||
duration_ms INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── BLOB demo ───────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
content BLOB,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
uploaded_by INTEGER REFERENCES users(id),
|
||||
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── No primary key on purpose: exercises the rowid row-locator editing ──
|
||||
CREATE TABLE IF NOT EXISTS page_views (
|
||||
url TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
viewed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Non-integer (TEXT) primary key ─────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Deliberately empty: exercises the Empty Table change + empty state ──
|
||||
CREATE TABLE IF NOT EXISTS marketing_campaigns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
budget REAL,
|
||||
starts_at TEXT,
|
||||
ends_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft','active','paused','completed','cancelled'))
|
||||
);
|
||||
|
||||
-- ── Read-only view for the query editor / ER diagram ───────────────────
|
||||
CREATE VIEW IF NOT EXISTS order_summary AS
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
u.name AS customer_name,
|
||||
COUNT(oi.product_id) AS item_count,
|
||||
o.total AS order_total,
|
||||
o.status,
|
||||
o.created_at
|
||||
FROM orders o
|
||||
JOIN users u ON u.id = o.user_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id
|
||||
GROUP BY o.id, u.name, o.total, o.status, o.created_at;
|
||||
|
||||
-- ── Indexes (surface in the copied DDL) ─────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Seed data
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── Categories ──────────────────────────────────────────────────────────
|
||||
INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES
|
||||
(1, 'Electronics', NULL, 'electronics', 1, 'Computers, phones, and peripherals.', datetime('now','-400 days')),
|
||||
(2, 'Accessories', NULL, 'accessories', 2, 'Cables, cases, hubs, and add-ons.', datetime('now','-400 days')),
|
||||
(3, 'Office', NULL, 'office', 3, 'Furniture, lighting, and desk essentials.', datetime('now','-400 days')),
|
||||
(4, 'Furniture', NULL, 'furniture', 4, 'Chairs, desks, shelves, and storage.', datetime('now','-380 days')),
|
||||
(5, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-300 days')),
|
||||
(6, 'Monitors', 1, 'monitors', 2, 'External displays and monitor arms.', datetime('now','-300 days'));
|
||||
|
||||
-- ── Users: a realistic mix of admins, moderators, customers, and guests ───
|
||||
INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES
|
||||
(1, 'Sarah Chen', 'sarah.chen@acme.dev', 'admin', '+1-415-555-0101', '1990-04-12',
|
||||
'Founding engineer at Acme Dev. Manages the catalog, reviews flagged orders, and keeps the demo data looking sharp.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
1240.75, 1, datetime('now','-35 minutes'), datetime('now','-540 days'), datetime('now','-35 minutes')),
|
||||
(2, 'Marcus Johnson', 'marcus.j@pixelforge.studio', 'moderator', '+1-512-555-0192', '1985-11-30',
|
||||
'Customer-success lead and part-time photographer. Moderates reviews, handles refunds, and tracks the weekly audit log.',
|
||||
'{"theme":"system","notifications":{"email":true,"push":false},"locale":"en-GB","timezone":"America/Chicago"}',
|
||||
48.20, 1, datetime('now','-6 hours'), datetime('now','-510 days'), datetime('now','-6 hours')),
|
||||
(3, 'Emily Rodriguez', 'emily.r@techbloom.io', 'customer', '+1-206-555-0143', '1993-07-19',
|
||||
'Remote frontend developer based in Seattle. Buys desk accessories in bulk and leaves detailed product reviews.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
315.50, 1, datetime('now','-2 days'), datetime('now','-480 days'), datetime('now','-2 days')),
|
||||
(4, 'David Kim', 'david.kim@outlook.com', 'customer', '+1-212-555-0178', '1988-03-04',
|
||||
'Freelance sound engineer in New York. Orders AV gear and cables regularly for his home studio.',
|
||||
'{"theme":"dark","notifications":{"email":false,"push":true},"locale":"ko-KR","timezone":"America/New_York"}',
|
||||
0.00, 1, datetime('now','-4 days'), datetime('now','-450 days'), datetime('now','-4 days')),
|
||||
(5, 'Aisha Patel', 'aisha.patel@horizonlabs.co', 'customer', '+1-303-555-0165', '1996-02-08',
|
||||
'Data analyst and ergonomic-chair evangelist. Currently outfitting a standing-desk setup for her team.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Denver"}',
|
||||
892.10, 1, datetime('now','-12 hours'), datetime('now','-420 days'), datetime('now','-12 hours')),
|
||||
(6, 'James O’Brien', 'james.obrien@fastmail.com', 'customer', '+1-617-555-0134', '1991-09-23',
|
||||
'DevOps contractor. Bought a monitor arm and never looked back. Always asks for gift receipts.',
|
||||
NULL,
|
||||
67.99, 1, datetime('now','-1 day'), datetime('now','-390 days'), datetime('now','-1 day')),
|
||||
(7, 'Yuki Tanaka', 'yuki.tanaka@me.com', 'customer', '+1-650-555-0189', '1994-05-17',
|
||||
'Product designer at a fintech startup. Switches between light and dark mode depending on the weather.',
|
||||
'{"theme":"system","notifications":{"email":true,"push":true},"locale":"ja-JP","timezone":"America/Los_Angeles"}',
|
||||
150.00, 0, datetime('now','-28 days'), datetime('now','-360 days'), datetime('now','-28 days')),
|
||||
(8, 'Olivia Müller', 'olivia.mueller@werkstatt.de', 'customer', '+49-30-555-0921', '1987-12-01',
|
||||
'Berlin-based architect. Orders office lamps and cable organizers for her co-working space.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"de-DE","timezone":"Europe/Berlin"}',
|
||||
540.30, 1, datetime('now','-5 days'), datetime('now','-330 days'), datetime('now','-5 days')),
|
||||
(9, 'Carlos Rivera', 'carlos.rivera@openmail.net', 'customer', '+1-305-555-0156', '1992-08-30',
|
||||
'Miami-based content creator. Replaced his entire streaming setup through the store last quarter.',
|
||||
'{"theme":"dark","notifications":{"email":false,"push":false},"locale":"es-MX","timezone":"America/New_York"}',
|
||||
28.50, 1, datetime('now','-3 hours'), datetime('now','-300 days'), datetime('now','-3 hours')),
|
||||
(10, 'Priya Sharma', 'priya.sharma@nimbus.team', 'customer', '+1-408-555-0127', '1995-01-14',
|
||||
'Engineering manager at Nimbus. Buys team gear in batches and expects invoices by email.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-IN","timezone":"America/Los_Angeles"}',
|
||||
2100.00, 1, datetime('now','-50 minutes'), datetime('now','-270 days'), datetime('now','-50 minutes')),
|
||||
(11, 'Liam Thompson', 'liam.t@rivermail.com', 'guest', NULL, '1999-06-05',
|
||||
'University student window-shopping for a first mechanical keyboard. Has not completed a purchase yet.',
|
||||
'{"theme":"system","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/New_York"}',
|
||||
0.00, 1, datetime('now','-7 days'), datetime('now','-240 days'), datetime('now','-7 days')),
|
||||
(12, 'Sofia Andersen', 'sofia.andersen@nordic.design', 'customer', '+46-8-555-0733', '1989-10-28',
|
||||
'Scandinavian design consultant. Values minimal packaging and fast, trackable shipping.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"sv-SE","timezone":"Europe/Stockholm"}',
|
||||
175.80, 1, datetime('now','-10 hours'), datetime('now','-210 days'), datetime('now','-10 hours')),
|
||||
(13, 'Benjamin Wright', 'ben.wright@compose.ly', 'customer', '+1-503-555-0198', '1993-04-11',
|
||||
'Music producer and software tinkerer. Always adds a USB-C cable to every order just in case.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
430.25, 1, datetime('now','-18 hours'), datetime('now','-180 days'), datetime('now','-18 hours')),
|
||||
(14, 'Hannah Lee', 'hannah.lee@cloudscale.io', 'admin', '+1-206-555-0204', '1990-07-22',
|
||||
'Platform reliability lead. Monitors the audit log and keeps the demo app settings up to date.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
3500.00, 1, datetime('now','-15 minutes'), datetime('now','-150 days'), datetime('now','-15 minutes')),
|
||||
(15, 'Noah Fischer', 'noah.fischer@bytehaus.at', 'customer', '+43-1-555-0817', '1997-03-15',
|
||||
'Vienna-based indie game dev. Needs low-latency peripherals and a quiet mechanical keyboard.',
|
||||
'{"theme":"system","notifications":{"email":false,"push":true},"locale":"de-AT","timezone":"Europe/Vienna"}',
|
||||
95.00, 1, datetime('now','-2 days'), datetime('now','-120 days'), datetime('now','-2 days')),
|
||||
(16, 'Grace Okafor', 'grace.okafor@lift.ng', 'customer', '+234-1-555-0294', '1986-11-09',
|
||||
'Remote team lead in Lagos. Orders standing desks and monitor arms for distributed teammates.',
|
||||
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-NG","timezone":"Africa/Lagos"}',
|
||||
1280.00, 1, datetime('now','-9 hours'), datetime('now','-90 days'), datetime('now','-9 hours')),
|
||||
(17, 'Ethan Brooks', 'ethan.brooks@nullsecurity.dev', 'customer', '+1-720-555-0112', '1994-12-30',
|
||||
'Security researcher with a dry sense of humor. Tests the demo with suspiciously long JSON blobs.',
|
||||
'{"theme":"dark","notifications":{"email":false,"push":false},"locale":"en-US","timezone":"America/Denver"}',
|
||||
12.49, 1, datetime('now','-5 days'), datetime('now','-60 days'), datetime('now','-5 days')),
|
||||
(18, 'Mia Rossi', 'mia.rossi@artigiano.it', 'customer', '+39-02-555-0366', '1991-05-03',
|
||||
'Graphic designer from Milan. Cares a lot about color-accurate monitors and clean desk lighting.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"it-IT","timezone":"Europe/Rome"}',
|
||||
725.60, 1, datetime('now','-1 day'), datetime('now','-30 days'), datetime('now','-1 day')),
|
||||
(19, 'Alexander Petrov', 'alex.petrov@polycode.ru', 'customer', '+7-495-555-0412', '1984-08-18',
|
||||
'Backend engineer in Moscow. Maintains a home lab and buys networking accessories in pairs.',
|
||||
'{"theme":"system","notifications":{"email":true,"push":false},"locale":"ru-RU","timezone":"Europe/Moscow"}',
|
||||
199.00, 0, datetime('now','-45 days'), datetime('now','-15 days'), datetime('now','-45 days')),
|
||||
(20, 'Zoe Williams', 'zoe.williams@greenleaf.org', 'moderator', '+1-510-555-0285', '1992-02-25',
|
||||
'Sustainability coordinator. Reviews product descriptions and pushes for paperless invoices.',
|
||||
'{"theme":"light","notifications":{"email":true,"push":false},"locale":"en-US","timezone":"America/Los_Angeles"}',
|
||||
88.00, 1, datetime('now','-6 hours'), datetime('now','-7 days'), datetime('now','-6 hours'));
|
||||
|
||||
-- ── Products: realistic SKUs, descriptions, and attributes ──────────────
|
||||
INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES
|
||||
(1, 'GL-MSE-001', 'Gridline Wireless Mouse', 'Ergonomic wireless mouse with silent switches, 2.4 GHz and Bluetooth 5.0, adjustable 800-1600 DPI, and a 12-month battery life.', 34.99, 1, 142, 4.6, 0, '{"color":"Graphite","wireless":true,"dpi_max":1600,"buttons":5}', datetime('now','-360 days'), datetime('now','-3 days')),
|
||||
(2, 'GL-KBD-001', 'Gridline Mechanical Keyboard', 'Hot-swappable TKL board with tactile brown switches, per-key RGB, PBT keycaps, and a USB-C braided cable.', 119.99, 5, 68, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true,"connection":"wired"}', datetime('now','-350 days'), datetime('now','-5 days')),
|
||||
(3, 'GL-HUB-001', 'Gridline 7-in-1 USB-C Hub', 'Aluminium hub with 4K HDMI, 100 W power delivery, two USB-A 3.2 ports, SD/microSD slots, and a braided cable.', 44.99, 2, 215, 4.4, 0, '{"ports":7,"hdmi":"4K60","power_delivery":"100W","material":"aluminium"}', datetime('now','-340 days'), datetime('now','-2 days')),
|
||||
(4, 'GL-MON-001', 'Gridline 27" 4K USB-C Monitor', '27-inch IPS panel, 3840×2160, 60 Hz, 99% sRGB, USB-C upstream with 90 W charging, fully adjustable stand.', 499.99, 6, 28, 4.7, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS","usb_c_power":90}', datetime('now','-330 days'), datetime('now','-8 days')),
|
||||
(5, 'GL-STD-001', 'Gridline Aluminium Laptop Stand', 'Foldable aluminium stand with six height positions, ventilated design and silicone pads. Fits 12-16 inch laptops.', 54.99, 2, 108, 4.2, 0, '{"color":"Silver","max_height_mm":280,"foldable":true}', datetime('now','-320 days'), datetime('now','-10 days')),
|
||||
(6, 'GL-CAM-001', 'Gridline 1080p Webcam', 'Full HD webcam with privacy shutter, dual noise-reducing mics, autofocus, and plug-and-play compatibility.', 69.99, 1, 0, 4.1, 1, '{"resolution":"1920x1080","fps":30,"microphone":true,"autofocus":true}', datetime('now','-310 days'), datetime('now','-60 days')),
|
||||
(7, 'GL-LMP-001', 'Gridline LED Desk Lamp', 'Dimmable LED lamp with 2700-6500 K colour temperature, flexible neck, and a built-in USB-A charging port.', 42.99, 3, 134, 4.5, 0, '{"color_temp_k":"2700-6500","dimmable":true,"usb_port":true}', datetime('now','-300 days'), datetime('now','-6 days')),
|
||||
(8, 'GL-CHR-001', 'Gridline Ergonomic Mesh Chair', 'Breathable mesh back, adjustable lumbar support, 4D armrests, and a gas lift rated up to 150 kg.', 649.99, 4, 12, 4.7, 0, '{"material":"mesh","lumbar_support":true,"max_weight_kg":150}', datetime('now','-290 days'), datetime('now','-15 days')),
|
||||
(9, 'GL-CBL-001', 'Gridline Braided USB-C Cable 2 m', 'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data. Tested to 10,000 bends.', 16.99, 2, 480, 4.3, 0, '{"length_m":2,"charging_w":100,"data_speed":"USB 3.2","color":"Midnight"}', datetime('now','-280 days'), datetime('now','-4 days')),
|
||||
(10, 'GL-ARM-001', 'Gridline Single Monitor Arm', 'Gas-spring monitor arm with 75/100 mm VESA, 360° rotation, and cable management. Supports up to 9 kg.', 89.99, 6, 36, 4.5, 0, '{"weight_capacity_kg":9,"vesa":"75/100","rotation":"360"}', datetime('now','-270 days'), datetime('now','-9 days')),
|
||||
(11, 'GL-MSE-002', 'Gridline Pro Wireless Mouse', 'Premium wireless mouse with 4000 DPI sensor, USB-C rechargeable battery, and magnetic storage case.', 79.99, 1, 64, 4.7, 0, '{"color":"Pearl","wireless":true,"dpi_max":4000,"rechargeable":true}', datetime('now','-260 days'), datetime('now','-7 days')),
|
||||
(12, 'GL-KBD-002', 'Gridline Compact Keyboard', '65% low-profile mechanical keyboard with red linear switches, white backlight, and Bluetooth/wired dual mode.', 94.99, 5, 91, 4.4, 0, '{"layout":"65%","switches":"red linear","backlit":true,"wireless":true}', datetime('now','-250 days'), datetime('now','-6 days')),
|
||||
(13, 'GL-HUB-002', 'Gridline 10-in-1 Docking Station', 'Thunderbolt-compatible dock with dual 4K display support, 2.5 GbE, and 140 W power delivery.', 219.99, 2, 41, 4.6, 0, '{"ports":10,"ethernet":"2.5G","power_delivery":"140W","displays":2}', datetime('now','-240 days'), datetime('now','-3 days')),
|
||||
(14, 'GL-MON-002', 'Gridline 32" Curved Monitor', '32-inch curved VA panel, 2560×1440, 165 Hz refresh rate, HDR400, and adaptive sync.', 429.99, 6, 19, 4.5, 0, '{"resolution":"2560x1440","refresh_hz":165,"panel":"VA","curved":true}', datetime('now','-230 days'), datetime('now','-11 days')),
|
||||
(15, 'GL-DES-001', 'Gridline Standing Desk Frame', 'Electric sit-stand desk frame with dual motors, memory presets, and a quiet 45 dB lift mechanism.', 549.99, 4, 8, 4.6, 0, '{"width_adjustable":true,"memory_presets":4,"noise_db":45}', datetime('now','-220 days'), datetime('now','-12 days')),
|
||||
(16, 'GL-KEY-001', 'Gridline Keycap Set', 'PBT dye-sub keycap set with 140 keys, compatible with most ANSI and ISO layouts.', 39.99, 5, 77, 4.3, 0, '{"keys":140,"material":"PBT","profile":"Cherry"}', datetime('now','-210 days'), datetime('now','-5 days')),
|
||||
(17, 'GL-PAD-001', 'Gridline Desk Mat', 'Oversized felt desk mat with stitched edges and anti-slip base. 900 × 400 mm.', 29.99, 3, 156, 4.4, 0, '{"material":"felt","size_mm":"900x400","anti_slip":true}', datetime('now','-200 days'), datetime('now','-8 days')),
|
||||
(18, 'GL-MIC-001', 'Gridline USB Microphone', 'Cardioid condenser mic with built-in pop filter, mute button, and 24-bit/96 kHz sampling.', 119.99, 1, 33, 4.5, 0, '{"pattern":"cardioid","sample_rate":"96kHz","bit_depth":24}', datetime('now','-190 days'), datetime('now','-9 days')),
|
||||
(19, 'GL-LGT-001', 'Gridline Monitor Light Bar', 'Screen-hanging light bar with auto-dimming, warm/cool temperature control, and no glare on the panel.', 59.99, 3, 88, 4.2, 0, '{"mount":"screen_hanging","auto_dim":true,"color_temp_k":"3000-6500"}', datetime('now','-180 days'), datetime('now','-7 days')),
|
||||
(20, 'GL-CBL-002', 'Gridline Magnetic Cable Trio', 'Set of three braided magnetic cables (USB-C, Lightning, Micro-USB) with one interchangeable tip dock.', 34.99, 2, 203, 4.0, 0, '{"cables":3,"tips":["USB-C","Lightning","Micro-USB"],"magnetic":true}', datetime('now','-170 days'), datetime('now','-6 days')),
|
||||
(21, 'GL-BAG-001', 'Gridline Tech Pouch', 'Water-resistant tech pouch with elastic loops, mesh pockets, and a cable pass-through.', 44.99, 2, 119, 4.5, 0, '{"water_resistant":true,"compartments":8,"color":"Charcoal"}', datetime('now','-160 days'), datetime('now','-4 days')),
|
||||
(22, 'GL-SPK-001', 'Gridline Desktop Speakers', 'Pair of powered bookshelf speakers with Bluetooth input, RCA/AUX, and solid wood enclosures.', 149.99, 1, 22, 4.3, 0, '{"pair":true,"inputs":["Bluetooth","RCA","AUX"],"enclosure":"wood"}', datetime('now','-150 days'), datetime('now','-10 days')),
|
||||
(23, 'GL-TRP-001', 'Gridline Tripod Desk Lamp', 'Minimal tripod desk lamp with touch dimming, USB-C power, and a 360° rotating head.', 49.99, 3, 62, 4.1, 0, '{"base":"tripod","touch_dim":true,"power":"USB-C"}', datetime('now','-140 days'), datetime('now','-8 days')),
|
||||
(24, 'GL-HDM-001', 'Gridline HDMI 2.1 Cable 1.5 m', 'Certified Ultra High Speed HDMI 2.1 cable supporting 8K@60 Hz and 4K@120 Hz, 48 Gbps.', 24.99, 2, 312, 4.4, 0, '{"length_m":1.5,"hdmi_version":"2.1","bandwidth_gbps":48}', datetime('now','-130 days'), datetime('now','-3 days'));
|
||||
|
||||
-- ── Addresses: 1-3 realistic addresses per user ────────────────────────
|
||||
INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, state, zip, country, is_primary, created_at) VALUES
|
||||
(1, 1, 'Home', '1234 Mission Street, Apt 42', 'San Francisco', 'CA', '94103', 'USA', 1, datetime('now','-500 days')),
|
||||
(2, 1, 'Work', '555 Montgomery Street, Floor 8', 'San Francisco', 'CA', '94111', 'USA', 0, datetime('now','-480 days')),
|
||||
(3, 2, 'Home', '2100 Barton Creek Blvd', 'Austin', 'TX', '78735', 'USA', 1, datetime('now','-490 days')),
|
||||
(4, 3, 'Home', '4542 11th Avenue NE', 'Seattle', 'WA', '98105', 'USA', 1, datetime('now','-470 days')),
|
||||
(5, 3, 'Office', '7200 Woodlawn Ave NE', 'Seattle', 'WA', '98115', 'USA', 0, datetime('now','-460 days')),
|
||||
(6, 4, 'Home', '850 7th Avenue, Apt 12B', 'New York', 'NY', '10019', 'USA', 1, datetime('now','-440 days')),
|
||||
(7, 5, 'Home', '1600 Glenarm Place', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-410 days')),
|
||||
(8, 5, 'Warehouse', '4650 Paris Street', 'Denver', 'CO', '80239', 'USA', 0, datetime('now','-400 days')),
|
||||
(9, 6, 'Home', '44 Prince Street', 'Boston', 'MA', '02113', 'USA', 1, datetime('now','-380 days')),
|
||||
(10, 7, 'Home', '1080 Arastradero Road', 'Palo Alto', 'CA', '94304', 'USA', 1, datetime('now','-350 days')),
|
||||
(11, 8, 'Office', 'Friedrichstraße 123', 'Berlin', 'Berlin', '10117', 'Germany', 1, datetime('now','-320 days')),
|
||||
(12, 9, 'Home', '1500 Ocean Drive, Apt 805', 'Miami Beach', 'FL', '33139', 'USA', 1, datetime('now','-290 days')),
|
||||
(13, 10, 'Work', '555 Ellis Street', 'Mountain View', 'CA', '94043', 'USA', 1, datetime('now','-260 days')),
|
||||
(14, 11, 'Home', '1925 SE Hawthorne Blvd', 'Portland', 'OR', '97214', 'USA', 1, datetime('now','-230 days')),
|
||||
(15, 12, 'Office', 'Birger Jarlsgatan 58', 'Stockholm', 'Stockholm', '111 45', 'Sweden', 1, datetime('now','-200 days')),
|
||||
(16, 13, 'Home', '925 W 5th Avenue', 'Chicago', 'IL', '60642', 'USA', 1, datetime('now','-170 days')),
|
||||
(17, 14, 'Home', '2211 7th Avenue', 'Seattle', 'WA', '98121', 'USA', 1, datetime('now','-140 days')),
|
||||
(18, 15, 'Home', 'Opernring 5', 'Vienna', 'Vienna', '1010', 'Austria', 1, datetime('now','-110 days')),
|
||||
(19, 16, 'Home', '12 Ikoyi Crescent', 'Lagos', 'Lagos', '101233', 'Nigeria', 1, datetime('now','-80 days')),
|
||||
(20, 17, 'Home', '1600 Walnut Street, Unit 300', 'Denver', 'CO', '80202', 'USA', 1, datetime('now','-50 days')),
|
||||
(21, 18, 'Home', 'Via Tortona 12', 'Milan', 'MI', '20144', 'Italy', 1, datetime('now','-25 days')),
|
||||
(22, 19, 'Home', 'Ulitsa Bolshaya Dmitrovka 9', 'Moscow', 'Moscow', '125009', 'Russia', 1, datetime('now','-10 days')),
|
||||
(23, 20, 'Home', '1900 Broadway', 'Oakland', 'CA', '94612', 'USA', 1, datetime('now','-5 days'));
|
||||
|
||||
-- ── Files: realistic file attachments with BLOB headers ─────────────────
|
||||
INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES
|
||||
(1, 'gridline_logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452000001000000010008060000005C72A866000000017352474200AECE1CE90000000467414D410000B18F0BFC61050000', 14256, 1, datetime('now','-45 days')),
|
||||
(2, 'product_photos.zip', 'application/zip', X'504B03040A00000000008B6B2D570000000000000000000000000800000070726F64756374732F504B0102001F000A00000000008B6B2D57000000000000000000000000080000000000000000000000A4810000000070726F64756374732F504B050600000000010001003A0000001A0000000000', 28934, 2, datetime('now','-42 days')),
|
||||
(3, 'invoice_2026_001.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F4C696E656172697A65642031202F4C2031203E3E0A3E3E0A73747265616D0A0A42510A0A656E6473747265616D0A656E646F626A0A', 18432, 10, datetime('now','-38 days')),
|
||||
(4, 'shipping_labels.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD30A342030206F626A0A3C3C202F54797065202F436174616C6F67202F50616765732031302020302020520A2F4F75746C696E65732032302020302020520A3E3E0A656E646F626A0A', 12288, 14, datetime('now','-30 days')),
|
||||
(5, 'user_avatars.jpg', 'image/jpeg', X'FFD8FFE000104A46494600010100000100010000FFDB004300080606070605080707070909080A0C140D0C0B0B0C1912130F14311A1F1F1A1C232D', 8934, 1, datetime('now','-25 days')),
|
||||
(6, 'q4_inventory.csv', 'text/csv', X'69642C6E616D652C73746F636B2C72657365727665640A312C47726E646C696E6520576972656C657373204D6F7573652C3134322C300A322C47726E646C696E65204D656368616E6963616C204B6579626F6172642C36382C350A', 5632, 14, datetime('now','-18 days')),
|
||||
(7, 'backup.sql', 'application/sql', X'2D2D20477269646C696E652064656D6F206261636B75700A50524F4752416D757365725F76657273696F6E203D20333B0A435245415445205441424C45204946204E4F542045584953545320757365727320282E2E2E293B', 4096, 14, datetime('now','-7 days')),
|
||||
(8, 'favicon.ico', 'image/x-icon', X'00000100010010100000000020006804000016000000280000001000000020000000010008', 4286, 1, datetime('now','-3 days'));
|
||||
|
||||
-- ── App settings: TEXT primary key ──────────────────────────────────────
|
||||
INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES
|
||||
('site_name', 'Gridline Demo Store', datetime('now','-60 days')),
|
||||
('maintenance_mode', 'false', datetime('now','-5 days')),
|
||||
('max_cart_items', '50', datetime('now','-30 days')),
|
||||
('currency', 'USD', datetime('now','-60 days')),
|
||||
('default_shipping_country', 'USA', datetime('now','-20 days')),
|
||||
('support_email', 'support@gridline.dev', datetime('now','-10 days'));
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Generated data: orders, line items, page views, audit log
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- 50 realistic orders across the user base with varied statuses and dates.
|
||||
WITH RECURSIVE order_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM order_seq WHERE n < 50
|
||||
)
|
||||
INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at)
|
||||
SELECT
|
||||
n,
|
||||
-- Distribute orders across users 1-18; users 19 (inactive) and 20 (recent) get fewer.
|
||||
CASE
|
||||
WHEN n <= 35 THEN ((n - 1) % 18) + 1
|
||||
WHEN n <= 45 THEN ((n - 1) % 12) + 1
|
||||
ELSE ((n - 1) % 8) + 1
|
||||
END,
|
||||
-- Pick a primary or secondary address for that user if one exists.
|
||||
NULL,
|
||||
0, -- total recalculated from line items below
|
||||
CASE n % 12
|
||||
WHEN 0 THEN 'pending'
|
||||
WHEN 1 THEN 'processing'
|
||||
WHEN 2 THEN 'shipped'
|
||||
WHEN 3 THEN 'delivered'
|
||||
WHEN 4 THEN 'completed'
|
||||
WHEN 5 THEN 'cancelled'
|
||||
WHEN 6 THEN 'refunded'
|
||||
WHEN 7 THEN 'shipped'
|
||||
WHEN 8 THEN 'processing'
|
||||
WHEN 9 THEN 'completed'
|
||||
WHEN 10 THEN 'pending'
|
||||
ELSE 'delivered'
|
||||
END,
|
||||
CASE n % 8
|
||||
WHEN 0 THEN 'Please leave the package at the front desk.'
|
||||
WHEN 1 THEN 'Gift wrap, please.'
|
||||
WHEN 2 THEN 'Customer requested eco-friendly packaging.'
|
||||
WHEN 3 THEN 'Ship after the 15th — office move in progress.'
|
||||
WHEN 4 THEN NULL
|
||||
WHEN 5 THEN 'Call before delivery.'
|
||||
WHEN 6 THEN 'Authority to leave if not home.'
|
||||
ELSE NULL
|
||||
END,
|
||||
datetime('now', printf('-%d days', 60 - (n % 58))),
|
||||
datetime('now', printf('-%d days', 58 - (n % 56))),
|
||||
CASE WHEN n % 12 IN (2,3,4,7,11) THEN datetime('now', printf('-%d days', 55 - (n % 53))) ELSE NULL END
|
||||
FROM order_seq;
|
||||
|
||||
-- Assign a realistic shipping address to each order from the user's address set.
|
||||
UPDATE orders SET shipping_address_id = (
|
||||
SELECT a.id FROM addresses a
|
||||
WHERE a.user_id = orders.user_id
|
||||
ORDER BY a.is_primary DESC, a.id
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- 100+ order line items: 1-3 products per order.
|
||||
WITH RECURSIVE line_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM line_seq WHERE n < 105
|
||||
)
|
||||
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price)
|
||||
SELECT
|
||||
((n - 1) % 50) + 1,
|
||||
((n - 1) % 24) + 1,
|
||||
CASE n % 5 WHEN 0 THEN 3 WHEN 1 THEN 2 ELSE 1 END,
|
||||
(SELECT price FROM products WHERE id = ((n - 1) % 24) + 1)
|
||||
FROM line_seq;
|
||||
|
||||
-- Recalculate order totals from line items.
|
||||
UPDATE orders SET total = (
|
||||
SELECT ROUND(SUM(quantity * unit_price), 2)
|
||||
FROM order_items
|
||||
WHERE order_items.order_id = orders.id
|
||||
);
|
||||
|
||||
-- 100 page views with realistic user agents and URLs.
|
||||
WITH RECURSIVE pv_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM pv_seq WHERE n < 100
|
||||
)
|
||||
INSERT INTO page_views (url, session_id, user_agent, viewed_at)
|
||||
SELECT
|
||||
CASE n % 10
|
||||
WHEN 0 THEN '/products'
|
||||
WHEN 1 THEN '/products/' || ((n % 24) + 1)
|
||||
WHEN 2 THEN '/cart'
|
||||
WHEN 3 THEN '/checkout'
|
||||
WHEN 4 THEN '/orders'
|
||||
WHEN 5 THEN '/settings'
|
||||
WHEN 6 THEN '/categories/electronics'
|
||||
WHEN 7 THEN '/categories/office'
|
||||
WHEN 8 THEN '/search?q=keyboard'
|
||||
ELSE '/'
|
||||
END,
|
||||
'sess-' || printf('%03d', (n % 30) + 1),
|
||||
CASE n % 6
|
||||
WHEN 0 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
||||
WHEN 1 THEN 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
||||
WHEN 2 THEN 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||
WHEN 3 THEN 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36'
|
||||
WHEN 4 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0'
|
||||
ELSE 'Mozilla/5.0 (iPad; CPU OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||
END,
|
||||
datetime('now', printf('-%d minutes', n * 13))
|
||||
FROM pv_seq
|
||||
WHERE NOT EXISTS (SELECT 1 FROM page_views);
|
||||
|
||||
-- 500 audit-log rows for pagination / virtualization / filtering demos.
|
||||
WITH RECURSIVE audit_seq(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM audit_seq WHERE n < 500
|
||||
)
|
||||
INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at)
|
||||
SELECT
|
||||
CASE WHEN n % 9 = 0 THEN NULL ELSE ((n - 1) % 18) + 1 END,
|
||||
CASE n % 8
|
||||
WHEN 0 THEN 'login'
|
||||
WHEN 1 THEN 'page_view'
|
||||
WHEN 2 THEN 'update'
|
||||
WHEN 3 THEN 'create'
|
||||
WHEN 4 THEN 'delete'
|
||||
WHEN 5 THEN 'export'
|
||||
WHEN 6 THEN 'refund'
|
||||
ELSE 'ship'
|
||||
END,
|
||||
CASE n % 5
|
||||
WHEN 0 THEN 'order'
|
||||
WHEN 1 THEN 'product'
|
||||
WHEN 2 THEN 'user'
|
||||
WHEN 3 THEN 'address'
|
||||
ELSE 'report'
|
||||
END,
|
||||
(n % 50) + 1,
|
||||
CASE n % 6
|
||||
WHEN 0 THEN 'info'
|
||||
WHEN 1 THEN 'info'
|
||||
WHEN 2 THEN 'warning'
|
||||
WHEN 3 THEN 'error'
|
||||
WHEN 4 THEN 'critical'
|
||||
ELSE 'warning'
|
||||
END,
|
||||
CASE WHEN n % 7 = 0 THEN NULL
|
||||
ELSE '{"page":"' ||
|
||||
CASE n % 4 WHEN 0 THEN '/' WHEN 1 THEN '/products' WHEN 2 THEN '/checkout' ELSE '/orders' END ||
|
||||
'","retries":' || (n % 3) || ',"row":' || n || ',"region":"' ||
|
||||
CASE n % 5 WHEN 0 THEN 'us-east' WHEN 1 THEN 'us-west' WHEN 2 THEN 'eu-central' WHEN 3 THEN 'ap-south' ELSE 'sa-east' END ||
|
||||
'"}'
|
||||
END,
|
||||
(n * 37) % 2500,
|
||||
datetime('now', printf('-%d minutes', n * 7))
|
||||
FROM audit_seq
|
||||
WHERE NOT EXISTS (SELECT 1 FROM audit_log);
|
||||
@@ -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,11 +95,14 @@ 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();
|
||||
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,11 +125,14 @@ 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();
|
||||
delete_folder_inner(&st, &folder.id).unwrap();
|
||||
assert_eq!(get_folders_inner(&st).unwrap().len(), 0);
|
||||
|
||||
@@ -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![],
|
||||
});
|
||||
|
||||
@@ -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 schema_graph;
|
||||
pub mod folders;
|
||||
pub mod import_export;
|
||||
pub mod keychain;
|
||||
pub mod query;
|
||||
pub mod schema_graph;
|
||||
pub mod settings;
|
||||
pub mod ssh;
|
||||
pub mod tags;
|
||||
pub mod test_connection;
|
||||
|
||||
@@ -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,8 +756,12 @@ 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)
|
||||
let result = execute_sqlite_query(
|
||||
&unwrap_sqlite(&conn),
|
||||
"SELECT * FROM users ORDER BY id",
|
||||
1,
|
||||
2,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.total_rows, 3);
|
||||
@@ -780,8 +772,12 @@ 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)
|
||||
let result2 = execute_sqlite_query(
|
||||
&unwrap_sqlite(&conn),
|
||||
"SELECT * FROM users ORDER BY id",
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result2.total_rows, 3);
|
||||
|
||||
@@ -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 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_schema: "main".into(),
|
||||
source_table: table_name.clone(),
|
||||
source_column: name.clone(),
|
||||
target_schema: "main".into(), target_table: ref_t.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,
|
||||
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();
|
||||
})
|
||||
.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),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,8 +400,7 @@ 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()")
|
||||
let server_version = sqlx::query_scalar::<_, String>("SELECT VERSION()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.ok();
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod pool;
|
||||
pub mod introspection;
|
||||
pub mod pool;
|
||||
pub mod tls;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -113,9 +113,8 @@ 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(),
|
||||
))
|
||||
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()
|
||||
@@ -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)
|
||||
assert!(build_tls_config(TlsDecision::Disable, None, None, None)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_require_returns_some_without_files() {
|
||||
assert!(
|
||||
build_tls_config(TlsDecision::Require, None, None, None)
|
||||
assert!(build_tls_config(TlsDecision::Require, None, None, None)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -214,7 +221,12 @@ 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)
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,20 +148,38 @@ 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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,10 +109,7 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
||||
}
|
||||
|
||||
// Record the migration.
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (2)",
|
||||
[],
|
||||
)
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (2)", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
@@ -141,10 +134,7 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (3)",
|
||||
[],
|
||||
)
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (3)", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
let count_scoped: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM queries WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0))
|
||||
conn.execute(
|
||||
"DELETE FROM connections WHERE id = ?1",
|
||||
rusqlite::params![conn_id],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count_scoped, 0, "Scoped saved query should be cascade-deleted");
|
||||
let count_scoped: i64 = conn
|
||||
.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"
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
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,8 +639,9 @@ 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)],
|
||||
@@ -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,8 +747,9 @@ 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())],
|
||||
@@ -746,7 +761,8 @@ impl Store {
|
||||
)
|
||||
};
|
||||
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,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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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,50 +155,63 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
className="flex flex-1 min-w-0 items-stretch overflow-x-auto"
|
||||
role="tablist"
|
||||
>
|
||||
<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;
|
||||
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" ? (
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
return (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={isActive}
|
||||
icon={icon}
|
||||
onSelect={() => setActiveTab(tab.id)}
|
||||
onClose={() => closeTab(tab.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Right: fixed actions */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ─────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||