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
@@ -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,6 +5,10 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
"core:window:allow-set-title",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"fs:default",
|
||||
"keyring-store:default"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 100 KiB |
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod pool;
|
||||
pub mod introspection;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"#));
|
||||
}
|
||||
}
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||