feat: initial Gridline implementation (#1)
* chore: configure Tailwind, Vitest, Rust deps, dialog plugin (Task 1)
* feat: shared types and validation/filter utilities (Task 2)
* feat: Rust models, SQLite store, and migrations (Task 3)
* feat: Tauri commands for connections and folders (Task 4)
* feat: tag, settings, import/export Tauri commands (Task 5)
* feat: frontend command wrappers and Zustand stores (Task 6)
* feat: useSearch debounce and useFilteredConnections hooks (Task 7)
* feat: App view router with load-on-mount (Task 8)
* feat: UI primitives Button, Input, Badge, Card (Task 9)
* feat: SearchBar, ActionRow, ImportExportMenu (Task 10)
* feat: ConnectionCard, ConnectionGrid, TagBadge (Task 11)
* fix: resolve TypeScript errors across utils, Input, and test files
* feat: FolderTree, CreateFolderDialog, NewConnectionForm, SettingsPage, HomeScreen (Task 12)
* feat: Tauri file dialog integration for import/export (Task 13)
* feat: error/loading states and full App router integration (Task 14)
* chore: final integration gate — full test + build green (Task 15)
* fix: move folders inline, rearrange ActionRow layout, add borders to buttons
* feat: upgrade to Tailwind v4, apply new color palette
* Landing page restyle, folder explorer, selection/delete, and icon update
- Black-and-white color scheme with blue accent
- Glassmorphic New Folder modal with auto-parent
- Nested folder explorer with breadcrumb navigation
- Folder/connection selection with hover checkboxes
- Select All / Clear Selection / Delete dropdown
- Delete reparents children to parent folder
- Fixed Rust serde camelCase mismatch (models now use snake_case)
- Updated app icons from Apple Icon Composer exports
- Search bar with ⌘K badge
- Button styling: rounded pills, borders, cursor-pointer
- Prevent drag selection across app
* Tag system, folder edit/delete, searchable tag picker, delete confirmation, tag reordering
- Added folder_tags table for folder-tag associations
- Tags now show configured colors on folder and connection cards
- Searchable tag picker with text filtering in New/Edit Folder dialogs
- Edit Folder dialog with name and tag management
- Edit/Delete buttons next to breadcrumb with icons
- Delete confirmation dialog with reparenting notice
- Tag reordering in Settings with up/down buttons, persisted via tag_order setting
- useSortedTags hook for consistent tag ordering
- Cmd+K focuses search input
- Selection dropdown simplified to Select All/Clear/Delete
- Click-outside closes all dropdowns
- cursor-pointer on all interactables
* deps: install motion for animations
* feat(types): add system theme option
* feat(settings): default stored theme to system
* feat(ui): add Toggle primitive
* feat(ui): add Select primitive
* feat(ui): add SettingsRow and SettingsSection primitives
* fix(ui): improve SettingsRow and SettingsSection quality
* feat(ui): add ThemePicker primitive
* feat(ui): add AnimatedModal primitive with motion
* refactor(dialogs): use AnimatedModal for enter/exit animations
* fix(dialogs): ensure exit animations and Escape handling work with AnimatedModal
* feat(settings): redesign page with multi-section layout
* refactor(settings): split tabs, improve accessibility and animation
* fix(tauri): set window background color to dark canvas
* fix(settings): remove fake traffic lights, add header background, transparent title bar
* fix(settings): move Back into sidebar, add Settings heading, dynamic window title
* fix(tauri): show native window title so Home/Settings labels are visible
* fix(ui): disable overscroll on both axes
* test(dialogs): verify CreateFolderDialog shows and saves tags
* chore: ignore .worktrees directory
* refactor: centralize db type icons and labels
* feat: add connection string parser and detection
* test: add edge cases for connection string parser
* fix: preserve absolute paths in SQLite connection strings
* types: add new connection form fields
* feat: add folder path label helper
* test: cover missing folder in path label helper
* feat: add stub connection string parse and test commands
* feat: add stub test connection command
* feat: add new connection form primitives
* feat: add connection form shell
* feat: add shared form type and simple connection form
* fix: keep SimpleConnectionForm fully controlled
* style: format and tighten types for simple connection form
* feat: add detailed connection form
* fix: export DetailedConnectionFormProps and assert port update
* feat: add new connection screen container
* fix: clean up useEffect dependencies in new connection screen
* fix: address quality feedback on new connection screen
* feat: wire new connection screen into app and remove old form
* feat: open new connection screen from pasted db url in search
* test: add home search URL shortcut integration test
* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown
Add a reusable SelectDropdown component that keeps the select-styled
trigger while using the ActionRow-style popover menu. Update both
FolderSelect and EnvironmentSelect to use it.
* refactor: remove db type icon and label from connection form shell
Drop DbTypeHeader and the db_type prop from ConnectionFormShell so the
form header no longer displays the database icon/type. Update
NewConnectionScreen and its test accordingly.
* feat: home screen redesign and connection form updates
- Redesign HomeScreen layout and settings page
- Update connection card, grid, and simple form
- Replace selects with SelectDropdown component
- Remove DbTypeHeader from connection form
- Fix tests to match updated components
* fix: hide folders during search, update placeholder, and set window titles
- ConnectionGrid now hides folders when hasSearch is true so only
matching connections are shown.
- SearchBar placeholder now mentions typing a database URL to create
a new connection.
- App window titles set to Gridline (home), Settings, and New Connection.
* fix: shorter placeholder, dynamic window title, and startup flash
- Shorten SearchBar placeholder to mention DB URL creation concisely.
- Add core:window:allow-set-title permission and set document.title so
the window title updates per view (Gridline, Settings, New Connection).
- Add inline dark background style to index.html to prevent white flash
before CSS loads.
* feat: extend connection models with SSH/SSL/database fields, add db_viewer models (Task 1a)
* feat: extend frontend types with SSH/SSL fields and DB viewer types (Task 1b)
* feat: migrate connections table with SSH/SSL columns (Task 1c)
* chore: add new Rust dependencies (tokio, sqlx, redis, ssh2, indexmap, etc.) for Phase 2
* feat: connection pool manager with LRU eviction (Task 2a)
* feat: schema introspection query builders for PG, MySQL, SQLite (Task 2b)
* feat: test connection command (all 4 DB types) and SSH tunnel manager (Task 2c)
* feat: db viewer Rust commands and AppState refactor (Task 2d)
* feat: frontend command wrappers for DB viewer and updated validation (Task 3a)
* feat: dbViewer store with tabs, changes queue, pagination; uiStore activeConnectionId (Task 3b)
* feat: ConnectionCard navigates to DB Viewer, App routes to DbViewerScreen (Task 3c)
* feat: tooltip UI primitive (Task 4a)
* feat: SSH/SSL form tabs in DetailedConnectionForm (Task 4b)
* feat: DbViewerScreen shell with sidebar navigation (Task 4c)
* feat: toolbar, table tree, and overflow menu (Task 4d)
* fix: align TableInfo interface between frontend types and test files
* feat: tab bar, data grid, and pagination controls (Task 4e)
* feat: changes queue panel with cancel per change (Task 4f)
* feat: wire real IPC connect/disconnect/load in DbViewerScreen (Task 5a)
* feat: error banner, guard dialogs for destructive actions (Task 5b)
* feat: changes queue commit all with per-change IPC and error handling (Task 5c)
* chore: suppress expected dead-code warnings during multi-phase development
* fix: wire ConnectionCard click through to DB Viewer (HomeScreen -> ConnectionGrid -> ConnectionCard -> App route)
* fix: click connection opens DB viewer when nothing selected, toggles selection when items already selected; fix delete connections + generic confirm message
* fix: align ConnectionTestResult field name (ok vs success) and fix testConnection invoke param name (input vs config) to match Rust backend
* feat: implement DB Viewer Tauri commands (db_connect, get_databases, etc.)
* fix: align DB viewer IPC param names (snake_case), implement execute_change + refresh_connection, snake_case Change enum tags
* fix: use camelCase IPC keys for multi-word Tauri command params (Tauri v2 converts snake_case Rust -> camelCase)
* fix: surface full postgres error detail and redact credentials instead of opaque 'db error'; URL-encode pg connection strings
* fix: cache passwords per-session so saved connections can auto-connect; surface full postgres error detail
* fix: auto-fetch table data, full-row click, fill viewport, load columns on expand, database switching
* fix: align ColumnInfo/QueryResult types with Rust backend (columns=ColumnInfo[], rows=unknown[][], field names match)
* feat: truncate cell text, resizable columns with drag handles, tab bar no-wrap horizontal scroll
* fix: QueryResult uses total_rows/page/page_size (match Rust), brighter table borders, pagination NaN fix
* fix: table horizontal scrolling, viewport fills screen height (h-screen), remove clipping overflow-hidden
* fix: constrain layout to viewport with overflow-hidden on content column; flex-1 fills height; sidebar+grid scroll independently
* fix: move overflow-hidden down to grid wrapper so DataGrid scrollbars surface properly
* fix: add min-w-0 to DataGrid wrapper so flexbox allows shrinking for horizontal scroll
* fix: add overflow-hidden to flex row and right column to clip at viewport, DataGrid scrolls inside
* fix: use w-0 on right column to force width:0 flex-basis, preventing any content-based expansion
* fix: overscroll-contain on DataGrid and TableTree scroll areas to prevent bounce
* fix: inline overscroll-behavior:none + WebkitOverflowScrolling:auto for reliable macOS bounce prevention
* feat: page-size selector (50/100/200) in pagination bar, setPageSize resets to page 1 and triggers re-fetch
* fix: column resize with refs, FK cell click opens table with filter, vertical borders, tooltip positioning (right/bottom), sidebar transparent bg
* fix: sidebar bg-canvas, remove overflow-hidden from toolbar parent so dropdown tooltips aren't clipped
* chore: rename sidebar label from DB Viewer to Explorer
* feat: resizable table panel (180-600px) with drag handle on right edge
* feat: double-click panel resize handle resets to default 280px
* feat: column visibility dropdown in filter bar, max column width 800px, double-click resets column width
* fix: enforce column width on th cells, remove min-width:100% so columns stay at set width instead of stretching
* fix: removable resize handle always clickable (remove opacity-0), maxWidth+overflow on th/td to prevent column blowout
* feat: add environment label to connections (production/staging/development) with badge on cards
* feat: OS keychain integration for connection passwords via tauri-plugin-keyring-store
* style: monospace font for table data cells
* style: use Space Mono (font-heading) for table data cells
* feat: unified table controls bar (insert, refresh, auto-refresh, filter modal, sort modal, export, columns, pagination); fix pagination setPage not clearing data
* fix: auto-refresh defaults to off, click opens dropdown to pick interval
* fix: pagination/refresh keeps existing data visible, shows subtle loading bar instead of blank
* fix: new tabs start with loading:true so auto-fetch triggers instead of stuck on 'loading table data'
* feat: checkbox column for row selection (header=all, row=individual, selected state tracks indices)
* feat: selected row count in toolbar with clear button
* feat: bulk actions dropdown on selection (Copy JSON, Copy CSV, Copy SQL INSERT, Delete rows)
* docs: add changes-queue-before-execution rule to AGENTS.md guardrails
* docs: broaden CRUD guardrail to cover all destructive operations (DB data via queue, app entities via confirm dialog)
* feat: auto-create demo SQLite DB on first launch with sample e-commerce schema (users, products, orders, order_items)
* fix: demo DB startup panic (state before manage), add settings: re-add demo, table refresh rate, table page size
* feat: Cmd+W closes tab or navigates home, edit connection modal with update_connection backend, action queue button with count badge and dropdown
* fix: columns button icon-only, wire settings.table_page_size and table_refresh_rate to actual table behavior
* feat: shortcuts settings tab showing all keyboard shortcuts (Cmd+K, Cmd+W, Esc, Enter, Space, click, header checkbox)
* feat: editable keyboard shortcuts - click pencil to record new keybinding, persisted to settings, useShortcut hook for dynamic binding
* feat: add Tags & Env tab to edit connection modal (environment, folder, tag picker)
* feat(db-viewer): UX polish - cursor pointers, icon-only toolbar, top border
- Add cursor-pointer to all interactive elements across db-viewer components
- Simplify TableControls: Filter/Sort/Export show icons only with tooltips
- Add border-t to DbViewerScreen for visual separation from window frame
* fix(db-viewer): style EditConnectionModal, fix test connection, working refresh button
- Match EditConnectionModal styling to home screen modals (AnimatedModal, Button, no dividers)
- Fix test connection sending null password by fetching from keychain first
- Make refresh database button functional with success/error visual feedback and spin animation
* feat(db-viewer): compact ghost-style DB/schema dropdowns in single row
- Add variant prop to SelectDropdown (pill | ghost) for minimal text-only style
- Place DB and schema dropdowns side-by-side on one row with | separator
* fix(db-viewer): proper FK/enum detection and improved schema tree display
Rust backend:
- PostgreSQL: fix column query to detect FKs (was hardcoded false) and map
USER-DEFINED types to udt_name for enum display
- SQLite: use PRAGMA table_info for types/PK/NOT NULL/defaults and
PRAGMA foreign_key_list for FK detection
Frontend:
- FK columns show orange key icon in TableTree
- Data type abbreviations (varchar, int, bool, timestamptz, etc.) with
full type name on hover tooltip
- Column icons use shrink-0 to maintain size
* feat(db-viewer): PK/FK icons and shorthand types in DataGrid headers
* fix(db-viewer): fix NULL values for UUID and timestamp columns, FK underline style
- Add uuid::Uuid, chrono types to pg_value_to_json chain so UUID PKs/FKs
and timestamp columns display correctly instead of NULL
- Enable with-serde_json-1 feature on tokio-postgres
- Change FK cell styling from blue text to dotted underline
* feat(db-viewer): FK preview popover with inline filter
- Replace direct FK navigation with popover showing the referenced row
- New get_fk_preview Rust command fetches single row by column value
- FkPreviewPopover component displays columns with PK/FK icons
- 'Open' button creates filtered tab, visible in existing Filter UI
- Consolidate columnFilter into filterRules (no duplicate filter logic)
* feat(db-viewer): selectable cell text, JSON/JSONB popover with formatted/raw views
- Add select-text to cell contents for copy support
- New JsonCellPopover component with Formatted/Raw tabs and copy button
- JSON/JSONB columns show brief preview ({ N keys } / [ N items ])
- Click JSON cells to open popover with pretty-printed or raw output
* feat(db-viewer): smart default sort adds newest-first sorting automatically
- 12-tier priority system detects recency/ordering columns
- Prefers updated_at, created_at, *_at suffixes, last_* prefixes
- Falls back to timestamp types, numeric IDs, sequence/position cols
- Also covers rank, version, count/quantity columns
- Applied once per tab, visible in Sort dropdown for manual override
* feat(db-viewer): search tables input with slide animation and tree filtering
- Search icon in toolbar toggles animated input with slide-down effect
- Filters TableTree by table name as user types
- Clean border-bottom styling, search icon on left, X clear on right
- Auto-hides on blur when empty, stays when content present
- Search icon highlights when active
* docs: add comprehensive implementation status to AGENTS.md
- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with ✅/🟡/❌ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user