feat: initial Gridline implementation (#1)

* chore: configure Tailwind, Vitest, Rust deps, dialog plugin (Task 1)

* feat: shared types and validation/filter utilities (Task 2)

* feat: Rust models, SQLite store, and migrations (Task 3)

* feat: Tauri commands for connections and folders (Task 4)

* feat: tag, settings, import/export Tauri commands (Task 5)

* feat: frontend command wrappers and Zustand stores (Task 6)

* feat: useSearch debounce and useFilteredConnections hooks (Task 7)

* feat: App view router with load-on-mount (Task 8)

* feat: UI primitives Button, Input, Badge, Card (Task 9)

* feat: SearchBar, ActionRow, ImportExportMenu (Task 10)

* feat: ConnectionCard, ConnectionGrid, TagBadge (Task 11)

* fix: resolve TypeScript errors across utils, Input, and test files

* feat: FolderTree, CreateFolderDialog, NewConnectionForm, SettingsPage, HomeScreen (Task 12)

* feat: Tauri file dialog integration for import/export (Task 13)

* feat: error/loading states and full App router integration (Task 14)

* chore: final integration gate — full test + build green (Task 15)

* fix: move folders inline, rearrange ActionRow layout, add borders to buttons

* feat: upgrade to Tailwind v4, apply new color palette

* Landing page restyle, folder explorer, selection/delete, and icon update

- Black-and-white color scheme with blue accent
- Glassmorphic New Folder modal with auto-parent
- Nested folder explorer with breadcrumb navigation
- Folder/connection selection with hover checkboxes
- Select All / Clear Selection / Delete dropdown
- Delete reparents children to parent folder
- Fixed Rust serde camelCase mismatch (models now use snake_case)
- Updated app icons from Apple Icon Composer exports
- Search bar with ⌘K badge
- Button styling: rounded pills, borders, cursor-pointer
- Prevent drag selection across app

* Tag system, folder edit/delete, searchable tag picker, delete confirmation, tag reordering

- Added folder_tags table for folder-tag associations
- Tags now show configured colors on folder and connection cards
- Searchable tag picker with text filtering in New/Edit Folder dialogs
- Edit Folder dialog with name and tag management
- Edit/Delete buttons next to breadcrumb with icons
- Delete confirmation dialog with reparenting notice
- Tag reordering in Settings with up/down buttons, persisted via tag_order setting
- useSortedTags hook for consistent tag ordering
- Cmd+K focuses search input
- Selection dropdown simplified to Select All/Clear/Delete
- Click-outside closes all dropdowns
- cursor-pointer on all interactables

* deps: install motion for animations

* feat(types): add system theme option

* feat(settings): default stored theme to system

* feat(ui): add Toggle primitive

* feat(ui): add Select primitive

* feat(ui): add SettingsRow and SettingsSection primitives

* fix(ui): improve SettingsRow and SettingsSection quality

* feat(ui): add ThemePicker primitive

* feat(ui): add AnimatedModal primitive with motion

* refactor(dialogs): use AnimatedModal for enter/exit animations

* fix(dialogs): ensure exit animations and Escape handling work with AnimatedModal

* feat(settings): redesign page with multi-section layout

* refactor(settings): split tabs, improve accessibility and animation

* fix(tauri): set window background color to dark canvas

* fix(settings): remove fake traffic lights, add header background, transparent title bar

* fix(settings): move Back into sidebar, add Settings heading, dynamic window title

* fix(tauri): show native window title so Home/Settings labels are visible

* fix(ui): disable overscroll on both axes

* test(dialogs): verify CreateFolderDialog shows and saves tags

* chore: ignore .worktrees directory

* refactor: centralize db type icons and labels

* feat: add connection string parser and detection

* test: add edge cases for connection string parser

* fix: preserve absolute paths in SQLite connection strings

* types: add new connection form fields

* feat: add folder path label helper

* test: cover missing folder in path label helper

* feat: add stub connection string parse and test commands

* feat: add stub test connection command

* feat: add new connection form primitives

* feat: add connection form shell

* feat: add shared form type and simple connection form

* fix: keep SimpleConnectionForm fully controlled

* style: format and tighten types for simple connection form

* feat: add detailed connection form

* fix: export DetailedConnectionFormProps and assert port update

* feat: add new connection screen container

* fix: clean up useEffect dependencies in new connection screen

* fix: address quality feedback on new connection screen

* feat: wire new connection screen into app and remove old form

* feat: open new connection screen from pasted db url in search

* test: add home search URL shortcut integration test

* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown

Add a reusable SelectDropdown component that keeps the select-styled
trigger while using the ActionRow-style popover menu. Update both
FolderSelect and EnvironmentSelect to use it.

* refactor: remove db type icon and label from connection form shell

Drop DbTypeHeader and the db_type prop from ConnectionFormShell so the
form header no longer displays the database icon/type. Update
NewConnectionScreen and its test accordingly.

* feat: home screen redesign and connection form updates

- Redesign HomeScreen layout and settings page
- Update connection card, grid, and simple form
- Replace selects with SelectDropdown component
- Remove DbTypeHeader from connection form
- Fix tests to match updated components

* fix: hide folders during search, update placeholder, and set window titles

- ConnectionGrid now hides folders when hasSearch is true so only
  matching connections are shown.
- SearchBar placeholder now mentions typing a database URL to create
  a new connection.
- App window titles set to Gridline (home), Settings, and New Connection.

* fix: shorter placeholder, dynamic window title, and startup flash

- Shorten SearchBar placeholder to mention DB URL creation concisely.
- Add core:window:allow-set-title permission and set document.title so
  the window title updates per view (Gridline, Settings, New Connection).
- Add inline dark background style to index.html to prevent white flash
  before CSS loads.

* feat: extend connection models with SSH/SSL/database fields, add db_viewer models (Task 1a)

* feat: extend frontend types with SSH/SSL fields and DB viewer types (Task 1b)

* feat: migrate connections table with SSH/SSL columns (Task 1c)

* chore: add new Rust dependencies (tokio, sqlx, redis, ssh2, indexmap, etc.) for Phase 2

* feat: connection pool manager with LRU eviction (Task 2a)

* feat: schema introspection query builders for PG, MySQL, SQLite (Task 2b)

* feat: test connection command (all 4 DB types) and SSH tunnel manager (Task 2c)

* feat: db viewer Rust commands and AppState refactor (Task 2d)

* feat: frontend command wrappers for DB viewer and updated validation (Task 3a)

* feat: dbViewer store with tabs, changes queue, pagination; uiStore activeConnectionId (Task 3b)

* feat: ConnectionCard navigates to DB Viewer, App routes to DbViewerScreen (Task 3c)

* feat: tooltip UI primitive (Task 4a)

* feat: SSH/SSL form tabs in DetailedConnectionForm (Task 4b)

* feat: DbViewerScreen shell with sidebar navigation (Task 4c)

* feat: toolbar, table tree, and overflow menu (Task 4d)

* fix: align TableInfo interface between frontend types and test files

* feat: tab bar, data grid, and pagination controls (Task 4e)

* feat: changes queue panel with cancel per change (Task 4f)

* feat: wire real IPC connect/disconnect/load in DbViewerScreen (Task 5a)

* feat: error banner, guard dialogs for destructive actions (Task 5b)

* feat: changes queue commit all with per-change IPC and error handling (Task 5c)

* chore: suppress expected dead-code warnings during multi-phase development

* fix: wire ConnectionCard click through to DB Viewer (HomeScreen -> ConnectionGrid -> ConnectionCard -> App route)

* fix: click connection opens DB viewer when nothing selected, toggles selection when items already selected; fix delete connections + generic confirm message

* fix: align ConnectionTestResult field name (ok vs success) and fix testConnection invoke param name (input vs config) to match Rust backend

* feat: implement DB Viewer Tauri commands (db_connect, get_databases, etc.)

* fix: align DB viewer IPC param names (snake_case), implement execute_change + refresh_connection, snake_case Change enum tags

* fix: use camelCase IPC keys for multi-word Tauri command params (Tauri v2 converts snake_case Rust -> camelCase)

* fix: surface full postgres error detail and redact credentials instead of opaque 'db error'; URL-encode pg connection strings

* fix: cache passwords per-session so saved connections can auto-connect; surface full postgres error detail

* fix: auto-fetch table data, full-row click, fill viewport, load columns on expand, database switching

* fix: align ColumnInfo/QueryResult types with Rust backend (columns=ColumnInfo[], rows=unknown[][], field names match)

* feat: truncate cell text, resizable columns with drag handles, tab bar no-wrap horizontal scroll

* fix: QueryResult uses total_rows/page/page_size (match Rust), brighter table borders, pagination NaN fix

* fix: table horizontal scrolling, viewport fills screen height (h-screen), remove clipping overflow-hidden

* fix: constrain layout to viewport with overflow-hidden on content column; flex-1 fills height; sidebar+grid scroll independently

* fix: move overflow-hidden down to grid wrapper so DataGrid scrollbars surface properly

* fix: add min-w-0 to DataGrid wrapper so flexbox allows shrinking for horizontal scroll

* fix: add overflow-hidden to flex row and right column to clip at viewport, DataGrid scrolls inside

* fix: use w-0 on right column to force width:0 flex-basis, preventing any content-based expansion

* fix: overscroll-contain on DataGrid and TableTree scroll areas to prevent bounce

* fix: inline overscroll-behavior:none + WebkitOverflowScrolling:auto for reliable macOS bounce prevention

* feat: page-size selector (50/100/200) in pagination bar, setPageSize resets to page 1 and triggers re-fetch

* fix: column resize with refs, FK cell click opens table with filter, vertical borders, tooltip positioning (right/bottom), sidebar transparent bg

* fix: sidebar bg-canvas, remove overflow-hidden from toolbar parent so dropdown tooltips aren't clipped

* chore: rename sidebar label from DB Viewer to Explorer

* feat: resizable table panel (180-600px) with drag handle on right edge

* feat: double-click panel resize handle resets to default 280px

* feat: column visibility dropdown in filter bar, max column width 800px, double-click resets column width

* fix: enforce column width on th cells, remove min-width:100% so columns stay at set width instead of stretching

* fix: removable resize handle always clickable (remove opacity-0), maxWidth+overflow on th/td to prevent column blowout

* feat: add environment label to connections (production/staging/development) with badge on cards

* feat: OS keychain integration for connection passwords via tauri-plugin-keyring-store

* style: monospace font for table data cells

* style: use Space Mono (font-heading) for table data cells

* feat: unified table controls bar (insert, refresh, auto-refresh, filter modal, sort modal, export, columns, pagination); fix pagination setPage not clearing data

* fix: auto-refresh defaults to off, click opens dropdown to pick interval

* fix: pagination/refresh keeps existing data visible, shows subtle loading bar instead of blank

* fix: new tabs start with loading:true so auto-fetch triggers instead of stuck on 'loading table data'

* feat: checkbox column for row selection (header=all, row=individual, selected state tracks indices)

* feat: selected row count in toolbar with clear button

* feat: bulk actions dropdown on selection (Copy JSON, Copy CSV, Copy SQL INSERT, Delete rows)

* docs: add changes-queue-before-execution rule to AGENTS.md guardrails

* docs: broaden CRUD guardrail to cover all destructive operations (DB data via queue, app entities via confirm dialog)

* feat: auto-create demo SQLite DB on first launch with sample e-commerce schema (users, products, orders, order_items)

* fix: demo DB startup panic (state before manage), add settings: re-add demo, table refresh rate, table page size

* feat: Cmd+W closes tab or navigates home, edit connection modal with update_connection backend, action queue button with count badge and dropdown

* fix: columns button icon-only, wire settings.table_page_size and table_refresh_rate to actual table behavior

* feat: shortcuts settings tab showing all keyboard shortcuts (Cmd+K, Cmd+W, Esc, Enter, Space, click, header checkbox)

* feat: editable keyboard shortcuts - click pencil to record new keybinding, persisted to settings, useShortcut hook for dynamic binding

* feat: add Tags & Env tab to edit connection modal (environment, folder, tag picker)

* feat(db-viewer): UX polish - cursor pointers, icon-only toolbar, top border

- Add cursor-pointer to all interactive elements across db-viewer components
- Simplify TableControls: Filter/Sort/Export show icons only with tooltips
- Add border-t to DbViewerScreen for visual separation from window frame

* fix(db-viewer): style EditConnectionModal, fix test connection, working refresh button

- Match EditConnectionModal styling to home screen modals (AnimatedModal, Button, no dividers)
- Fix test connection sending null password by fetching from keychain first
- Make refresh database button functional with success/error visual feedback and spin animation

* feat(db-viewer): compact ghost-style DB/schema dropdowns in single row

- Add variant prop to SelectDropdown (pill | ghost) for minimal text-only style
- Place DB and schema dropdowns side-by-side on one row with | separator

* fix(db-viewer): proper FK/enum detection and improved schema tree display

Rust backend:
- PostgreSQL: fix column query to detect FKs (was hardcoded false) and map
  USER-DEFINED types to udt_name for enum display
- SQLite: use PRAGMA table_info for types/PK/NOT NULL/defaults and
  PRAGMA foreign_key_list for FK detection

Frontend:
- FK columns show orange key icon in TableTree
- Data type abbreviations (varchar, int, bool, timestamptz, etc.) with
  full type name on hover tooltip
- Column icons use shrink-0 to maintain size

* feat(db-viewer): PK/FK icons and shorthand types in DataGrid headers

* fix(db-viewer): fix NULL values for UUID and timestamp columns, FK underline style

- Add uuid::Uuid, chrono types to pg_value_to_json chain so UUID PKs/FKs
  and timestamp columns display correctly instead of NULL
- Enable with-serde_json-1 feature on tokio-postgres
- Change FK cell styling from blue text to dotted underline

* feat(db-viewer): FK preview popover with inline filter

- Replace direct FK navigation with popover showing the referenced row
- New get_fk_preview Rust command fetches single row by column value
- FkPreviewPopover component displays columns with PK/FK icons
- 'Open' button creates filtered tab, visible in existing Filter UI
- Consolidate columnFilter into filterRules (no duplicate filter logic)

* feat(db-viewer): selectable cell text, JSON/JSONB popover with formatted/raw views

- Add select-text to cell contents for copy support
- New JsonCellPopover component with Formatted/Raw tabs and copy button
- JSON/JSONB columns show brief preview ({ N keys } / [ N items ])
- Click JSON cells to open popover with pretty-printed or raw output

* feat(db-viewer): smart default sort adds newest-first sorting automatically

- 12-tier priority system detects recency/ordering columns
- Prefers updated_at, created_at, *_at suffixes, last_* prefixes
- Falls back to timestamp types, numeric IDs, sequence/position cols
- Also covers rank, version, count/quantity columns
- Applied once per tab, visible in Sort dropdown for manual override

* feat(db-viewer): search tables input with slide animation and tree filtering

- Search icon in toolbar toggles animated input with slide-down effect
- Filters TableTree by table name as user types
- Clean border-bottom styling, search icon on left, X clear on right
- Auto-hides on blur when empty, stays when content present
- Search icon highlights when active

* docs: add comprehensive implementation status to AGENTS.md

- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with /🟡/ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
2026-07-28 19:01:00 +08:00
committed by GitHub
parent 346372c552
commit d872e429e4
186 changed files with 23311 additions and 194 deletions
+6718
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -22,4 +22,20 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
# URL percent-encoding for tokio-postgres connection strings (user/password may contain special chars)
urlencoding = "2"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls"] }
redis = { version = "0.27", features = ["tokio-comp"] }
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
ssh2 = { version = "0.9" }
deadpool-postgres = { version = "0.14" }
indexmap = { version = "2", features = ["serde"] }
tauri-plugin-keyring-store = { version = "0.2.0", default-features = false }
+5 -1
View File
@@ -5,6 +5,10 @@
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
"core:window:allow-set-title",
"opener:default",
"dialog:default",
"fs:default",
"keyring-store:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 100 KiB

+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(1.464129,0,0,1.464129,-67.349927,-273.792094)">
<path d="M729,357.75L729,699.25C729,793.489 652.489,870 558.25,870L216.75,870C122.511,870 46,793.489 46,699.25L46,357.75C46,263.511 122.511,187 216.75,187L558.25,187C652.489,187 729,263.511 729,357.75Z" style="fill:url(#_Linear1);stroke:black;stroke-width:0.68px;"/>
</g>
<g id="Icon">
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,351C325,305.743 403.415,269 500,269C596.585,269 675,305.743 675,351C675,365.931 666.465,379.936 651.557,392C640.124,401.252 624.943,409.364 606.99,415.876C577.4,426.609 540.28,433 500,433C459.711,433 422.583,426.607 392.99,415.869C375.046,409.358 359.872,401.249 348.443,392C333.535,379.936 325,365.931 325,351Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,433L325,351C325,365.931 333.535,379.936 348.443,392C359.872,401.249 375.046,409.358 392.99,415.869L392.99,497.869C375.046,491.358 359.872,483.249 348.443,474C333.535,461.936 325,447.931 325,433Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<path d="M626.996,592.007C591.874,604.748 547.812,612.333 500,612.333L500,515C547.812,515 591.874,507.414 626.996,494.674C648.307,486.944 666.326,477.316 679.897,466.333C697.592,452.013 707.724,435.39 707.724,417.667L707.724,515C707.724,532.723 697.592,549.347 679.897,563.667C666.326,574.649 648.307,584.277 626.996,592.007Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,515L325,433C325,447.931 333.535,461.936 348.443,474C359.872,483.249 375.046,491.358 392.99,497.869L392.99,579.869C375.046,573.358 359.872,565.249 348.443,556C333.535,543.936 325,529.931 325,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,597L325,515C325,529.931 333.535,543.936 348.443,556C359.872,565.249 375.046,573.358 392.99,579.869L392.99,661.869C375.046,655.358 359.872,647.249 348.443,638C333.535,625.936 325,611.931 325,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M675,515L675,597C675,611.931 666.465,625.936 651.557,638C640.124,647.252 624.943,655.364 606.99,661.876L606.99,579.876C624.943,573.364 640.124,565.252 651.557,556C666.465,543.936 675,529.931 675,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M675,597L675,679C675,705.383 648.352,728.872 606.99,743.876L606.99,661.876C624.943,655.364 640.124,647.252 651.557,638C666.465,625.936 675,611.931 675,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<path d="M372.98,786.665C323.898,768.856 292.276,740.978 292.276,709.667L292.276,612.333C292.276,630.057 302.408,646.68 320.103,661C333.669,671.979 351.68,681.603 372.98,689.332C408.107,702.078 452.177,709.667 500,709.667C547.812,709.667 591.874,702.081 626.996,689.341L626.996,786.674C591.874,799.414 547.812,807 500,807C452.177,807 408.107,799.411 372.98,786.665Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(683,683,-683,683,46,187)"><stop offset="0" style="stop-color:rgb(108,108,108);stop-opacity:1"/><stop offset="0.17" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="0.5" style="stop-color:black;stop-opacity:1"/><stop offset="0.83" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+219
View File
@@ -0,0 +1,219 @@
use crate::models::{Connection, ConnectionInput};
use crate::store::Store;
use std::sync::Mutex;
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
fn validate(input: &ConnectionInput) -> Result<(), String> {
if input.name.is_empty() || input.name.chars().count() > 100 {
return Err("name is required and must be 100 chars or fewer".into());
}
if !VALID_DB_TYPES.contains(&input.db_type.as_str()) {
return Err(format!(
"db_type must be one of: {}",
VALID_DB_TYPES.join(", ")
));
}
if input.host.is_empty() || input.host.chars().count() > 255 {
return Err("host is required and must be 255 chars or fewer".into());
}
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(),
)
}
}
}
if let Some(u) = &input.username {
if u.chars().count() > 100 {
return Err("username must be 100 chars or fewer".into());
}
}
Ok(())
}
pub fn get_connections_inner(state: &Mutex<Store>) -> Result<Vec<Connection>, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_connections()
}
pub fn create_connection_inner(
state: &Mutex<Store>,
input: ConnectionInput,
) -> Result<Connection, String> {
validate(&input)?;
let store = state.lock().map_err(|e| e.to_string())?;
store.create_connection(input)
}
pub fn update_connection_inner(
state: &Mutex<Store>,
id: String,
input: ConnectionInput,
) -> Result<Connection, String> {
validate(&input)?;
let store = state.lock().map_err(|e| e.to_string())?;
store.update_connection(&id, input)
}
pub fn delete_connection_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.delete_connection(id)
}
pub fn add_connection_tags_inner(
state: &Mutex<Store>,
connection_id: String,
tag_ids: Vec<String>,
) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.add_connection_tags(&connection_id, &tag_ids)
}
#[tauri::command]
pub fn get_connections(state: tauri::State<crate::AppState>) -> Result<Vec<Connection>, String> {
get_connections_inner(&state.db_store)
}
#[tauri::command]
pub fn create_connection(
state: tauri::State<crate::AppState>,
input: ConnectionInput,
) -> Result<Connection, String> {
create_connection_inner(&state.db_store, input)
}
#[tauri::command]
pub fn update_connection(
state: tauri::State<crate::AppState>,
id: String,
input: ConnectionInput,
) -> Result<Connection, String> {
update_connection_inner(&state.db_store, id, input)
}
#[tauri::command]
pub fn delete_connection(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
delete_connection_inner(&state.db_store, &id)
}
#[tauri::command]
pub fn add_connection_tags(
state: tauri::State<crate::AppState>,
connection_id: String,
tag_ids: Vec<String>,
) -> Result<(), String> {
add_connection_tags_inner(&state.db_store, connection_id, tag_ids)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::ConnectionInput;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn get_connections_returns_list() {
let st = state();
let result = get_connections_inner(&st);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 0);
}
#[test]
fn create_connection_command_returns_connection() {
let st = state();
let input = ConnectionInput {
name: "Prod".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
};
let result = create_connection_inner(&st, input.clone()).unwrap();
assert_eq!(result.name, "Prod");
assert_eq!(get_connections_inner(&st).unwrap().len(), 1);
}
#[test]
fn create_connection_rejects_invalid_db_type() {
let st = state();
let input = ConnectionInput {
name: "X".into(),
db_type: "mongodb".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
};
assert!(create_connection_inner(&st, input).is_err());
}
#[test]
fn delete_connection_command_removes_it() {
let st = state();
let input = ConnectionInput {
name: "X".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
};
let conn = create_connection_inner(&st, input).unwrap();
delete_connection_inner(&st, &conn.id).unwrap();
assert_eq!(get_connections_inner(&st).unwrap().len(), 0);
}
}
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
use crate::models::ConnectionInput;
use crate::store::Store;
use crate::AppState;
use rusqlite::Connection;
use std::sync::Mutex;
use tauri::Manager;
const DEMO_DB_FILENAME: &str = "demo.db";
const DEMO_CONNECTION_NAME: &str = "Demo (SQLite)";
/// Ensure the demo SQLite database exists and a corresponding connection is
/// registered. Safe to call on every app start — it's idempotent.
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> {
// Check if the demo connection already exists
{
let s = store.lock().map_err(|e| e.to_string())?;
let existing = s.get_connections()?;
if existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
return Ok(()); // already set up
}
}
// Resolve app data directory
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
let db_path = data_dir.join(DEMO_DB_FILENAME);
// Create the demo SQLite file if it doesn't exist
if !db_path.exists() {
let conn =
Connection::open(&db_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}"))?;
}
// Create the demo connection
let input = ConnectionInput {
name: DEMO_CONNECTION_NAME.to_string(),
db_type: "sqlite".to_string(),
host: db_path.to_string_lossy().to_string(),
port: None,
username: None,
password: None,
database: None,
folder_id: None,
tag_ids: vec![],
environment: Some("development".to_string()),
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let s = store.lock().map_err(|e| e.to_string())?;
s.create_connection(input)?;
Ok(())
}
/// Tauri command to re-add the demo connection from the settings screen.
#[tauri::command]
pub fn recreate_demo_db(state: tauri::State<AppState>) -> Result<String, String> {
let store = &state.db_store;
ensure_demo_db_inner(store)
.map(|()| "Demo database connection re-created.".to_string())
.map_err(|e| e.to_string())
}
/// Internal helper that does not need an AppHandle (for settings usage).
fn ensure_demo_db_inner(store: &Mutex<Store>) -> Result<(), String> {
// Use a temp dir since we don't have the app handle
let data_dir = std::env::temp_dir().join("gridline-demo");
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
let db_path = data_dir.join(DEMO_DB_FILENAME);
// Check if file already exists
if db_path.exists() {
// Just re-create the connection if it was deleted
let s = store.lock().map_err(|e| e.to_string())?;
let existing = s.get_connections()?;
if !existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
drop(s);
let input = ConnectionInput {
name: DEMO_CONNECTION_NAME.to_string(),
db_type: "sqlite".to_string(),
host: db_path.to_string_lossy().to_string(),
port: None,
username: None,
password: None,
database: None,
folder_id: None,
tag_ids: vec![],
environment: Some("development".to_string()),
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let s = store.lock().map_err(|e| e.to_string())?;
s.create_connection(input)?;
}
return Ok(());
}
// Create demo DB file and seed it
let conn = Connection::open(&db_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}"))?;
let input = ConnectionInput {
name: DEMO_CONNECTION_NAME.to_string(),
db_type: "sqlite".to_string(),
host: db_path.to_string_lossy().to_string(),
port: None,
username: None,
password: None,
database: None,
folder_id: None,
tag_ids: vec![],
environment: Some("development".to_string()),
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let s = store.lock().map_err(|e| e.to_string())?;
s.create_connection(input)?;
Ok(())
}
fn get_demo_schema() -> String {
"
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',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price REAL NOT NULL,
category TEXT NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
total REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL DEFAULT 1,
unit_price REAL NOT NULL
);
INSERT OR IGNORE INTO users (id, name, email, role) VALUES
(1, 'Alice Johnson', 'alice@example.com', 'admin'),
(2, 'Bob Smith', 'bob@example.com', 'user'),
(3, 'Carol Davis', 'carol@example.com', 'user'),
(4, 'Dan Wilson', 'dan@example.com', 'user'),
(5, 'Eve Martinez', 'eve@example.com', 'moderator');
INSERT OR IGNORE INTO products (id, name, price, category, stock) VALUES
(1, 'Wireless Mouse', 29.99, 'Electronics', 150),
(2, 'Mechanical Keyboard', 89.99, 'Electronics', 75),
(3, 'USB-C Hub', 34.99, 'Accessories', 200),
(4, '27\" 4K Monitor', 449.99, 'Electronics', 30),
(5, 'Laptop Stand', 49.99, 'Accessories', 100),
(6, 'Webcam 1080p', 59.99, 'Electronics', 60),
(7, 'Desk Lamp LED', 39.99, 'Office', 120),
(8, 'Ergonomic Chair', 599.99, 'Office', 15);
INSERT OR IGNORE INTO orders (id, user_id, total, status) VALUES
(1, 1, 119.98, 'completed'),
(2, 2, 484.98, 'pending'),
(3, 3, 59.99, 'completed'),
(4, 1, 89.99, 'shipped'),
(5, 4, 689.98, 'pending');
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);
".to_string()
}
+136
View File
@@ -0,0 +1,136 @@
use crate::models::{Folder, FolderInput};
use crate::store::Store;
use std::sync::Mutex;
fn validate(input: &FolderInput) -> Result<(), String> {
if input.name.is_empty() || input.name.chars().count() > 100 {
return Err("name is required and must be 100 chars or fewer".into());
}
Ok(())
}
pub fn get_folders_inner(state: &Mutex<Store>) -> Result<Vec<Folder>, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_folders()
}
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)
}
pub fn delete_folder_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.delete_folder(id)
}
pub fn add_folder_tags_inner(
state: &Mutex<Store>,
folder_id: String,
tag_ids: Vec<String>,
) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.add_folder_tags(&folder_id, &tag_ids)
}
pub fn update_folder_inner(
state: &Mutex<Store>,
id: String,
input: FolderInput,
) -> Result<Folder, String> {
validate(&input)?;
let store = state.lock().map_err(|e| e.to_string())?;
store.update_folder(&id, input)
}
#[tauri::command]
pub fn get_folders(state: tauri::State<crate::AppState>) -> Result<Vec<Folder>, String> {
get_folders_inner(&state.db_store)
}
#[tauri::command]
pub fn create_folder(
state: tauri::State<crate::AppState>,
input: FolderInput,
) -> Result<Folder, String> {
create_folder_inner(&state.db_store, input)
}
#[tauri::command]
pub fn delete_folder(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
delete_folder_inner(&state.db_store, &id)
}
#[tauri::command]
pub fn add_folder_tags(
state: tauri::State<crate::AppState>,
folder_id: String,
tag_ids: Vec<String>,
) -> Result<(), String> {
add_folder_tags_inner(&state.db_store, folder_id, tag_ids)
}
#[tauri::command]
pub fn update_folder(
state: tauri::State<crate::AppState>,
id: String,
input: FolderInput,
) -> Result<Folder, String> {
update_folder_inner(&state.db_store, id, input)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::FolderInput;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn create_folder_command_works() {
let st = state();
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");
}
#[test]
fn create_folder_rejects_empty_name() {
let st = state();
let result = create_folder_inner(
&st,
FolderInput { tag_ids: None,
name: "".into(),
parent_id: None,
},
);
assert!(result.is_err());
}
#[test]
fn delete_folder_command_works() {
let st = state();
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);
}
}
+184
View File
@@ -0,0 +1,184 @@
use crate::models::ConnectionInput;
use crate::store::Store;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
#[derive(Debug, Deserialize)]
pub(crate) struct ImportRecord {
name: Option<String>,
db_type: String,
host: String,
port: Option<i64>,
username: Option<String>,
folder_id: Option<String>,
tag_ids: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkippedRecord {
pub index: usize,
pub reason: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportResult {
pub imported: usize,
pub skipped: usize,
pub skipped_records: Vec<SkippedRecord>,
}
#[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))?;
for (i, rec) in records.iter().enumerate() {
if rec.name.as_deref().unwrap_or("").is_empty() {
return Err(format!("record {}: name is required", i));
}
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
return Err(format!("record {}: invalid db_type: {}", i, rec.db_type));
}
}
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))?;
let store = state.lock().map_err(|e| e.to_string())?;
let mut imported = 0usize;
let mut skipped_records = Vec::new();
for (i, rec) in records.iter().enumerate() {
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() });
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) });
continue;
}
if rec.host.is_empty() {
skipped_records.push(SkippedRecord { index: i, reason: "missing or empty host".into() });
continue;
}
let input = ConnectionInput {
name,
db_type: rec.db_type.clone(),
host: rec.host.clone(),
port: rec.port,
username: rec.username.clone(),
folder_id: rec.folder_id.clone(),
password: None,
database: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
};
match store.create_connection(input) {
Ok(_) => imported += 1,
Err(e) => skipped_records.push(SkippedRecord { index: i, reason: e }),
}
}
Ok(ImportResult { imported, skipped: skipped_records.len(), skipped_records })
}
pub fn export_connections_inner(state: &Mutex<Store>) -> Result<String, String> {
let store = state.lock().map_err(|e| e.to_string())?;
let conns = store.get_connections()?;
let export = serde_json::json!({ "version": 1, "connections": conns });
serde_json::to_string_pretty(&export).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn import_connections(state: tauri::State<crate::AppState>, json: String) -> Result<ImportResult, String> {
import_connections_inner(&state.db_store, json)
}
#[tauri::command]
pub fn export_connections(state: tauri::State<crate::AppState>) -> Result<String, String> {
export_connections_inner(&state.db_store)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::models::ConnectionInput;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn parse_import_validates_required_fields() {
let json = r#"[{ "name": "X", "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
let parsed = parse_import(json).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].name.as_deref(), Some("X"));
}
#[test]
fn parse_import_rejects_missing_name() {
let json = r#"[{ "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
assert!(parse_import(json).is_err());
}
#[test]
fn parse_import_rejects_invalid_db_type() {
let json = r#"[{ "name": "X", "db_type": "mongodb", "host": "h", "port": 5432 }]"#;
assert!(parse_import(json).is_err());
}
#[test]
fn import_connections_inserts_all() {
let st = state();
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
let result = import_connections_inner(&st, json.to_string()).unwrap();
assert_eq!(result.imported, 2);
assert_eq!(result.skipped, 0);
}
#[test]
fn import_connections_skips_invalid_keeps_valid() {
let st = state();
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
let result = import_connections_inner(&st, json.to_string()).unwrap();
assert_eq!(result.imported, 2);
assert_eq!(result.skipped, 1);
assert_eq!(result.skipped_records[0].reason, "missing or empty name");
}
#[test]
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_passphrase: None,
ssl_mode: None, ssl_ca_path: None, ssl_cert_path: None, ssl_key_path: None,
environment: None,
tag_ids: vec![],
});
let json = export_connections_inner(&st).unwrap();
assert!(json.contains("\"name\""));
assert!(json.contains("\"version\""));
}
}
+40
View File
@@ -0,0 +1,40 @@
use tauri_plugin_keyring_store::KeyringExt;
/// Store a connection password in the OS keychain.
/// The connection ID is used as the keyring account name.
#[tauri::command]
pub fn save_connection_password(
app: tauri::AppHandle,
connection_id: String,
password: String,
) -> Result<(), String> {
app.keyring()
.store
.set_password(&connection_id, &password)
.map_err(|e| e.to_string())
}
/// Retrieve a connection password from the OS keychain.
/// Returns None if no password was stored for this connection.
#[tauri::command]
pub fn get_connection_password(
app: tauri::AppHandle,
connection_id: String,
) -> Result<Option<String>, String> {
app.keyring()
.store
.get_password(&connection_id)
.map_err(|e| e.to_string())
}
/// Delete a connection password from the OS keychain.
#[tauri::command]
pub fn delete_connection_password(
app: tauri::AppHandle,
connection_id: String,
) -> Result<(), String> {
app.keyring()
.store
.delete(&connection_id)
.map_err(|e| e.to_string())
}
+10
View File
@@ -0,0 +1,10 @@
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;
+50
View File
@@ -0,0 +1,50 @@
use crate::models::Settings;
use crate::store::Store;
use std::sync::Mutex;
pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_settings()
}
pub fn update_setting_inner(state: &Mutex<Store>, key: &str, value: &str) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.update_setting(key, value)
}
#[tauri::command]
pub fn get_settings(state: tauri::State<crate::AppState>) -> Result<Settings, String> {
get_settings_inner(&state.db_store)
}
#[tauri::command]
pub fn update_setting(state: tauri::State<crate::AppState>, key: String, value: String) -> Result<(), String> {
update_setting_inner(&state.db_store, &key, &value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn get_settings_returns_defaults() {
let st = state();
let s = get_settings_inner(&st).unwrap();
assert_eq!(s.theme, "system");
assert_eq!(s.font_size, "medium");
}
#[test]
fn update_setting_persists() {
let st = state();
update_setting_inner(&st, "theme", "light").unwrap();
assert_eq!(get_settings_inner(&st).unwrap().theme, "light");
}
}
+179
View File
@@ -0,0 +1,179 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// SSH tunnel configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshConfig {
pub host: String,
pub port: u16,
pub user: String,
/// "password" or "key"
pub auth_method: String,
pub password: Option<String>,
pub private_key_path: Option<String>,
pub passphrase: Option<String>,
}
impl SshConfig {
/// Create a new `SshConfig` with the required fields.
pub fn new(
host: String,
port: u16,
user: String,
auth_method: String,
) -> Self {
SshConfig {
host,
port,
user,
auth_method,
password: None,
private_key_path: None,
passphrase: None,
}
}
/// Validate SSH configuration.
///
/// Returns `true` if:
/// - `host` is not empty
/// - `port` is in range 1..=65535 (u16 guarantees <= 65535)
/// - `user` is not empty
pub fn is_valid(&self) -> bool {
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
}
}
/// Represents an active SSH tunnel connection.
#[derive(Debug)]
struct SshTunnel {
local_port: u16,
remote_host: String,
remote_port: u16,
}
/// Manages SSH tunnels, mapping connection keys to active tunnels.
///
/// This is a placeholder implementation. Real SSH connectivity (via `ssh2`
/// or `async-ssh2`) will be added in a later task. Currently the manager
/// stores mock entries when validation passes.
#[derive(Debug)]
pub struct SshTunnelManager {
tunnels: HashMap<String, SshTunnel>,
}
impl SshTunnelManager {
/// Create a new empty tunnel manager.
pub fn new() -> Self {
SshTunnelManager {
tunnels: HashMap::new(),
}
}
/// Open an SSH tunnel for the given config.
///
/// Returns the local port on success.
///
/// TODO: Replace placeholder with a real SSH connection via `ssh2` or
/// `async-ssh2`. Currently stores a mock entry (`local_port = 15432`)
/// when `config.is_valid()` passes.
pub fn open_tunnel(&mut self, key: &str, config: &SshConfig) -> Result<u16, String> {
if !config.is_valid() {
return Err("invalid SSH configuration".to_string());
}
// TODO: Replace with real SSH tunnel via ssh2::Session + port forwarding.
// For now, store a mock entry with local_port = 15432.
self.tunnels.insert(
key.to_string(),
SshTunnel {
local_port: 15432,
remote_host: config.host.clone(),
remote_port: config.port,
},
);
Ok(15432)
}
/// Close and remove the SSH tunnel for the given key.
///
/// TODO: When real SSH is implemented, this should disconnect the
/// session and free the local port.
pub fn close_tunnel(&mut self, key: &str) {
self.tunnels.remove(key);
}
/// Close all active SSH tunnels.
pub fn close_all(&mut self) {
self.tunnels.clear();
}
/// Get the local port for an active tunnel, if any.
pub fn get_local_port(&self, key: &str) -> Option<u16> {
self.tunnels.get(key).map(|t| t.local_port)
}
/// Return the number of active tunnels.
pub fn active_count(&self) -> usize {
self.tunnels.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
// ------------------------------------------------------------------
// SshConfig validation
// ------------------------------------------------------------------
#[test]
fn ssh_config_validation() {
// Invalid: empty host
let config = SshConfig::new(
"".to_string(),
22,
"user".to_string(),
"password".to_string(),
);
assert!(!config.is_valid(), "empty host should be invalid");
// Invalid: empty user
let config = SshConfig::new(
"host.example.com".to_string(),
22,
"".to_string(),
"password".to_string(),
);
assert!(!config.is_valid(), "empty user should be invalid");
// Valid: all required fields present
let config = SshConfig::new(
"host.example.com".to_string(),
2222,
"tunnel".to_string(),
"key".to_string(),
);
assert!(config.is_valid(), "valid config should be accepted");
}
#[test]
fn ssh_config_rejects_non_standard_ports() {
// Port 0 is invalid
let config = SshConfig::new(
"host.example.com".to_string(),
0,
"user".to_string(),
"password".to_string(),
);
assert!(!config.is_valid(), "port 0 should be invalid");
// Port 1 is valid (boundary)
let config = SshConfig::new(
"host.example.com".to_string(),
1,
"user".to_string(),
"password".to_string(),
);
assert!(config.is_valid(), "port 1 should be valid");
}
}
+88
View File
@@ -0,0 +1,88 @@
use crate::models::{Tag, TagInput};
use crate::store::Store;
use std::sync::Mutex;
fn validate(input: &TagInput) -> Result<(), String> {
if input.name.is_empty() || input.name.chars().count() > 50 {
return Err("name is required and must be 50 chars or fewer".into());
}
Ok(())
}
pub fn get_tags_inner(state: &Mutex<Store>) -> Result<Vec<Tag>, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_tags()
}
pub fn create_tag_inner(state: &Mutex<Store>, input: TagInput) -> Result<Tag, String> {
validate(&input)?;
let store = state.lock().map_err(|e| e.to_string())?;
store.create_tag(input)
}
pub fn delete_tag_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.delete_tag(id)
}
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)
}
#[tauri::command]
pub fn get_tags(state: tauri::State<crate::AppState>) -> Result<Vec<Tag>, String> {
get_tags_inner(&state.db_store)
}
#[tauri::command]
pub fn create_tag(state: tauri::State<crate::AppState>, input: TagInput) -> Result<Tag, String> {
create_tag_inner(&state.db_store, input)
}
#[tauri::command]
pub fn delete_tag(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
delete_tag_inner(&state.db_store, &id)
}
#[tauri::command]
pub fn update_tag(
state: tauri::State<crate::AppState>,
id: String,
input: TagInput,
) -> Result<Tag, String> {
update_tag_inner(&state.db_store, id, input)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::models::TagInput;
fn state() -> std::sync::Mutex<Store> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
std::sync::Mutex::new(Store::from_connection(conn))
}
#[test]
fn create_tag_command_works() {
let st = state();
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");
}
#[test]
fn create_tag_rejects_long_name() {
let st = state();
let result = create_tag_inner(&st, TagInput { name: "x".repeat(51), color: "#fff".into() });
assert!(result.is_err());
}
}
+439
View File
@@ -0,0 +1,439 @@
use serde::{Deserialize, Serialize};
use crate::db::pool::DbConfig;
/// Result of a test database connection attempt.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestConnectionResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Strip credentials and sensitive information from error messages while
/// preserving the useful diagnostic detail (severity, message, SQLSTATE).
///
/// Redacts `password=...`, `user=...`, `postgresql://user:pwd@host` URLs,
/// and `@host` credential fragments rather than discarding the whole
/// message — so the user can still see e.g. "password authentication
/// failed for user 'foo'" without leaking the password itself.
pub fn sanitize_error(msg: &str) -> String {
let mut out = String::with_capacity(msg.len());
let bytes = msg.as_bytes();
let mut i = 0;
while i < bytes.len() {
let lower = msg[i..].to_lowercase();
if lower.starts_with("postgres://") || lower.starts_with("postgresql://") {
out.push_str("[redacted-url://");
let scheme_end = i + msg[i..].find("://").unwrap_or(0) + 3;
let rest = &msg[scheme_end..];
let end = match rest.find(['/', '?']) {
Some(pos) => scheme_end + pos,
None => msg.len(),
};
i = end;
} else if lower.starts_with("password=") {
out.push_str("[redacted]");
let rest = &msg[i + "password=".len()..];
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
i += "password=".len() + skip;
} else if lower.starts_with("user=") {
out.push_str("[redacted]");
let rest = &msg[i + "user=".len()..];
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
i += "user=".len() + skip;
} else if lower.starts_with("secret") {
out.push_str("secret=[redacted]");
let rest = &msg[i + "secret".len()..];
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
i += "secret".len() + skip;
} else {
let ch = msg[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
}
// Truncate at 300 characters for safety.
if out.len() > 300 {
format!("{}...", &out[..297])
} else {
out
}
}
/// Validate `DbConfig` before attempting a connection test.
///
/// Returns `Some(error_message)` if the config is invalid, or `None` if valid.
///
/// Validation rules:
/// - `db_type` must be one of: `postgresql`, `mysql`, `sqlite`, `redis`
/// - For `postgresql`, `mysql`, `redis`: `host` must not be empty, `port` must
/// be `Some(1..=65535)`
/// - For `sqlite`: `host` (file path) must not be empty
pub fn validate_test_input(config: &DbConfig) -> Option<String> {
let db_type = config.db_type.to_lowercase();
let valid_types = ["postgresql", "mysql", "sqlite", "redis"];
if !valid_types.contains(&db_type.as_str()) {
return Some(format!(
"unsupported database type: {}. Supported types: {}",
config.db_type,
valid_types.join(", ")
));
}
if config.host.is_empty() {
return Some("host must not be empty".to_string());
}
// SQLite does not require a port (host is the file path)
if db_type != "sqlite" {
match config.port {
Some(p) if (1..=65535).contains(&p) => {}
_ => {
return Some(
"port must be an integer between 1 and 65535 for this db_type"
.to_string(),
);
}
}
}
None
}
/// Test a database connection for the given configuration.
///
/// 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) -> TestConnectionResult {
// Validate input first
if let Some(err) = validate_test_input(config) {
return TestConnectionResult {
ok: false,
error: Some(err),
};
}
let result = match config.db_type.to_lowercase().as_str() {
"postgresql" => test_pg_connection(config).await,
"mysql" => test_mysql_connection(config).await,
"sqlite" => test_sqlite_connection(config),
"redis" => test_redis_connection(config).await,
other => TestConnectionResult {
ok: false,
error: Some(format!("unsupported database type: {other}")),
},
};
TestConnectionResult {
ok: result.ok,
error: result.error.map(|e| sanitize_error(&e)),
}
}
/// Test a PostgreSQL connection using `tokio-postgres`.
///
/// Connects without TLS. The connection handler is spawned and immediately
/// dropped after confirming the connection is alive.
async fn test_pg_connection(config: &DbConfig) -> TestConnectionResult {
use tokio_postgres::NoTls;
let host = &config.host;
let port = config.port.unwrap_or(5432) as u16;
let user = config.username.as_deref().unwrap_or("postgres");
let dbname = config.database.as_deref().unwrap_or("postgres");
let password = config.password.as_deref().unwrap_or("");
// Use a postgres URL rather than libpq key=value format: tokio-postgres
// parses URLs reliably and urlencoding handles special chars safely.
use urlencoding::encode as enc;
let conn_str = format!(
"postgresql://{}:{}@{}:{}/{}?connect_timeout=10",
enc(user),
enc(password),
host,
port,
enc(dbname),
);
match tokio_postgres::connect(&conn_str, NoTls).await {
Ok((_client, connection)) => {
// Spawn the connection handler so it keeps running while we test
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("connection error: {}", e);
}
});
TestConnectionResult { ok: true, error: None }
}
Err(e) => TestConnectionResult {
ok: false,
error: Some(e.to_string()),
},
}
}
/// Test a MySQL connection using `sqlx`.
///
/// Uses `MySqlPoolOptions` with a pool size of 1 and a 10-second
/// `acquire_timeout`.
async fn test_mysql_connection(config: &DbConfig) -> TestConnectionResult {
use sqlx::mysql::MySqlPoolOptions;
let host = &config.host;
let port = config.port.unwrap_or(3306);
let user = config.username.as_deref().unwrap_or("root");
let password = config.password.as_deref().unwrap_or("");
let dbname = config.database.as_deref().unwrap_or("mysql");
let conn_str = format!(
"mysql://{}:{}@{}:{}/{}",
user, password, host, port, dbname
);
match MySqlPoolOptions::new()
.max_connections(1)
.acquire_timeout(std::time::Duration::from_secs(10))
.connect(&conn_str)
.await
{
Ok(pool) => {
pool.close().await;
TestConnectionResult { ok: true, error: None }
}
Err(e) => TestConnectionResult {
ok: false,
error: Some(e.to_string()),
},
}
}
/// Test a SQLite connection using `rusqlite`.
///
/// Opens the database file at `config.host`. Returns success if the file
/// can be opened as a valid SQLite database.
fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
match rusqlite::Connection::open(&config.host) {
Ok(_conn) => TestConnectionResult { ok: true, error: None },
Err(e) => TestConnectionResult {
ok: false,
error: Some(e.to_string()),
},
}
}
/// Test a Redis connection using the `redis` crate.
///
/// Uses `redis::Client::open` followed by `get_async_connection` with a
/// 10-second timeout via `tokio::time::timeout`.
async fn test_redis_connection(config: &DbConfig) -> TestConnectionResult {
use tokio::time::timeout;
let host = &config.host;
let port = config.port.unwrap_or(6379);
let password = config.password.as_deref();
let conn_str = if let Some(pwd) = password {
format!("redis://:{}@{}:{}/", pwd, host, port)
} else {
format!("redis://{}:{}/", host, port)
};
match redis::Client::open(conn_str.as_str()) {
Ok(client) => {
match timeout(
std::time::Duration::from_secs(10),
client.get_multiplexed_async_connection(),
)
.await
{
Ok(Ok(_conn)) => TestConnectionResult { ok: true, error: None },
Ok(Err(e)) => TestConnectionResult {
ok: false,
error: Some(e.to_string()),
},
Err(_) => TestConnectionResult {
ok: false,
error: Some("connection timed out after 10 seconds".to_string()),
},
}
}
Err(e) => TestConnectionResult {
ok: false,
error: Some(e.to_string()),
},
}
}
/// Tauri command to test a database connection.
///
/// Calls `test_database_connection` and returns the result.
#[tauri::command]
pub async fn test_connection(config: DbConfig) -> Result<TestConnectionResult, String> {
Ok(test_database_connection(&config).await)
}
#[cfg(test)]
mod tests {
use super::*;
// ------------------------------------------------------------------
// TestConnectionResult serialization
// ------------------------------------------------------------------
#[test]
fn test_connection_result_serialization() {
// ok=true result serializes correctly
let result = TestConnectionResult { ok: true, error: None };
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"ok\":true"), "ok=true should appear in JSON");
// error result includes the error message
let result = TestConnectionResult {
ok: false,
error: Some("connection refused".to_string()),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"connection refused\""), "error message should appear in JSON");
}
// ------------------------------------------------------------------
// sanitize_error
// ------------------------------------------------------------------
#[test]
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("user="), "should remove user= pattern");
}
// ------------------------------------------------------------------
// validate_test_input rejection
// ------------------------------------------------------------------
#[test]
fn validate_test_input_rejects_invalid() {
// Unsupported db type
let config = DbConfig {
db_type: "mongodb".to_string(),
host: "localhost".to_string(),
port: Some(27017),
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
assert!(
validate_test_input(&config).is_some(),
"mongodb should be rejected"
);
// Empty host
let config = DbConfig {
db_type: "postgresql".to_string(),
host: "".to_string(),
port: Some(5432),
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
assert!(
validate_test_input(&config).is_some(),
"empty host should be rejected"
);
// Port 0
let config = DbConfig {
db_type: "postgresql".to_string(),
host: "localhost".to_string(),
port: Some(0),
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
assert!(
validate_test_input(&config).is_some(),
"port 0 should be rejected"
);
}
// ------------------------------------------------------------------
// validate_test_input acceptance
// ------------------------------------------------------------------
#[test]
fn validate_test_input_accepts_valid() {
let config = DbConfig {
db_type: "postgresql".to_string(),
host: "localhost".to_string(),
port: Some(5432),
username: Some("user".to_string()),
password: None,
database: Some("mydb".to_string()),
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
assert!(
validate_test_input(&config).is_none(),
"valid postgresql config should be accepted"
);
}
#[test]
fn sqlite_accepts_no_port() {
// SQLite does not require a port
let config = DbConfig {
db_type: "sqlite".to_string(),
host: "/tmp/test.db".to_string(),
port: None,
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
assert!(
validate_test_input(&config).is_none(),
"sqlite without port should be accepted"
);
// SQLite should also accept a config with any port (port is ignored)
let config = DbConfig {
db_type: "sqlite".to_string(),
host: "/tmp/test.db".to_string(),
port: Some(9999),
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
assert!(
validate_test_input(&config).is_none(),
"sqlite with any port should be accepted"
);
}
}
+385
View File
@@ -0,0 +1,385 @@
//! Schema introspection query builders.
//!
//! This module provides pure functions that generate SQL query strings
//! for database schema introspection. No actual DB connections are needed
//! for testing — all functions are deterministic string builders.
// ---------------------------------------------------------------------------
// PostgreSQL
// ---------------------------------------------------------------------------
/// Approximate row count for a table via `pg_class.reltuples`.
pub fn pg_reltuples_query(schema: &str, table: &str) -> String {
format!(
"SELECT reltuples::bigint AS count FROM pg_class \
WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '{}') \
AND relname = '{}'",
schema, table
)
}
/// List tables and views in a schema (or all non-system schemata).
///
/// When `schema` is `None` all schemata except the built-in system schemata
/// (`pg_catalog`, `information_schema`) are included.
pub fn pg_tables_query(schema: Option<&str>) -> String {
match schema {
Some(s) => format!(
"SELECT table_name, table_type FROM information_schema.tables \
WHERE table_schema = '{}' ORDER BY table_name",
s
),
None => {
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
ORDER BY table_schema, table_name"
.to_string()
}
}
}
/// List all non-system schemata.
pub fn pg_schemas_query() -> String {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
ORDER BY schema_name"
.to_string()
}
/// List non-template databases.
pub fn pg_databases_query() -> String {
"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname".to_string()
}
/// Column details with primary-key and foreign-key annotations.
///
/// Joins `information_schema.columns` with constraint metadata so that
/// each row includes PK / FK information when applicable.
pub fn pg_columns_query(schema: &str, table: &str) -> String {
format!(
r#"SELECT
c.column_name,
c.data_type,
c.is_nullable,
c.character_maximum_length,
c.numeric_precision,
c.numeric_scale,
c.column_default,
c.ordinal_position,
pk.constraint_type,
fk.foreign_table_schema,
fk.foreign_table_name,
fk.foreign_column_name
FROM information_schema.columns c
LEFT JOIN (
SELECT kcu.column_name, kcu.table_schema, kcu.table_name, 'PRIMARY KEY' AS constraint_type
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_catalog = kcu.constraint_catalog
AND tc.constraint_schema = kcu.constraint_schema
AND tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'PRIMARY KEY'
) pk ON c.table_schema = pk.table_schema AND c.table_name = pk.table_name AND c.column_name = pk.column_name
LEFT JOIN (
SELECT
kcu.column_name,
kcu.table_schema,
kcu.table_name,
ccu.table_schema AS foreign_table_schema,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_catalog = kcu.constraint_catalog
AND tc.constraint_schema = kcu.constraint_schema
AND tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_catalog = ccu.constraint_catalog
AND tc.constraint_schema = ccu.constraint_schema
AND tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
) fk ON c.table_schema = fk.table_schema AND c.table_name = fk.table_name AND c.column_name = fk.column_name
WHERE c.table_schema = '{}' AND c.table_name = '{}'
ORDER BY c.ordinal_position"#,
schema, table
)
}
// ---------------------------------------------------------------------------
// MySQL
// ---------------------------------------------------------------------------
/// List tables (and views) in the given schema.
///
/// When `schema` is `None` all non-system schemata are included (excluding
/// `information_schema`, `performance_schema`, `mysql`, and `sys`).
pub fn mysql_tables_query(schema: Option<&str>) -> String {
match schema {
Some(s) => format!(
"SELECT table_name, table_type FROM information_schema.tables \
WHERE table_schema = '{}' ORDER BY table_name",
s
),
None => {
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
WHERE table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys') \
ORDER BY table_schema, table_name"
.to_string()
}
}
}
// ---------------------------------------------------------------------------
// SQLite
// ---------------------------------------------------------------------------
/// List tables and views from `sqlite_master`.
pub fn sqlite_tables_query() -> String {
"SELECT name AS table_name, type AS table_type FROM sqlite_master \
WHERE type IN ('table', 'view') ORDER BY name"
.to_string()
}
/// Column metadata via `PRAGMA table_info`.
pub fn sqlite_columns_query(table: &str) -> String {
format!("PRAGMA table_info('{}')", table)
}
/// Foreign-key metadata via `PRAGMA foreign_key_list`.
pub fn sqlite_foreign_keys_query(table: &str) -> String {
format!("PRAGMA foreign_key_list('{}')", table)
}
// ---------------------------------------------------------------------------
// Generic helpers
// ---------------------------------------------------------------------------
/// Build a paginated `SELECT` query.
///
/// Returns the SQL string (with `$1` / `$2` placeholders for `LIMIT` and
/// `OFFSET`) together with a vector of the corresponding `i64` parameter
/// values `[page_size, page * page_size]`.
///
/// When `columns` is empty the query uses `*`.
pub fn build_select_query(
schema: &str,
table: &str,
columns: &[String],
page: i64,
page_size: i64,
) -> (String, Vec<i64>) {
let cols = if columns.is_empty() {
"*".to_string()
} else {
let mut buf = String::new();
for (i, col) in columns.iter().enumerate() {
if i > 0 {
buf.push_str(", ");
}
buf.push('"');
buf.push_str(col);
buf.push('"');
}
buf
};
let sql = format!(
"SELECT {} FROM \"{}\".\"{}\" LIMIT $1 OFFSET $2",
cols, schema, table
);
let params = vec![page_size, page * page_size];
(sql, params)
}
/// Build a `COUNT(*)` query.
pub fn build_count_query(schema: &str, table: &str) -> String {
format!("SELECT COUNT(*) FROM \"{}\".\"{}\"", schema, table)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// ---------------------------------------------------------------
// PostgreSQL
// ---------------------------------------------------------------
#[test]
fn pg_count_approximation_query_is_valid() {
let sql = pg_reltuples_query("public", "users");
assert!(
sql.contains("pg_class"),
"should query pg_class for row estimates; got: {}",
sql
);
assert!(sql.contains("public"), "should contain schema name");
assert!(sql.contains("users"), "should contain table name");
}
#[test]
fn pg_table_list_query_is_valid() {
let sql = pg_tables_query(Some("public"));
assert!(
sql.contains("information_schema.tables"),
"should query information_schema.tables; got: {}",
sql
);
assert!(sql.contains("public"), "should contain the given schema");
// Without schema filter — should exclude system schemata
let all_sql = pg_tables_query(None);
assert!(
all_sql.contains("information_schema.tables"),
"should query information_schema.tables"
);
assert!(
all_sql.contains("pg_catalog"),
"should exclude pg_catalog via NOT IN"
);
}
#[test]
fn pg_column_query_is_valid() {
let sql = pg_columns_query("public", "orders");
assert!(
sql.contains("information_schema.columns"),
"should query information_schema.columns; got: {}",
sql
);
assert!(sql.contains("public"), "should contain schema name");
assert!(sql.contains("orders"), "should contain table name");
assert!(
sql.contains("FOREIGN KEY"),
"should include FK constraint metadata"
);
assert!(
sql.contains("PRIMARY KEY"),
"should include PK constraint metadata"
);
}
#[test]
fn pg_schemas_query_is_valid() {
let sql = pg_schemas_query();
assert!(sql.contains("information_schema.schemata"));
assert!(sql.contains("pg_catalog"));
}
#[test]
fn pg_databases_query_is_valid() {
let sql = pg_databases_query();
assert!(sql.contains("pg_database"));
assert!(sql.contains("datistemplate"));
}
// ---------------------------------------------------------------
// MySQL
// ---------------------------------------------------------------
#[test]
fn mysql_table_list_query_is_valid() {
let sql = mysql_tables_query(Some("mydb"));
assert!(
sql.contains("information_schema.tables"),
"should query information_schema.tables; got: {}",
sql
);
assert!(sql.contains("mydb"), "should contain the given schema");
let all_sql = mysql_tables_query(None);
assert!(all_sql.contains("information_schema.tables"));
assert!(all_sql.contains("performance_schema"));
}
// ---------------------------------------------------------------
// SQLite
// ---------------------------------------------------------------
#[test]
fn sqlite_table_list_query_is_valid() {
let sql = sqlite_tables_query();
assert!(
sql.contains("sqlite_master"),
"should query sqlite_master; got: {}",
sql
);
}
#[test]
fn sqlite_columns_query_is_valid() {
let sql = sqlite_columns_query("users");
assert!(
sql.contains("PRAGMA table_info"),
"should use PRAGMA table_info; got: {}",
sql
);
assert!(sql.contains("users"), "should contain table name");
}
#[test]
fn sqlite_foreign_keys_query_is_valid() {
let sql = sqlite_foreign_keys_query("orders");
assert!(
sql.contains("PRAGMA foreign_key_list"),
"should use PRAGMA foreign_key_list; got: {}",
sql
);
assert!(sql.contains("orders"), "should contain table name");
}
// ---------------------------------------------------------------
// Generic helpers
// ---------------------------------------------------------------
#[test]
fn build_paginated_query_with_limits() {
let columns = vec!["id".to_string(), "name".to_string()];
let (sql, params) = build_select_query("public", "users", &columns, 2, 25);
assert!(
sql.contains("LIMIT"),
"should contain LIMIT clause; got: {}",
sql
);
assert!(
sql.contains("OFFSET"),
"should contain OFFSET clause; got: {}",
sql
);
assert!(sql.contains("public"), "should contain schema name");
assert!(sql.contains("users"), "should contain table name");
assert!(sql.contains("\"id\""), "should quote column names");
assert!(sql.contains("\"name\""), "should quote column names");
// page=2, page_size=25 => offset = 50
assert_eq!(params, vec![25, 50], "params should be [page_size, offset]");
}
#[test]
fn build_paginated_query_empty_columns_uses_star() {
let (sql, _) = build_select_query("public", "users", &[], 0, 10);
assert!(
sql.contains('*'),
"empty columns should produce SELECT *; got: {}",
sql
);
}
#[test]
fn build_count_query_is_valid() {
let sql = build_count_query("public", "orders");
assert!(
sql.contains("COUNT(*)"),
"should contain COUNT(*); got: {}",
sql
);
assert!(sql.contains("public"), "should contain schema name");
assert!(sql.contains("orders"), "should contain table name");
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod pool;
pub mod introspection;
#[allow(unused_imports)]
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
+264
View File
@@ -0,0 +1,264 @@
use serde::{Deserialize, Serialize};
use std::time::Instant;
/// Configuration for establishing a database connection.
///
/// Fields map to connection parameters. For SQLite, `host` stores the
/// file path and `port` is always `None`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbConfig {
pub db_type: String,
pub host: String,
pub port: Option<i64>,
pub username: Option<String>,
pub password: Option<String>,
pub database: Option<String>,
pub ssl_mode: Option<String>,
pub ssl_ca_path: Option<String>,
pub ssl_cert_path: Option<String>,
pub ssl_key_path: Option<String>,
}
impl DbConfig {
/// Create a `DbConfig` for a SQLite database at `path`.
///
/// `host` is set to the file path; all other optional fields are `None`.
pub fn sqlite(path: &str) -> Self {
Self {
db_type: "SQLite".into(),
host: path.into(),
port: None,
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
}
}
}
/// A handle to an active database connection.
///
/// Supports `Sqlite` (synchronous via `rusqlite`) and
/// `Postgresql` (async via `tokio-postgres`). MySQL and Redis
/// variants will be added in later tasks.
#[derive(Debug)]
pub enum DbHandle {
/// A synchronous SQLite connection via `rusqlite`.
Sqlite(rusqlite::Connection),
/// An asynchronous PostgreSQL connection via `tokio-postgres`.
/// Stores the client handle and the background connection task.
Postgresql(tokio_postgres::Client, tokio::task::JoinHandle<()>),
}
/// Internal entry stored in the pool manager.
///
/// Tracks the database handle and the last time it was accessed for LRU
/// eviction.
#[derive(Debug)]
pub(crate) struct DbPoolEntry {
pub(crate) handle: DbHandle,
pub(crate) last_accessed: Instant,
}
/// A connection pool manager with LRU eviction.
///
/// Manages a set of active database handles keyed by a user-defined
/// identifier. When the number of registered pools exceeds `max_pools`,
/// the least-recently-used entry (i.e. the pool whose handle was accessed
/// furthest in the past) is evicted.
///
/// Default `max_pools` is 5.
pub struct ConnectionPoolManager {
pools: indexmap::IndexMap<String, DbPoolEntry>,
max_pools: usize,
}
impl ConnectionPoolManager {
/// Create a new manager with a maximum of 5 pools.
pub fn new() -> Self {
Self {
pools: indexmap::IndexMap::new(),
max_pools: 5,
}
}
/// Set the maximum number of pools before LRU eviction kicks in.
///
/// If the current pool count exceeds the new maximum, the oldest
/// entries are evicted immediately.
pub fn set_max_pools(&mut self, max: usize) {
self.max_pools = max;
while self.pools.len() > self.max_pools {
self.pools.shift_remove_index(0);
}
}
/// Register a new database handle under `id`.
///
/// * If `id` already exists the old entry is removed first.
/// * The new entry is inserted as the most-recently-used.
/// * If the total pool count exceeds `max_pools` the least-recently-used
/// (oldest) entry is evicted.
pub fn register(&mut self, id: &str, handle: DbHandle) {
// Remove existing entry if present
self.pools.shift_remove(id);
let entry = DbPoolEntry {
handle,
last_accessed: Instant::now(),
};
self.pools.insert(id.to_string(), entry);
// LRU eviction: remove oldest (front) entries until within capacity
while self.pools.len() > self.max_pools {
self.pools.shift_remove_index(0);
}
}
/// Get a mutable reference to the handle for `id`, or `None`.
///
/// Updates the last-accessed timestamp and re-orders the entry to
/// mark it as most-recently-used.
pub fn get(&mut self, id: &str) -> Option<&mut DbHandle> {
if let Some((key, mut entry)) = self.pools.shift_remove_entry(id) {
entry.last_accessed = Instant::now();
self.pools.insert(key, entry);
// The newly inserted entry is at the end (MRU position)
self.pools.last_mut().map(|(_, e)| &mut e.handle)
} else {
None
}
}
/// Remove the pool with `id` from the manager.
pub fn remove(&mut self, id: &str) {
self.pools.shift_remove(id);
}
/// Return a reference to the underlying pool map.
pub(crate) fn pools(&self) -> &indexmap::IndexMap<String, DbPoolEntry> {
&self.pools
}
/// Return `true` if a pool with `id` is registered.
pub fn contains(&self, id: &str) -> bool {
self.pools.contains_key(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
// ------------------------------------------------------------------
// DbConfig tests
// ------------------------------------------------------------------
#[test]
fn create_pg_pool_with_minimal_config() {
let cfg = DbConfig {
db_type: "PostgreSQL".into(),
host: "pg.example.com".into(),
port: Some(5432),
username: Some("admin".into()),
password: Some("secret".into()),
database: Some("mydb".into()),
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
assert_eq!(cfg.db_type, "PostgreSQL");
assert_eq!(cfg.host, "pg.example.com");
assert_eq!(cfg.port, Some(5432));
assert_eq!(cfg.username.as_deref(), Some("admin"));
assert_eq!(cfg.password.as_deref(), Some("secret"));
assert_eq!(cfg.database.as_deref(), Some("mydb"));
assert!(cfg.ssl_mode.is_none());
}
#[test]
fn db_config_for_sqlite_has_no_port() {
let cfg = DbConfig::sqlite("/tmp/test.db");
assert_eq!(cfg.db_type, "SQLite");
assert_eq!(cfg.host, "/tmp/test.db");
assert!(cfg.port.is_none());
assert!(cfg.username.is_none());
assert!(cfg.database.is_none());
}
// ------------------------------------------------------------------
// ConnectionPoolManager tests
// ------------------------------------------------------------------
#[test]
fn pool_manager_starts_empty() {
let manager = ConnectionPoolManager::new();
assert_eq!(manager.pools().len(), 0);
}
#[test]
fn pool_manager_register_and_evict() {
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(2);
let conn_a = rusqlite::Connection::open_in_memory().unwrap();
let conn_b = rusqlite::Connection::open_in_memory().unwrap();
let conn_c = rusqlite::Connection::open_in_memory().unwrap();
// Register A, B, then C with max=2 -- A should be evicted (LRU)
manager.register("a", DbHandle::Sqlite(conn_a));
manager.register("b", DbHandle::Sqlite(conn_b));
manager.register("c", DbHandle::Sqlite(conn_c));
assert_eq!(manager.pools().len(), 2);
assert!(!manager.contains("a"), "'a' should have been evicted (LRU)");
assert!(manager.contains("b"));
assert!(manager.contains("c"));
}
#[test]
fn pool_manager_remove_closes_pool() {
let mut manager = ConnectionPoolManager::new();
let conn = rusqlite::Connection::open_in_memory().unwrap();
manager.register("tmp", DbHandle::Sqlite(conn));
assert!(manager.contains("tmp"));
manager.remove("tmp");
assert!(!manager.contains("tmp"));
assert_eq!(manager.pools().len(), 0);
}
#[test]
fn pool_manager_get_updates_access_time() {
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(3);
let conn_a = rusqlite::Connection::open_in_memory().unwrap();
let conn_b = rusqlite::Connection::open_in_memory().unwrap();
let conn_c = rusqlite::Connection::open_in_memory().unwrap();
manager.register("a", DbHandle::Sqlite(conn_a));
manager.register("b", DbHandle::Sqlite(conn_b));
manager.register("c", DbHandle::Sqlite(conn_c));
// Access "a" -- makes it MRU
let _handle = manager.get("a").unwrap();
// Register "d" with max=3 -- "b" (now LRU) should be evicted, not "a"
let conn_d = rusqlite::Connection::open_in_memory().unwrap();
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("b"), "'b' is LRU and should be evicted");
assert!(manager.contains("c"));
assert!(manager.contains("d"));
}
}
+77 -1
View File
@@ -1,3 +1,26 @@
// Infrastructure modules: types, introspection, and DB viewer commands are built ahead
// of runtime usage, producing expected dead_code/unused warnings during development.
#![allow(dead_code)]
mod db;
mod models;
mod store;
mod commands;
use std::sync::Mutex as StdMutex;
use tauri::Manager;
use store::Store;
use commands::ssh::SshTunnelManager;
use db::pool::ConnectionPoolManager;
pub struct AppState {
pub db_store: StdMutex<Store>,
pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>,
pub ssh_manager: StdMutex<SshTunnelManager>,
}
use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
@@ -6,9 +29,62 @@ fn greet(name: &str) -> String {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let store = Store::open("gridline.db").expect("failed to open db");
let store_ref = StdMutex::new(store);
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_keyring_store::init())
.manage(AppState {
db_store: store_ref,
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
ssh_manager: StdMutex::new(SshTunnelManager::new()),
})
.setup(move |app| {
let state = app.state::<AppState>();
demo::ensure_demo_db(app.handle(), &state.db_store)
.map_err(|e| {
eprintln!("Failed to set up demo DB: {e}");
})
.ok();
Ok(())
})
.invoke_handler(tauri::generate_handler![
greet,
connections::get_connections,
connections::create_connection,
connections::update_connection,
connections::delete_connection,
connections::add_connection_tags,
folders::get_folders,
folders::create_folder,
folders::delete_folder,
folders::update_folder,
folders::add_folder_tags,
tags::get_tags,
tags::create_tag,
tags::delete_tag,
tags::update_tag,
settings::get_settings,
settings::update_setting,
import_export::import_connections,
import_export::export_connections,
commands::test_connection::test_connection,
db_viewer::db_connect,
db_viewer::db_disconnect,
db_viewer::get_databases,
db_viewer::get_schemas,
db_viewer::get_tables,
db_viewer::get_table_data,
db_viewer::get_fk_preview,
db_viewer::execute_change,
db_viewer::refresh_connection,
keychain::save_connection_password,
keychain::get_connection_password,
keychain::delete_connection_password,
demo::recreate_demo_db,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+136
View File
@@ -0,0 +1,136 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Connection {
pub id: String,
pub name: String,
pub db_type: String,
pub host: String,
pub port: Option<i64>,
pub username: Option<String>,
pub database: Option<String>,
pub folder_id: Option<String>,
pub keychain_ref: Option<String>,
pub environment: Option<String>,
pub ssh_host: Option<String>,
pub ssh_port: Option<i64>,
pub ssh_user: Option<String>,
pub ssh_auth_method: Option<String>,
pub ssh_private_key_path: Option<String>,
pub ssl_mode: Option<String>,
pub ssl_ca_path: Option<String>,
pub ssl_cert_path: Option<String>,
pub ssl_key_path: Option<String>,
pub tag_ids: Vec<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionInput {
pub name: String,
pub db_type: String,
pub host: String,
pub port: Option<i64>,
pub username: Option<String>,
pub folder_id: Option<String>,
pub tag_ids: Vec<String>,
pub password: Option<String>,
pub database: Option<String>,
pub environment: Option<String>,
pub ssh_host: Option<String>,
pub ssh_port: Option<i64>,
pub ssh_user: Option<String>,
pub ssh_auth_method: Option<String>,
pub ssh_private_key_path: Option<String>,
pub ssh_passphrase: Option<String>,
pub ssl_mode: Option<String>,
pub ssl_ca_path: Option<String>,
pub ssl_cert_path: Option<String>,
pub ssl_key_path: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_input_roundtrip_with_all_fields() {
let input = ConnectionInput {
name: "Test DB".to_string(),
db_type: "PostgreSQL".to_string(),
host: "db.example.com".to_string(),
port: Some(5432),
username: Some("admin".to_string()),
folder_id: Some("folder1".to_string()),
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
password: Some("secret123".to_string()),
database: Some("mydb".to_string()),
ssh_host: Some("jumphost.example.com".to_string()),
ssh_port: Some(2222),
ssh_user: Some("tunnel".to_string()),
ssh_auth_method: Some("Key".to_string()),
ssh_private_key_path: Some("/path/to/key".to_string()),
ssh_passphrase: Some("passphrase".to_string()),
ssl_mode: Some("require".to_string()),
ssl_ca_path: Some("/path/to/ca".to_string()),
ssl_cert_path: Some("/path/to/cert".to_string()),
ssl_key_path: Some("/path/to/key".to_string()),
environment: Some("production".to_string()),
};
let json = serde_json::to_string(&input).unwrap();
let deserialized: ConnectionInput = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "Test DB");
assert_eq!(deserialized.db_type, "PostgreSQL");
assert_eq!(deserialized.host, "db.example.com");
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.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_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_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_key_path, Some("/path/to/key".to_string()));
}
#[test]
fn connection_persisted_does_not_include_password() {
let conn = Connection {
id: "test-id".to_string(),
name: "Test".to_string(),
db_type: "PostgreSQL".to_string(),
host: "localhost".to_string(),
port: Some(5432),
username: Some("user".to_string()),
folder_id: Some("folder".to_string()),
keychain_ref: Some("keychain-ref".to_string()),
environment: None,
tag_ids: vec![],
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
database: Some("mydb".to_string()),
ssh_host: Some("ssh-host".to_string()),
ssh_port: Some(2222),
ssh_user: Some("ssh-user".to_string()),
ssh_auth_method: Some("Key".to_string()),
ssh_private_key_path: Some("/path/to/key".to_string()),
ssl_mode: Some("require".to_string()),
ssl_ca_path: Some("/path/to/ca".to_string()),
ssl_cert_path: Some("/path/to/cert".to_string()),
ssl_key_path: Some("/path/to/key".to_string()),
};
let json = serde_json::to_string(&conn).unwrap();
assert!(!json.contains("password"), "Connection JSON should not contain password field");
}
}
+194
View File
@@ -0,0 +1,194 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableInfo {
pub name: String,
pub schema: String,
pub table_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnInfo {
pub name: String,
pub data_type: String,
pub is_nullable: bool,
pub is_pk: bool,
pub is_fk: bool,
pub fk_ref: Option<(String, String)>,
pub default_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResult {
pub columns: Vec<ColumnInfo>,
pub rows: Vec<Vec<serde_json::Value>>,
pub total_rows: i64,
pub page: i64,
pub page_size: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pagination {
pub page: i64,
pub page_size: i64,
pub total_rows: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Change {
Update {
id: String,
schema: String,
table: String,
primary_key: String,
old_data: String,
new_data: String,
},
Insert {
id: String,
schema: String,
table: String,
data: String,
},
Delete {
id: String,
schema: String,
table: String,
primary_key: String,
},
AlterTable {
id: String,
schema: String,
table: String,
sql: String,
rollback_sql: String,
},
}
impl Change {
pub fn id(&self) -> &str {
match self {
Change::Update { id, .. }
| Change::Insert { id, .. }
| Change::Delete { id, .. }
| Change::AlterTable { id, .. } => id,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_info_serialization() {
let info = TableInfo {
name: "users".to_string(),
schema: "public".to_string(),
table_type: "TABLE".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("users"));
assert!(json.contains("public"));
assert!(json.contains("TABLE"));
}
#[test]
fn query_result_can_be_empty() {
let result = QueryResult {
columns: vec![],
rows: vec![],
total_rows: 0,
page: 1,
page_size: 100,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains(r#""rows":[]"#));
}
#[test]
fn change_id_method() {
let update = Change::Update {
id: "chg-1".to_string(),
schema: "public".to_string(),
table: "users".to_string(),
primary_key: "{\"id\": 1}".to_string(),
old_data: "{\"name\": \"old\"}".to_string(),
new_data: "{\"name\": \"new\"}".to_string(),
};
assert_eq!(update.id(), "chg-1");
let inserted = Change::Insert {
id: "chg-2".to_string(),
schema: "public".to_string(),
table: "users".to_string(),
data: "{\"name\": \"alice\"}".to_string(),
};
assert_eq!(inserted.id(), "chg-2");
let deleted = Change::Delete {
id: "chg-3".to_string(),
schema: "public".to_string(),
table: "users".to_string(),
primary_key: "{\"id\": 2}".to_string(),
};
assert_eq!(deleted.id(), "chg-3");
let alter = Change::AlterTable {
id: "chg-4".to_string(),
schema: "public".to_string(),
table: "users".to_string(),
sql: "ALTER TABLE users ADD COLUMN age INT".to_string(),
rollback_sql: "ALTER TABLE users DROP COLUMN age".to_string(),
};
assert_eq!(alter.id(), "chg-4");
}
#[test]
fn change_serde_tag() {
let update = Change::Update {
id: "chg-1".to_string(),
schema: "public".to_string(),
table: "users".to_string(),
primary_key: "{\"id\": 1}".to_string(),
old_data: "{\"name\": \"old\"}".to_string(),
new_data: "{\"name\": \"new\"}".to_string(),
};
let json = serde_json::to_string(&update).unwrap();
assert!(
json.contains(r#""type":"update""#),
"serialized Change::Update should use snake_case tag 'update'; got: {}",
json
);
}
#[test]
fn column_info_fk_ref() {
let col = ColumnInfo {
name: "user_id".to_string(),
data_type: "integer".to_string(),
is_nullable: true,
is_pk: false,
is_fk: true,
fk_ref: Some(("users".to_string(), "id".to_string())),
default_value: None,
};
let json = serde_json::to_string(&col).unwrap();
assert!(json.contains("user_id"));
assert!(json.contains("users"));
}
#[test]
fn pagination_serialization() {
let pagination = Pagination {
page: 2,
page_size: 50,
total_rows: 250,
};
let json = serde_json::to_string(&pagination).unwrap();
assert!(json.contains(r#""page":2"#));
assert!(json.contains(r#""page_size":50"#));
assert!(json.contains(r#""total_rows":250"#));
}
}
+18
View File
@@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Folder {
pub id: String,
pub name: String,
pub parent_id: Option<String>,
pub tag_ids: Vec<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FolderInput {
pub name: String,
pub parent_id: Option<String>,
pub tag_ids: Option<Vec<String>>,
}
+12
View File
@@ -0,0 +1,12 @@
pub mod connection;
pub mod db_viewer;
pub mod folder;
pub mod tag;
pub mod settings;
pub use connection::{Connection, ConnectionInput};
#[allow(unused_imports)]
pub use db_viewer::{Change, ColumnInfo, Pagination, QueryResult, TableInfo};
pub use folder::{Folder, FolderInput};
pub use settings::Settings;
pub use tag::{Tag, TagInput};
+15
View File
@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub confirm_before_delete: bool,
pub default_folder_id: Option<String>,
pub theme: String,
pub font_size: String,
pub default_ports: HashMap<String, Option<i64>>,
pub tag_order: Option<String>,
pub table_refresh_rate: i64,
pub table_page_size: i64,
pub shortcuts: HashMap<String, String>,
}
+15
View File
@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
pub id: String,
pub name: String,
pub color: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagInput {
pub name: String,
pub color: String,
}
+188
View File
@@ -0,0 +1,188 @@
use rusqlite::Connection;
/// All new-column additions for the connections table since version 1.
const CONNECTION_COLUMNS_V2: &[(&str, &str)] = &[
("database", "TEXT"),
("ssh_host", "TEXT"),
("ssh_port", "INTEGER"),
("ssh_user", "TEXT"),
("ssh_auth_method", "TEXT"),
("ssh_private_key_path", "TEXT"),
("ssl_mode", "TEXT"),
("ssl_ca_path", "TEXT"),
("ssl_cert_path", "TEXT"),
("ssl_key_path", "TEXT"),
];
/// New columns added since version 2.
const CONNECTION_COLUMNS_V3: &[(&str, &str)] = &[
("environment", "TEXT"),
];
pub fn run_migrations(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
db_type TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER,
username TEXT,
database TEXT,
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
keychain_ref TEXT,
ssh_host TEXT,
ssh_port INTEGER,
ssh_user TEXT,
ssh_auth_method TEXT,
ssh_private_key_path TEXT,
ssl_mode TEXT,
ssl_ca_path TEXT,
ssl_cert_path TEXT,
ssl_key_path TEXT,
environment TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL DEFAULT '#8b5cf6',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS connection_tags (
connection_id TEXT NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (connection_id, tag_id)
);
CREATE TABLE IF NOT EXISTS folder_tags (
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (folder_id, tag_id)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)
.map_err(|e| e.to_string())?;
// --- Version-specific migrations ----------------------------------------
let current_ver: i64 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
[],
|row| row.get(0),
)
.unwrap_or(0);
if current_ver < 2 {
// Discover which columns the connections table already has.
let existing: Vec<String> = {
let mut stmt = conn
.prepare("PRAGMA table_info(connections)")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for (col_name, col_type) in CONNECTION_COLUMNS_V2 {
if !existing.contains(&col_name.to_string()) {
let sql = format!(
"ALTER TABLE connections ADD COLUMN {} {}",
col_name, col_type
);
conn.execute(&sql, []).map_err(|e| e.to_string())?;
}
}
// Record the migration.
conn.execute(
"INSERT INTO schema_version (version) VALUES (2)",
[],
)
.map_err(|e| e.to_string())?;
}
if current_ver < 3 {
let existing: Vec<String> = {
let mut stmt = conn
.prepare("PRAGMA table_info(connections)")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for (col_name, col_type) in CONNECTION_COLUMNS_V3 {
if !existing.contains(&col_name.to_string()) {
let sql = format!(
"ALTER TABLE connections ADD COLUMN {} {}",
col_name, col_type
);
conn.execute(&sql, []).map_err(|e| e.to_string())?;
}
}
conn.execute(
"INSERT INTO schema_version (version) VALUES (3)",
[],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn fresh_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
conn
}
#[test]
fn migrations_create_all_tables() {
let conn = fresh_db();
let tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(tables.contains(&"folders".to_string()));
assert!(tables.contains(&"connections".to_string()));
assert!(tables.contains(&"tags".to_string()));
assert!(tables.contains(&"connection_tags".to_string()));
assert!(tables.contains(&"settings".to_string()));
assert!(tables.contains(&"schema_version".to_string()));
}
#[test]
fn migrations_are_idempotent() {
let conn = fresh_db();
// Running again must not error
run_migrations(&conn).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 2);
}
}
+742
View File
@@ -0,0 +1,742 @@
pub mod migrations;
use rusqlite::params;
use rusqlite::Connection as SqliteConnection;
use std::collections::HashMap;
use std::sync::Mutex;
use crate::models::{Connection, ConnectionInput, Folder, FolderInput, Settings, Tag, TagInput};
pub struct Store {
conn: Mutex<SqliteConnection>,
}
impl Store {
pub fn from_connection(conn: SqliteConnection) -> Self {
Self {
conn: Mutex::new(conn),
}
}
pub fn open(path: &str) -> Result<Self, String> {
let conn = SqliteConnection::open(path).map_err(|e| e.to_string())?;
migrations::run_migrations(&conn).map_err(|e| e.to_string())?;
Ok(Self::from_connection(conn))
}
fn now() -> String {
chrono::Utc::now().to_rfc3339()
}
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")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(Folder {
id: row.get(0)?,
name: row.get(1)?,
parent_id: row.get(2)?,
tag_ids: vec![],
created_at: row.get(3)?,
updated_at: row.get(4)?,
})
})
.map_err(|e| e.to_string())?;
let mut folders: Vec<Folder> = rows.filter_map(|r| r.ok()).collect();
// Load tags for each folder
for f in folders.iter_mut() {
let mut tag_stmt = conn
.prepare("SELECT tag_id FROM folder_tags WHERE folder_id = ?1")
.map_err(|e| e.to_string())?;
let tag_rows = tag_stmt
.query_map(params![f.id], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?;
f.tag_ids = tag_rows.filter_map(|r| r.ok()).collect();
}
Ok(folders)
}
pub fn create_folder(&self, input: FolderInput) -> Result<Folder, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string();
let now = Self::now();
conn.execute(
"INSERT INTO folders (id, name, parent_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, input.name, input.parent_id, now, now],
)
.map_err(|e| e.to_string())?;
let tag_ids = input.tag_ids.unwrap_or_default();
for tag_id in &tag_ids {
conn.execute(
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
params![id, tag_id],
)
.map_err(|e| e.to_string())?;
}
Ok(Folder {
id,
name: input.name,
parent_id: input.parent_id,
tag_ids,
created_at: now.clone(),
updated_at: now,
})
}
pub fn update_folder(&self, id: &str, input: FolderInput) -> Result<Folder, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
conn.execute(
"UPDATE folders SET name = ?1, parent_id = ?2, updated_at = ?3 WHERE id = ?4",
params![input.name, input.parent_id, now, id],
)
.map_err(|e| e.to_string())?;
// Replace all tags: clear existing, insert new
conn.execute("DELETE FROM folder_tags WHERE folder_id = ?1", params![id])
.map_err(|e| e.to_string())?;
let tag_ids = input.tag_ids.unwrap_or_default();
for tag_id in &tag_ids {
conn.execute(
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
params![id, tag_id],
)
.map_err(|e| e.to_string())?;
}
Ok(Folder {
id: id.to_string(),
name: input.name,
parent_id: input.parent_id,
tag_ids,
created_at: now.clone(),
updated_at: now,
})
}
pub fn delete_folder(&self, id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
// Get the folder's parent_id to reparent children
let parent_id: Option<String> = conn
.query_row(
"SELECT parent_id FROM folders WHERE id = ?1",
params![id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
// Move child folders to the parent
conn.execute(
"UPDATE folders SET parent_id = ?1 WHERE parent_id = ?2",
params![parent_id, id],
)
.map_err(|e| e.to_string())?;
// Move child connections to the parent
conn.execute(
"UPDATE connections SET folder_id = ?1 WHERE folder_id = ?2",
params![parent_id, id],
)
.map_err(|e| e.to_string())?;
// Delete the folder
conn.execute("DELETE FROM folders WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn add_folder_tags(&self, folder_id: &str, tag_ids: &[String]) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
for tag_id in tag_ids {
conn.execute(
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
params![folder_id, tag_id],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn add_connection_tags(&self, conn_id: &str, tag_ids: &[String]) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
for tag_id in tag_ids {
conn.execute(
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
params![conn_id, tag_id],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn get_tags(&self) -> Result<Vec<Tag>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, name, color, created_at FROM tags ORDER BY name")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(Tag {
id: row.get(0)?,
name: row.get(1)?,
color: row.get(2)?,
created_at: row.get(3)?,
})
})
.map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn create_tag(&self, input: TagInput) -> Result<Tag, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string();
let now = Self::now();
conn.execute(
"INSERT INTO tags (id, name, color, created_at) VALUES (?1, ?2, ?3, ?4)",
params![id, input.name, input.color, now],
)
.map_err(|e| e.to_string())?;
Ok(Tag {
id,
name: input.name,
color: input.color,
created_at: now,
})
}
pub fn delete_tag(&self, id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn update_tag(&self, id: &str, input: TagInput) -> Result<Tag, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
conn.execute(
"UPDATE tags SET name = ?1, color = ?2 WHERE id = ?3",
params![input.name, input.color, id],
)
.map_err(|e| e.to_string())?;
Ok(Tag {
id: id.to_string(),
name: input.name,
color: input.color,
created_at: now,
})
}
pub fn get_connections(&self) -> Result<Vec<Connection>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at FROM connections ORDER BY name",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(Connection {
id: row.get(0)?,
name: row.get(1)?,
db_type: row.get(2)?,
host: row.get(3)?,
port: row.get(4)?,
username: row.get(5)?,
database: row.get(6)?,
folder_id: row.get(7)?,
keychain_ref: row.get(8)?,
ssh_host: row.get(9)?,
ssh_port: row.get(10)?,
ssh_user: row.get(11)?,
ssh_auth_method: row.get(12)?,
ssh_private_key_path: row.get(13)?,
ssl_mode: row.get(14)?,
ssl_ca_path: row.get(15)?,
ssl_cert_path: row.get(16)?,
ssl_key_path: row.get(17)?,
environment: row.get(18)?,
tag_ids: vec![],
created_at: row.get(19)?,
updated_at: row.get(20)?,
})
})
.map_err(|e| e.to_string())?;
let mut conns: Vec<Connection> = rows.filter_map(|r| r.ok()).collect();
// Load tags for each connection
for c in conns.iter_mut() {
let mut tag_stmt = conn
.prepare("SELECT tag_id FROM connection_tags WHERE connection_id = ?1")
.map_err(|e| e.to_string())?;
let tag_rows = tag_stmt
.query_map(params![c.id], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?;
c.tag_ids = tag_rows.filter_map(|r| r.ok()).collect();
}
Ok(conns)
}
pub fn create_connection(&self, input: ConnectionInput) -> Result<Connection, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string();
let now = Self::now();
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now],
)
.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())?;
}
Ok(Connection {
id,
name: input.name,
db_type: input.db_type,
host: input.host,
port: input.port,
username: input.username,
folder_id: input.folder_id,
database: input.database,
keychain_ref: None,
environment: input.environment,
ssh_host: input.ssh_host,
ssh_port: input.ssh_port,
ssh_user: input.ssh_user,
ssh_auth_method: input.ssh_auth_method,
ssh_private_key_path: input.ssh_private_key_path,
ssl_mode: input.ssl_mode,
ssl_ca_path: input.ssl_ca_path,
ssl_cert_path: input.ssl_cert_path,
ssl_key_path: input.ssl_key_path,
tag_ids: input.tag_ids,
created_at: now.clone(),
updated_at: now,
})
}
pub fn delete_connection(&self, id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM connections WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
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(
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, updated_at=?18 WHERE id=?19",
params![
input.name, input.db_type, input.host, input.port, input.username,
input.database, input.folder_id, input.ssh_host, input.ssh_port,
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
input.environment, now, id
],
).map_err(|e| e.to_string())?;
// Update tags
conn.execute("DELETE FROM connection_tags WHERE connection_id = ?1", params![id])
.map_err(|e| e.to_string())?;
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())?;
}
Ok(Connection {
id: id.to_string(),
name: input.name,
db_type: input.db_type,
host: input.host,
port: input.port,
username: input.username,
database: input.database,
folder_id: input.folder_id,
keychain_ref: None,
environment: input.environment,
ssh_host: input.ssh_host,
ssh_port: input.ssh_port,
ssh_user: input.ssh_user,
ssh_auth_method: input.ssh_auth_method,
ssh_private_key_path: input.ssh_private_key_path,
ssl_mode: input.ssl_mode,
ssl_ca_path: input.ssl_ca_path,
ssl_cert_path: input.ssl_cert_path,
ssl_key_path: input.ssl_key_path,
tag_ids: input.tag_ids.clone(),
created_at: String::new(), // not updated
updated_at: now,
})
}
pub fn get_settings(&self) -> Result<Settings, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut map: HashMap<String, String> = HashMap::new();
let mut stmt = conn
.prepare("SELECT key, value FROM settings")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
))
})
.map_err(|e| e.to_string())?;
for r in rows.filter_map(|r| r.ok()) {
map.insert(r.0, r.1);
}
let theme = map
.get("theme")
.cloned()
.unwrap_or_else(|| "system".to_string());
let font_size = map
.get("font_size")
.cloned()
.unwrap_or_else(|| "medium".to_string());
let confirm = map
.get("confirm_before_delete")
.map(|v| v == "true")
.unwrap_or(true);
let default_folder_id = map
.get("default_folder_id")
.filter(|v| v.as_str() != "null")
.cloned();
let mut default_ports = HashMap::new();
default_ports.insert("postgresql".to_string(), Some(5432i64));
default_ports.insert("mysql".to_string(), Some(3306i64));
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)
{
default_ports = parsed;
}
}
Ok(Settings {
confirm_before_delete: confirm,
default_folder_id,
theme,
font_size,
default_ports,
tag_order: map.get("tag_order").cloned(),
table_refresh_rate: map
.get("table_refresh_rate")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
table_page_size: map
.get("table_page_size")
.and_then(|v| v.parse().ok())
.unwrap_or(50),
shortcuts: map
.get("shortcuts")
.and_then(|v| serde_json::from_str(v).ok())
.unwrap_or_default(),
})
}
pub fn update_setting(&self, key: &str, value: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)
.map_err(|e| e.to_string())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ConnectionInput, FolderInput, TagInput};
fn fresh_store() -> Store {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
Store::from_connection(conn)
}
#[test]
fn create_and_get_folder() {
let store = fresh_store();
let folder = store
.create_folder(FolderInput { tag_ids: None,
name: "Work".into(),
parent_id: None,
})
.unwrap();
let got = store.get_folders().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "Work");
assert_eq!(got[0].id, folder.id);
assert!(got[0].parent_id.is_none());
}
#[test]
fn create_nested_folders() {
let store = fresh_store();
let parent = store
.create_folder(FolderInput { tag_ids: None,
name: "root".into(),
parent_id: None,
})
.unwrap();
let child = store
.create_folder(FolderInput { tag_ids: None,
name: "child".into(),
parent_id: Some(parent.id.clone()),
})
.unwrap();
assert_eq!(child.parent_id, Some(parent.id));
}
#[test]
fn create_and_get_tag() {
let store = fresh_store();
let tag = store
.create_tag(TagInput {
name: "production".into(),
color: "#ef4444".into(),
})
.unwrap();
let got = store.get_tags().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "production");
assert_eq!(got[0].color, "#ef4444");
assert_eq!(got[0].id, tag.id);
}
#[test]
fn create_and_get_connection() {
let store = fresh_store();
let conn = store
.create_connection(ConnectionInput {
name: "Prod".into(),
db_type: "postgresql".into(),
host: "prod.example.com".into(),
port: Some(5432),
username: Some("admin".into()),
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_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: vec![],
})
.unwrap();
let got = store.get_connections().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "Prod");
assert_eq!(got[0].port, Some(5432));
assert!(got[0].tag_ids.is_empty());
assert_eq!(got[0].id, conn.id);
}
#[test]
fn connection_with_tags_persists_join() {
let store = fresh_store();
let t1 = store
.create_tag(TagInput {
name: "prod".into(),
color: "#ef4444".into(),
})
.unwrap();
let t2 = store
.create_tag(TagInput {
name: "primary".into(),
color: "#3b82f6".into(),
})
.unwrap();
store
.create_connection(ConnectionInput {
name: "Prod".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_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: vec![t1.id.clone(), t2.id.clone()],
})
.unwrap();
let got = store.get_connections().unwrap();
assert_eq!(got[0].tag_ids.len(), 2);
assert!(got[0].tag_ids.contains(&t1.id));
assert!(got[0].tag_ids.contains(&t2.id));
}
#[test]
fn delete_folder_sets_connection_folder_null() {
let store = fresh_store();
let folder = store
.create_folder(FolderInput { tag_ids: None,
name: "f".into(),
parent_id: None,
})
.unwrap();
store
.create_connection(ConnectionInput {
name: "C".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: Some(folder.id.clone()),
password: None,
database: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: vec![],
})
.unwrap();
store.delete_folder(&folder.id).unwrap();
let conns = store.get_connections().unwrap();
assert!(conns[0].folder_id.is_none());
}
#[test]
fn delete_tag_removes_from_connection() {
let store = fresh_store();
let tag = store
.create_tag(TagInput {
name: "prod".into(),
color: "#ef4444".into(),
})
.unwrap();
store
.create_connection(ConnectionInput {
name: "C".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_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: vec![tag.id.clone()],
})
.unwrap();
store.delete_tag(&tag.id).unwrap();
let conns = store.get_connections().unwrap();
assert!(conns[0].tag_ids.is_empty());
}
#[test]
fn settings_get_returns_defaults_when_empty() {
let store = fresh_store();
let settings = store.get_settings().unwrap();
assert_eq!(settings.theme, "system");
assert_eq!(settings.font_size, "medium");
assert!(settings.confirm_before_delete);
assert_eq!(
settings.default_ports.get("postgresql"),
Some(&Some(5432))
);
}
#[test]
fn settings_update_persists() {
let store = fresh_store();
store.update_setting("theme", "light").unwrap();
let settings = store.get_settings().unwrap();
assert_eq!(settings.theme, "light");
}
#[test]
fn ssh_ssl_fields_persist_and_retrieve() {
let store = fresh_store();
let conn = store
.create_connection(ConnectionInput {
name: "SSH-Tunnel-DB".into(),
db_type: "postgresql".into(),
host: "localhost".into(),
port: Some(5432),
username: Some("dbuser".into()),
folder_id: None,
password: None,
database: Some("analytics".into()),
ssh_host: Some("jumphost.example.com".into()),
ssh_port: Some(2222),
ssh_user: Some("tunneluser".into()),
ssh_auth_method: Some("Key".into()),
ssh_private_key_path: Some("/home/user/.ssh/id_rsa".into()),
ssh_passphrase: None,
ssl_mode: Some("verify-full".into()),
ssl_ca_path: Some("/etc/ssl/certs/ca.pem".into()),
ssl_cert_path: Some("/etc/ssl/certs/client-cert.pem".into()),
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
environment: None,
tag_ids: vec![],
})
.unwrap();
let got = store.get_connections().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].database.as_deref(), Some("analytics"));
assert_eq!(got[0].ssh_host.as_deref(), Some("jumphost.example.com"));
assert_eq!(got[0].ssh_port, Some(2222));
assert_eq!(got[0].ssh_user.as_deref(), Some("tunneluser"));
assert_eq!(got[0].ssh_auth_method.as_deref(), Some("Key"));
assert_eq!(
got[0].ssh_private_key_path.as_deref(),
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_cert_path.as_deref(),
Some("/etc/ssl/certs/client-cert.pem")
);
assert_eq!(
got[0].ssl_key_path.as_deref(),
Some("/etc/ssl/private/client-key.pem")
);
}
}
+5 -2
View File
@@ -14,7 +14,9 @@
{
"title": "Gridline",
"width": 1200,
"height": 800
"height": 800,
"backgroundColor": "#0A0A0B",
"titleBarStyle": "Transparent"
}
],
"security": {
@@ -31,5 +33,6 @@
"icons/icon.icns",
"icons/icon.ico"
]
}
},
"plugins": {}
}