refactor: convert to bun-workspaces monorepo (desktop/ + www/)

- Move the Tauri app (React frontend + Rust backend) into desktop/ via
  git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts)
- Root package.json becomes a private bun workspace container with
  orchestration scripts; desktop package renamed gridline-desktop
- Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin,
  static output, ready for Dokploy)
- Update release.yml for the new layout: projectPath: desktop,
  desktop/src-tauri resource paths, rust-cache workspace path
- Update README + AGENTS.md structure trees and dev commands
- Verified: vitest (1074), cargo test (354), desktop build, tauri dev
  (window launches), tauri build (dmg + app bundles), Astro build
This commit is contained in:
2026-08-16 19:31:53 +08:00
parent d752642b4d
commit 6854d889c5
400 changed files with 857 additions and 149 deletions
+889
View File
@@ -0,0 +1,889 @@
//! 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 = '{}' \
UNION ALL \
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type \
FROM pg_matviews WHERE schemaname = '{}' \
ORDER BY table_name",
s, s
),
None => {
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
UNION ALL \
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, schemaname AS table_schema \
FROM pg_matviews WHERE schemaname 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,
CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS 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)
}
// ---------------------------------------------------------------------------
// Object introspection (functions, triggers, sequences, enums, extensions)
// ---------------------------------------------------------------------------
/// Query functions and procedures in a schema.
pub fn pg_functions_query(_schema: &str) -> String {
format!(
"SELECT p.proname, n.nspname, \
pg_catalog.format_type(p.prorettype, NULL) AS return_type, \
ARRAY(SELECT unnest(p.proargtypes::regtype[]::text[])) AS arg_types, \
ARRAY(SELECT unnest(p.proargnames::text[])) AS arg_names, \
ARRAY(SELECT unnest(p.proargmodes::text[])) AS arg_modes, \
l.lanname, pg_get_functiondef(p.oid) AS source, \
p.prokind::text \
FROM pg_proc p \
JOIN pg_namespace n ON p.pronamespace = n.oid \
JOIN pg_language l ON p.prolang = l.oid \
WHERE n.nspname = $1 \
AND p.prokind IN ('f', 'p') \
ORDER BY p.proname"
)
}
/// Query triggers in a schema.
pub fn pg_triggers_query(_schema: &str) -> String {
format!(
"SELECT t.tgname, tn.nspname AS trigger_schema, \
cn.nspname AS table_schema, c.relname AS table_name, \
CASE \
WHEN t.tgtype::int2 & 4 = 4 THEN 'INSERT' \
WHEN t.tgtype::int2 & 8 = 8 THEN 'DELETE' \
WHEN t.tgtype::int2 & 16 = 16 THEN 'UPDATE' \
WHEN t.tgtype::int2 & 32 = 32 THEN 'TRUNCATE' \
ELSE 'UNKNOWN' END AS event, \
CASE WHEN t.tgtype::int2 & 2 = 2 THEN 'BEFORE' ELSE 'AFTER' END AS timing, \
CASE WHEN t.tgtype::int2 & 1 = 1 THEN 'ROW' ELSE 'STATEMENT' END AS orientation, \
pg_get_triggerdef(t.oid) AS definition, \
t.tgenabled::text \
FROM pg_trigger t \
JOIN pg_class c ON t.tgrelid = c.oid \
JOIN pg_namespace cn ON c.relnamespace = cn.oid \
CROSS JOIN LATERAL (SELECT nspname FROM pg_namespace WHERE oid = (SELECT pronamespace FROM pg_proc WHERE oid = t.tgfoid)) tn \
WHERE cn.nspname = $1 AND NOT t.tgisinternal \
ORDER BY t.tgname"
)
}
/// Query sequences in a schema via information_schema.
pub fn pg_sequences_query(schema: &str) -> String {
format!(
"SELECT sequence_name, '{}' AS schema, \
COALESCE(start_value::text, '1'), \
COALESCE(minimum_value::text, '1'), \
COALESCE(maximum_value::text, '9223372036854775807'), \
COALESCE(increment::text, '1'), \
COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text, '0'), \
COALESCE(cycle_option::text, 'NO') \
FROM information_schema.sequences \
WHERE sequence_schema = $1 \
ORDER BY sequence_name",
schema
)
}
/// Query enums in a schema.
pub fn pg_enums_query(_schema: &str) -> String {
format!(
"SELECT t.typname, n.nspname, \
ARRAY(SELECT e.enumlabel FROM pg_enum e \
WHERE e.enumtypid = t.oid ORDER BY e.enumsortorder) AS labels \
FROM pg_type t \
JOIN pg_namespace n ON t.typnamespace = n.oid \
WHERE t.typtype = 'e' AND n.nspname = $1 \
ORDER BY t.typname"
)
}
/// Query installed extensions.
pub fn pg_extensions_query() -> String {
"SELECT e.extname, n.nspname, e.extversion::text, \
pg_catalog.obj_description(e.oid, 'pg_extension') AS comment \
FROM pg_extension e \
JOIN pg_namespace n ON e.extnamespace = n.oid \
ORDER BY e.extname"
.to_string()
}
/// Query available (installable) extensions with default version + comment.
/// Schema-wide: `pg_available_extensions` is not schema-scoped.
pub fn pg_available_extensions_query() -> String {
"SELECT name, default_version::text AS version, comment \
FROM pg_available_extensions ORDER BY name"
.to_string()
}
/// Query indexes in a schema.
///
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
/// uniqueness, access method, columns CSV, size in bytes, and tablespace.
pub fn pg_indexes_query(_schema: &str) -> String {
format!(
"SELECT \
i.relname AS index_name, \
ns.nspname AS schema, \
t.relname AS table_name, \
pg_get_indexdef(ix.indexrelid) AS definition, \
ix.indisunique AS is_unique, \
am.amname AS method, \
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
FROM unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum) AS columns, \
pg_relation_size(i.oid) AS size_bytes, \
ts.spcname AS tablespace \
FROM pg_index ix \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_namespace ns ON t.relnamespace = ns.oid \
JOIN pg_am am ON i.relam = am.oid \
LEFT JOIN pg_tablespace ts ON i.reltablespace = ts.oid \
WHERE ns.nspname = $1 \
ORDER BY i.relname"
)
}
/// Query CHECK / UNIQUE / EXCLUSION constraints in a schema.
///
/// Primary and foreign keys are intentionally excluded — they surface in the
/// table grid. Returns name, schema, table, contype, definition
/// (`pg_get_constraintdef`), deferrability, validation, and columns CSV.
pub fn pg_constraints_query(_schema: &str) -> String {
format!(
"SELECT \
c.conname AS name, \
ns.nspname AS schema, \
cl.relname AS table_name, \
c.contype::text, \
pg_get_constraintdef(c.oid) AS definition, \
c.condeferrable, \
c.convalidated, \
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
FROM unnest(c.conkey) WITH ORDINALITY AS ord(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ord.attnum) AS columns \
FROM pg_constraint c \
JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
WHERE ns.nspname = $1 AND c.contype IN ('c', 'u', 'x') \
ORDER BY c.conname"
)
}
// ---------------------------------------------------------------------------
// Roles, privileges, tablespaces, rebuild readiness
// ---------------------------------------------------------------------------
/// List non-system roles with all attributes used by the role form.
pub fn pg_roles_query() -> String {
"SELECT rolname, rolsuper, rolinherit, rolcreatedb, rolcreaterole, rolcanlogin, \
rolreplication, rolbypassrls, rolconnlimit::int8, COALESCE(rolvaliduntil::text, '') AS rolvaliduntil \
FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY rolname"
.to_string()
}
/// All role-to-role memberships (member/admin/grantor).
pub fn pg_role_memberships_query() -> String {
"SELECT roleid::regrole::text AS role, member::regrole::text AS member, \
grantor::regrole::text AS grantor, admin_option \
FROM pg_auth_members ORDER BY role"
.to_string()
}
/// Table/view/matview privileges for a grantee, grouped one row per object.
pub fn pg_table_privileges_query(role: &str) -> String {
format!(
"SELECT table_schema AS schema, table_name AS name, \
array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.table_privileges \
WHERE grantee = '{}' AND table_schema NOT IN ('pg_catalog','information_schema') \
GROUP BY table_schema, table_name \
ORDER BY table_schema, table_name",
role
)
}
/// Sequence privileges for a grantee, grouped one row per sequence.
/// aclexplode-based (role_sequence_grants was removed in PG 15).
pub fn pg_sequence_privileges_query(role: &str) -> String {
format!(
"SELECT n.nspname AS schema, c.relname AS name, \
array_agg(p.privilege_type::text) AS privileges, \
bool_or(p.is_grantable) AS grantable \
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace, \
LATERAL aclexplode(c.relacl) p \
WHERE c.relkind = 'S' AND n.nspname NOT IN ('pg_catalog','information_schema') \
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
GROUP BY n.nspname, c.relname \
ORDER BY n.nspname, c.relname",
role
)
}
/// Routine (function/procedure) privileges for a grantee.
pub fn pg_routine_privileges_query(role: &str) -> String {
format!(
"SELECT routine_schema AS schema, routine_name AS name, \
array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.routine_privileges \
WHERE grantee = '{}' \
GROUP BY routine_schema, routine_name \
ORDER BY routine_schema, routine_name",
role
)
}
/// Schema privileges for a grantee (USAGE/CREATE). aclexplode-based —
/// information_schema has no schema_privileges view.
pub fn pg_schema_privileges_query(role: &str) -> String {
format!(
"SELECT n.nspname AS name, \
array_agg(p.privilege_type::text) AS privileges, \
bool_or(p.is_grantable) AS grantable \
FROM pg_namespace n, LATERAL aclexplode(n.nspacl) p \
WHERE n.nspname NOT IN ('pg_catalog','information_schema') \
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
GROUP BY n.nspname ORDER BY n.nspname",
role
)
}
/// Database privileges for a grantee via aclexplode (broadly compatible; avoids PG15-only view).
pub fn pg_database_privileges_query(role: &str) -> String {
format!(
"SELECT d.datname AS name, array_agg(a.privilege_type) AS privileges, \
bool_or(a.is_grantable) AS grantable \
FROM pg_database d, LATERAL aclexplode(d.datacl) a \
WHERE a.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
AND d.datistemplate = false \
GROUP BY d.datname \
ORDER BY d.datname",
role
)
}
/// Non-system tablespaces for the table-options picker.
pub fn pg_tablespaces_query() -> String {
"SELECT spcname FROM pg_tablespace WHERE spcname !~ '^pg_' ORDER BY spcname".to_string()
}
/// Single query returning boolean blockers for a table rebuild (triggers, policies,
/// inheritance, partitioning, generated/identity columns). Parameterized via $1/$2.
pub fn pg_rebuild_readiness_query() -> String {
"SELECT \
EXISTS(SELECT 1 FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2 AND NOT t.tgisinternal) AS has_triggers, \
EXISTS(SELECT 1 FROM pg_policy p JOIN pg_class c ON p.polrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS has_policies, \
EXISTS(SELECT 1 FROM pg_inherits i JOIN pg_class c ON i.inhrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS is_inherits, \
EXISTS(SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON pt.partrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS is_partitioned, \
EXISTS(SELECT 1 FROM information_schema.columns \
WHERE table_schema = $1 AND table_name = $2 AND is_generated <> '') AS has_generated"
.to_string()
}
/// FKs owned by this table (contype='f'). Parameterized $1 schema, $2 table.
pub fn pg_table_fk_out_query() -> String {
"SELECT c.conname, pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace n ON cl.relnamespace = n.oid \
WHERE n.nspname = $1 AND cl.relname = $2 AND c.contype = 'f'".to_string()
}
/// FKs from other tables referencing this table. Parameterized $1 schema, $2 table.
pub fn pg_table_fk_in_query() -> String {
"SELECT c.conname, cn.nspname AS own_schema, cl.relname AS own_table, \
pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace cn ON cl.relnamespace = cn.oid \
JOIN pg_class r ON c.confrelid = r.oid \
JOIN pg_namespace rn ON r.relnamespace = rn.oid \
WHERE rn.nspname = $1 AND r.relname = $2 AND c.contype = 'f'".to_string()
}
/// Grants on this table (all grantees), grouped. Parameterized $1 schema, $2 table.
pub fn pg_table_grants_query() -> String {
"SELECT grantee, array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.table_privileges \
WHERE table_schema = $1 AND table_name = $2 AND grantee <> 'PUBLIC' \
GROUP BY grantee".to_string()
}
/// Sequences owned by this table's columns (via pg_depend). Parameterized $1 schema, $2 table.
pub fn pg_table_owned_sequences_query() -> String {
"SELECT sn.nspname AS seq_schema, s.relname AS seq_name, a.attname AS column \
FROM pg_depend d JOIN pg_class s ON d.objid = s.oid \
JOIN pg_namespace sn ON s.relnamespace = sn.oid \
JOIN pg_class t ON d.refobjid = t.oid \
JOIN pg_namespace tn ON t.relnamespace = tn.oid \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid \
WHERE tn.nspname = $1 AND t.relname = $2 AND d.classid = 'pg_class'::regclass AND s.relkind = 'S'".to_string()
}
// ---------------------------------------------------------------------------
// 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"));
}
#[test]
fn pg_indexes_query_is_parameterized_and_joins() {
let sql = pg_indexes_query("public");
assert!(
sql.contains("$1"),
"schema must be parameterized; got: {}",
sql
);
assert!(
sql.contains("pg_indexes") || sql.contains("pg_index"),
"should query pg_index; got: {}",
sql
);
assert!(
sql.contains("pg_get_indexdef"),
"should include index definition"
);
assert!(sql.contains("indisunique"), "should include uniqueness");
}
#[test]
fn pg_constraints_query_filters_check_unique_exclusion() {
let sql = pg_constraints_query("public");
assert!(
sql.contains("$1"),
"schema must be parameterized; got: {}",
sql
);
assert!(
sql.contains("pg_constraint"),
"should query pg_constraint; got: {}",
sql
);
assert!(sql.contains("contype"), "should select contype");
assert!(sql.contains("'c'"), "should filter CHECK ('c')");
assert!(sql.contains("'u'"), "should filter UNIQUE ('u')");
assert!(sql.contains("'x'"), "should filter EXCLUSION ('x')");
assert!(
sql.contains("pg_get_constraintdef"),
"should include definition"
);
}
#[test]
fn pg_tables_query_includes_materialized_views() {
let sql = pg_tables_query(Some("public"));
assert!(
sql.contains("pg_matviews"),
"matview UNION must source pg_matviews; got: {}",
sql,
);
assert!(
sql.contains("MATERIALIZED VIEW"),
"should label materialized views"
);
}
// ---------------------------------------------------------------
// 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");
}
// ---------------------------------------------------------------
// Object introspection
// ---------------------------------------------------------------
#[test]
fn pg_functions_query_has_expected_columns() {
let sql = pg_functions_query("public");
assert!(sql.contains("pg_proc"));
assert!(sql.contains("proname"));
}
#[test]
fn pg_triggers_query_has_expected_columns() {
let sql = pg_triggers_query("public");
assert!(sql.contains("pg_trigger"));
assert!(sql.contains("tgname"));
}
#[test]
fn pg_sequences_query_filters_by_schema() {
let sql = pg_sequences_query("myschema");
assert!(sql.contains("myschema"));
}
#[test]
fn pg_enums_query_has_typtype_e() {
let sql = pg_enums_query("public");
assert!(sql.contains("typtype = 'e'"));
}
#[test]
fn pg_extensions_query_selects_from_pg_extension() {
let sql = pg_extensions_query();
assert!(sql.contains("pg_extension"));
}
#[test]
fn pg_available_extensions_query_has_name_default_version_comment() {
let sql = pg_available_extensions_query();
assert!(sql.contains("pg_available_extensions"));
assert!(sql.contains("default_version"), "should select default version; got: {sql}");
assert!(sql.contains("comment"), "should select comment; got: {sql}");
assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}");
}
// ---------------------------------------------------------------
// Roles, privileges, tablespaces, rebuild readiness
// ---------------------------------------------------------------
#[test]
fn pg_roles_query_filters_pg_prefix() {
let sql = pg_roles_query();
assert!(sql.contains("pg_roles"));
assert!(sql.contains("rolname !~ '^pg_'"));
assert!(sql.contains("rolcanlogin"));
assert!(sql.contains("rolconnlimit::int8"));
}
#[test]
fn pg_role_memberships_query_uses_pg_auth_members() {
let sql = pg_role_memberships_query();
assert!(sql.contains("pg_auth_members"));
assert!(sql.contains("admin_option"));
}
#[test]
fn pg_table_privileges_query_filters_grantee_and_aggregates() {
let sql = pg_table_privileges_query("appuser");
assert!(sql.contains("information_schema.table_privileges"));
assert!(sql.contains("grantee = 'appuser'"));
assert!(sql.contains("array_agg"));
assert!(sql.contains("GROUP BY"));
}
#[test]
fn pg_database_privileges_query_uses_aclexplode() {
let sql = pg_database_privileges_query("appuser");
assert!(sql.contains("aclexplode"));
assert!(sql.contains("pg_database"));
assert!(sql.contains("appuser"));
}
#[test]
fn pg_rebuild_readiness_query_checks_blockers() {
let sql = pg_rebuild_readiness_query();
assert!(sql.contains("pg_trigger"));
assert!(sql.contains("pg_policy"));
assert!(sql.contains("pg_inherits"));
assert!(sql.contains("pg_partitioned_table"));
assert!(sql.contains("is_generated"));
}
#[test]
fn pg_tablespaces_query_filters_pg_prefix() {
let sql = pg_tablespaces_query();
assert!(sql.contains("pg_tablespace"));
assert!(sql.contains("spcname !~ '^pg_'"));
}
#[test]
fn pg_table_fk_out_query_uses_pg_constraint_f() {
let sql = pg_table_fk_out_query();
assert!(sql.contains("pg_constraint"));
assert!(sql.contains("contype = 'f'"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
#[test]
fn pg_table_fk_in_query_finds_referencing_tables() {
let sql = pg_table_fk_in_query();
assert!(sql.contains("pg_constraint"));
assert!(sql.contains("confrel"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
#[test]
fn pg_table_grants_query_uses_table_privileges() {
let sql = pg_table_grants_query();
assert!(sql.contains("information_schema.table_privileges"));
assert!(sql.contains("$1") && sql.contains("$2"));
assert!(sql.contains("array_agg"));
}
#[test]
fn pg_table_owned_sequences_query_uses_pg_depend() {
let sql = pg_table_owned_sequences_query();
assert!(sql.contains("pg_depend"));
assert!(sql.contains("pg_class"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod introspection;
pub mod mysql;
pub mod object_crud;
pub mod object_ddl;
pub mod pool;
pub mod tls;
#[allow(unused_imports)]
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
+326
View File
@@ -0,0 +1,326 @@
//! Pure MySQL SQL builders for the DB viewer. Identifiers are backtick-quoted
//! (never string-concatenated); values are bound via `?` placeholders at the
//! call site. Mirrors the PG builders in `commands/db_viewer.rs` but with
//! MySQL quoting and `LIMIT 1` on single-row UPDATE/DELETE.
use crate::models::db_viewer::{FilterRule, SortRule};
use sqlx::Row;
/// `SHOW DATABASES` — the browsing branches filter system DBs client-side
/// (see [`MYSQL_SYSTEM_DBS`]).
pub fn mysql_databases_query() -> String {
"SHOW DATABASES".to_string()
}
/// System databases hidden from the DB viewer's database/schema selector.
pub const MYSQL_SYSTEM_DBS: [&str; 4] = ["information_schema", "mysql", "performance_schema", "sys"];
/// Quote a MySQL identifier with backticks, doubling any embedded backticks.
pub fn mysql_quote_ident(name: &str) -> String {
format!("`{}`", name.replace('`', "``"))
}
/// Decode a MySQL row cell as a String. `information_schema` / `SHOW`
/// metadata columns can surface as VARBINARY (bytes) depending on the
/// connection charset, so fall back from String to a UTF-8 lossy decode.
pub fn mysql_row_string(row: &sqlx::mysql::MySqlRow, i: usize) -> String {
if let Ok(s) = row.try_get::<String, _>(i) {
return s;
}
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
return String::from_utf8_lossy(&b).into_owned();
}
String::new()
}
/// information_schema.columns query for a table — returns column metadata in
/// the column order the grid expects (name, data_type, is_nullable, column_key,
/// default, extra). Caller maps these into `ColumnInfo`.
pub fn mysql_columns_query(schema: &str, table: &str) -> String {
format!(
"SELECT column_name, data_type, is_nullable, column_key, column_default, extra \
FROM information_schema.columns \
WHERE table_schema = '{}' AND table_name = '{}' \
ORDER BY ordinal_position",
schema.replace('\'', "''"),
table.replace('\'', "''")
)
}
/// Build a `SELECT ... FROM \`schema\`.\`table\` [WHERE ...] [ORDER BY ...] LIMIT ? OFFSET ?`.
/// `filters` produce `?` placeholders (values bound by the caller); `sorts`
/// are quoted identifiers. `default_sort` is used when `sorts` is empty.
pub fn mysql_select_data_query(
schema: &str,
table: &str,
filters: &[FilterRule],
sorts: &[SortRule],
default_sort: &str,
) -> String {
let mut where_parts: Vec<String> = Vec::new();
for f in filters {
let col = mysql_quote_ident(&f.column);
let op = match f.operator.as_str() {
"eq" => format!("{} = ?", col),
"neq" => format!("{} <> ?", col),
"contains" => format!("{} LIKE CONCAT('%', ?, '%')", col),
"starts" => format!("{} LIKE CONCAT(?, '%')", col),
"ends" => format!("{} LIKE CONCAT('%', ?)", col),
"gt" => format!("{} > ?", col),
"lt" => format!("{} < ?", col),
"null" => format!("{} IS NULL", col),
"notnull" => format!("{} IS NOT NULL", col),
_ => format!("{} = ?", col),
};
where_parts.push(op);
}
let where_clause = if where_parts.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_parts.join(" AND "))
};
let order_cols: Vec<String> = sorts
.iter()
.map(|s| format!("{} {}", mysql_quote_ident(&s.column), if s.order.eq_ignore_ascii_case("desc") { "DESC" } else { "ASC" }))
.collect();
let order_clause = if order_cols.is_empty() {
if default_sort.is_empty() {
String::new()
} else {
format!(" ORDER BY {}", mysql_quote_ident(default_sort))
}
} else {
format!(" ORDER BY {}", order_cols.join(", "))
};
format!(
"SELECT * FROM {}{}{} LIMIT ? OFFSET ?",
qualified(schema, table),
where_clause,
order_clause
)
}
pub fn mysql_count_query(schema: &str, table: &str) -> String {
format!("SELECT COUNT(*) FROM {}", qualified(schema, table))
}
pub fn mysql_ddl_query(schema: &str, table: &str) -> String {
format!("SHOW CREATE TABLE {}", qualified(schema, table))
}
/// Foreign-key columns for a table (referenced table/column).
pub fn mysql_fk_query(schema: &str, table: &str) -> String {
format!(
"SELECT column_name, referenced_table_schema, referenced_table_name, referenced_column_name \
FROM information_schema.key_column_usage \
WHERE table_schema = '{}' AND table_name = '{}' AND referenced_table_name IS NOT NULL",
schema.replace('\'', "''"),
table.replace('\'', "''")
)
}
/// Choose a default sort column: prefer an `id`-like column, else the first.
pub fn mysql_default_sort(columns: &[String]) -> &str {
columns.iter().find(|c| c.as_str() == "id").map(|s| s.as_str()).unwrap_or_else(|| {
columns.first().map(|s| s.as_str()).unwrap_or("")
})
}
// ── Change-SQL builders ──────────────────────────────────────────────
pub fn mysql_build_update_sql(
schema: &str,
table: &str,
primary_key: &[(String, serde_json::Value)],
new_data: &[(String, serde_json::Value)],
) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot update a row without a primary key (MySQL has no ctid)".to_string());
}
let mut params: Vec<serde_json::Value> = Vec::new();
let set_clause: Vec<String> = new_data
.iter()
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
.collect();
let where_clause: Vec<String> = primary_key
.iter()
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
.collect();
Ok((
format!(
"UPDATE {} SET {} WHERE {} LIMIT 1",
qualified(schema, table),
set_clause.join(", "),
where_clause.join(" AND ")
),
params,
))
}
pub fn mysql_build_delete_sql(
schema: &str,
table: &str,
primary_key: &[(String, serde_json::Value)],
) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot delete a row without a primary key (MySQL has no ctid)".to_string());
}
let mut params: Vec<serde_json::Value> = Vec::new();
let where_clause: Vec<String> = primary_key
.iter()
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
.collect();
Ok((
format!("DELETE FROM {} WHERE {} LIMIT 1", qualified(schema, table), where_clause.join(" AND ")),
params,
))
}
pub fn mysql_build_insert_sql(
schema: &str,
table: &str,
pairs: &[(String, serde_json::Value)],
) -> (String, Vec<serde_json::Value>) {
let cols: Vec<String> = pairs.iter().map(|(c, _)| mysql_quote_ident(c)).collect();
let placeholders: Vec<&str> = pairs.iter().map(|_| "?").collect();
let params: Vec<serde_json::Value> = pairs.iter().map(|(_, v)| v.clone()).collect();
(
format!(
"INSERT INTO {} ({}) VALUES ({})",
qualified(schema, table),
cols.join(", "),
placeholders.join(", ")
),
params,
)
}
pub fn mysql_build_bulk_insert_sql(schema: &str, table: &str, columns: &[String], row_count: usize) -> String {
let cols: Vec<String> = columns.iter().map(|c| mysql_quote_ident(c)).collect();
let one_row = format!("({})", columns.iter().map(|_| "?").collect::<Vec<_>>().join(", "));
let rows = vec![one_row; row_count].join(", ");
format!("INSERT INTO {} ({}) VALUES {}", qualified(schema, table), cols.join(", "), rows)
}
pub fn mysql_build_drop_sql(schema: &str, table: &str) -> String {
format!("DROP TABLE {}", qualified(schema, table))
}
pub fn mysql_build_empty_sql(schema: &str, table: &str) -> String {
format!("DELETE FROM {}", qualified(schema, table))
}
fn qualified(schema: &str, table: &str) -> String {
format!("{}.{}", mysql_quote_ident(schema), mysql_quote_ident(table))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::db_viewer::FilterRule;
#[test]
fn quote_ident_backticks_and_escapes_embedded_backticks() {
assert_eq!(mysql_quote_ident("name"), "`name`");
assert_eq!(mysql_quote_ident("o`d`d"), "`o``d``d`");
assert_eq!(mysql_quote_ident("select"), "`select`");
}
#[test]
fn columns_query_targets_information_schema() {
let q = mysql_columns_query("shop", "orders");
assert!(q.contains("FROM information_schema.columns"));
assert!(q.contains("table_schema = 'shop'"));
assert!(q.contains("table_name = 'orders'"));
assert!(q.contains("ORDER BY ordinal_position"));
}
#[test]
fn select_data_quotes_schema_table_and_applies_limit_offset() {
let q = mysql_select_data_query("shop", "orders", &[], &[], "id");
assert!(q.contains("SELECT * FROM `shop`.`orders`"));
assert!(q.contains("ORDER BY `id`"));
assert!(q.contains("LIMIT ? OFFSET ?"));
}
#[test]
fn select_data_adds_where_for_filters_with_question_placeholders() {
let filters = vec![FilterRule { id: "f1".into(), column: "status".into(), operator: "eq".into(), value: "paid".into() }];
let q = mysql_select_data_query("shop", "orders", &filters, &[], "id");
assert!(q.contains("WHERE `status` = ?"));
assert!(q.contains("LIMIT ? OFFSET ?"));
}
#[test]
fn count_query_uses_quoted_table() {
let q = mysql_count_query("shop", "orders");
assert_eq!(q, "SELECT COUNT(*) FROM `shop`.`orders`");
}
#[test]
fn ddl_query_uses_show_create_table() {
let q = mysql_ddl_query("shop", "orders");
assert_eq!(q, "SHOW CREATE TABLE `shop`.`orders`");
}
#[test]
fn fk_query_targets_key_column_usage() {
let q = mysql_fk_query("shop", "orders");
assert!(q.contains("FROM information_schema.key_column_usage"));
assert!(q.contains("referenced_table_name IS NOT NULL"));
}
#[test]
fn build_update_uses_backticks_question_and_limit_one() {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let data = vec![("status".to_string(), serde_json::json!("paid"))];
let (sql, params) = mysql_build_update_sql("shop", "orders", &pk, &data).unwrap();
assert_eq!(sql, "UPDATE `shop`.`orders` SET `status` = ? WHERE `id` = ? LIMIT 1");
assert_eq!(params, vec![serde_json::json!("paid"), serde_json::json!(1)]);
}
#[test]
fn build_update_rejects_empty_primary_key() {
let r = mysql_build_update_sql("shop", "orders", &[], &[]);
assert!(r.is_err());
}
#[test]
fn build_delete_uses_backticks_question_and_limit_one() {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let (sql, params) = mysql_build_delete_sql("shop", "orders", &pk).unwrap();
assert_eq!(sql, "DELETE FROM `shop`.`orders` WHERE `id` = ? LIMIT 1");
assert_eq!(params, vec![serde_json::json!(1)]);
}
#[test]
fn build_insert_emits_columns_and_question_placeholders() {
let pairs = vec![
("a".to_string(), serde_json::json!(1)),
("b".to_string(), serde_json::json!("x")),
];
let (sql, params) = mysql_build_insert_sql("shop", "orders", &pairs);
assert_eq!(sql, "INSERT INTO `shop`.`orders` (`a`, `b`) VALUES (?, ?)");
assert_eq!(params, vec![serde_json::json!(1), serde_json::json!("x")]);
}
#[test]
fn build_drop_and_empty_table_quoted() {
assert_eq!(mysql_build_drop_sql("shop", "orders"), "DROP TABLE `shop`.`orders`");
assert_eq!(mysql_build_empty_sql("shop", "orders"), "DELETE FROM `shop`.`orders`");
}
#[test]
fn build_bulk_insert_columns_and_placeholders() {
let cols = vec!["a".to_string(), "b".to_string()];
let sql = mysql_build_bulk_insert_sql("shop", "orders", &cols, 2);
assert_eq!(sql, "INSERT INTO `shop`.`orders` (`a`, `b`) VALUES (?, ?), (?, ?)");
}
#[test]
fn default_sort_picks_id_then_first_column() {
assert_eq!(mysql_default_sort(&["updated_at".into(), "id".into()]), "id");
assert_eq!(mysql_default_sort(&["name".into()]), "name");
assert_eq!(mysql_default_sort(&[]), "");
}
}
File diff suppressed because it is too large Load Diff
+413
View File
@@ -0,0 +1,413 @@
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
//! and synthesized object DDL. No DB I/O — deterministic string builders.
use serde::{Deserialize, Serialize};
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
/// Column model for the SQLite table editor. Mirrors the frontend payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SqliteColumn {
pub name: String,
#[serde(rename = "type")]
pub type_: String,
pub nullable: bool,
pub default: Option<String>,
pub is_pk: bool,
pub auto_increment: bool,
pub unique: bool,
}
/// Double-quote an identifier, doubling embedded quotes.
pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// Reject empty / SQL-injection-prone names (mirrors schema_graph::validate_schema_name).
pub fn validate_object_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("Name must not be empty".into());
}
for bad in [";", "--", "/*", "'", "\"", "\\"] {
if name.contains(bad) {
return Err(format!("Name contains forbidden character(s): {bad}"));
}
}
Ok(())
}
pub fn create_schema_sql(name: &str) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("CREATE SCHEMA {}", quote_ident(name)))
}
pub fn rename_schema_sql(old: &str, new: &str) -> Result<String, String> {
validate_object_name(old)?;
validate_object_name(new)?;
Ok(format!("ALTER SCHEMA {} RENAME TO {}", quote_ident(old), quote_ident(new)))
}
pub fn drop_schema_sql(name: &str, cascade: bool) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("DROP SCHEMA {}{}", quote_ident(name), if cascade { " CASCADE" } else { "" }))
}
/// UNION ALL of every browsable object type in `$2` (schema), substring-matching `$1` (needle)
/// via position() — case-insensitive, no wildcard-escaping pitfalls. LIMIT 100.
pub fn pg_object_search_query() -> String {
"SELECT name, schema, type FROM (
SELECT table_name AS name, table_schema AS schema, CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS type FROM information_schema.tables WHERE table_schema=$2 AND position(lower($1) in lower(table_name))>0
UNION ALL
SELECT matviewname, schemaname, 'MATERIALIZED VIEW' FROM pg_matviews WHERE schemaname=$2 AND position(lower($1) in lower(matviewname))>0
UNION ALL
SELECT p.proname, n.nspname, CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' END FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(p.proname))>0
UNION ALL
SELECT t.tgname, cn.nspname, 'TRIGGER' FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace cn ON c.relnamespace=cn.oid WHERE cn.nspname=$2 AND NOT t.tgisinternal AND position(lower($1) in lower(t.tgname))>0
UNION ALL
SELECT sequence_name, sequence_schema, 'SEQUENCE' FROM information_schema.sequences WHERE sequence_schema=$2 AND position(lower($1) in lower(sequence_name))>0
UNION ALL
SELECT t.typname, n.nspname, 'ENUM' FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typtype='e' AND n.nspname=$2 AND position(lower($1) in lower(t.typname))>0
UNION ALL
SELECT e.extname, n.nspname, 'EXTENSION' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(e.extname))>0
UNION ALL
SELECT i.relname, ns.nspname, 'INDEX' FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace ns ON t.relnamespace=ns.oid WHERE ns.nspname=$2 AND position(lower($1) in lower(i.relname))>0
UNION ALL
SELECT c.conname, ns.nspname, 'CONSTRAINT' FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE ns.nspname=$2 AND c.contype IN ('c','u','x') AND position(lower($1) in lower(c.conname))>0
) AS hits ORDER BY type, name LIMIT 100".to_string()
}
/// Resolve a single object oid by type. $1=name, $2=schema. (Overloads: first match — see spec open questions.)
pub fn pg_object_oid_query(object_type: &str) -> String {
match object_type {
"table" | "view" | "materialized view" | "sequence" | "index" =>
"SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"function" | "procedure" =>
"SELECT p.oid FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"trigger" =>
"SELECT t.oid FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1".to_string(),
"enum" =>
"SELECT t.oid FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1".to_string(),
"extension" =>
"SELECT e.oid FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"constraint" =>
"SELECT c.oid FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace n ON cl.relnamespace=n.oid WHERE c.conname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
_ => String::new(),
}
}
/// Objects that depend on `$1` (the target oid). Excludes internal/pinned deps; resolves readable name per classid.
pub fn pg_depend_query() -> String {
"SELECT d.deptype::text AS deptype,
CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'
ELSE d.classid::regclass::text END AS class,
CASE
WHEN d.classid='pg_class'::regclass THEN (SELECT relname FROM pg_class WHERE oid=d.objid)
WHEN d.classid='pg_proc'::regclass THEN (SELECT proname FROM pg_proc WHERE oid=d.objid)
WHEN d.classid='pg_trigger'::regclass THEN (SELECT tgname FROM pg_trigger WHERE oid=d.objid)
WHEN d.classid='pg_type'::regclass THEN (SELECT typname FROM pg_type WHERE oid=d.objid)
WHEN d.classid='pg_constraint'::regclass THEN (SELECT conname FROM pg_constraint WHERE oid=d.objid)
WHEN d.classid='pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)
ELSE ''
END AS name
FROM pg_depend d
WHERE d.refobjid = $1 AND d.deptype IN ('n', 'a')
ORDER BY d.deptype, name".to_string()
}
/// Objects contained in a schema (for the schema-drop dependency warning). $1=schema.
pub fn pg_schema_contents_query() -> String {
"SELECT c.relname, c.relkind::text FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE n.nspname=$1 AND c.relkind IN ('r','v','m','S','i','c') ORDER BY c.relkind, c.relname".to_string()
}
// --- Synthesized DDL ---
pub fn sequence_ddl(s: &SequenceInfo) -> String {
format!("CREATE SEQUENCE {}.{}\n INCREMENT BY {}\n MINVALUE {}\n MAXVALUE {}\n START WITH {}\n {}",
quote_ident(&s.schema), quote_ident(&s.name), s.increment, s.min_value, s.max_value, s.start_value,
if s.cycle { "CYCLE" } else { "NO CYCLE" })
}
pub fn enum_ddl(e: &EnumInfo) -> String {
let labels: Vec<String> = e.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect();
format!("CREATE TYPE {}.{} AS ENUM ({});", quote_ident(&e.schema), quote_ident(&e.name), labels.join(", "))
}
pub fn extension_ddl(x: &ExtensionInfo) -> String {
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}';", quote_ident(&x.name), quote_ident(&x.schema), x.version.replace('\'', "''"))
}
pub fn view_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE OR REPLACE VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn matview_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE MATERIALIZED VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn constraint_ddl(c: &ConstraintInfo) -> String {
format!("ALTER TABLE {}.{} ADD CONSTRAINT {} {}",
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
}
// --- SQLite table editor ---
fn validate_type_fragment(t: &str) -> Result<(), String> {
let l = t.trim().to_lowercase();
if l.is_empty() { return Err("column type is required".into()); }
if l.contains(';') || l.contains("--") || l.contains("/*") { return Err("invalid characters in type".into()); }
Ok(())
}
/// CREATE TABLE for SQLite. PK inline for AUTOINCREMENT; single non-AUTOINCREMENT
/// PKs get a table-level PRIMARY KEY clause; FKs appended inline (SQLite grammar).
pub fn sqlite_create_table_sql(table: &str, cols: &[SqliteColumn], fks: &[(&str, &str)]) -> Result<String, String> {
validate_object_name(table)?;
let mut defs: Vec<String> = vec![];
let mut pk_cols: Vec<String> = vec![];
for c in cols {
validate_object_name(&c.name)?;
validate_type_fragment(&c.type_)?;
let mut d = format!("{} {}", quote_ident(&c.name), c.type_.trim());
if c.auto_increment && c.is_pk && c.type_.trim().eq_ignore_ascii_case("INTEGER") {
d = format!("{} INTEGER PRIMARY KEY AUTOINCREMENT", quote_ident(&c.name));
} else {
if !c.nullable { d.push_str(" NOT NULL"); }
if let Some(def) = &c.default { d.push_str(&format!(" DEFAULT {def}")); }
if c.unique && !c.is_pk { d.push_str(" UNIQUE"); }
if c.is_pk { pk_cols.push(quote_ident(&c.name)); }
}
defs.push(d);
}
// Always emit a table-level PRIMARY KEY when there are non-AUTOINCREMENT PK
// columns (single or composite) — the plan draft's `len > 1` condition would
// silently drop a single-column PK constraint.
if !pk_cols.is_empty() {
defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
}
for (lc, refc) in fks {
defs.push(format!("FOREIGN KEY ({}) REFERENCES {}", quote_ident(lc), refc));
}
Ok(format!("CREATE TABLE \"main\".{} ({})", quote_ident(table), defs.join(", ")))
}
/// Emit one statement per needed edit; falls back to a rebuild script (multi-stmt)
/// for edits SQLite's ALTER TABLE can't express.
pub fn sqlite_column_diff_sql(table: &str, old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
// rename detection: same position+type+nullable+default, name changed
for (i, n) in new.iter().enumerate() {
if let Some(o) = old.get(i) {
if o.name != n.name && o.type_ == n.type_ && o.default == n.default && o.nullable == n.nullable {
return Ok(vec![format!("ALTER TABLE \"main\".{} RENAME COLUMN {} TO {}", quote_ident(table), quote_ident(&o.name), quote_ident(&n.name))]);
}
}
}
// add column (new tail column, safe only if nullable or defaulted)
if new.len() > old.len() {
if let Some(c) = new.last() {
if c.nullable || c.default.is_some() {
validate_object_name(&c.name)?;
validate_type_fragment(&c.type_)?;
let mut d = format!("ALTER TABLE \"main\".{} ADD COLUMN {} {}", quote_ident(table), quote_ident(&c.name), c.type_.trim());
if !c.nullable {
d.push_str(&format!(" DEFAULT {}", c.default.as_deref().unwrap_or("''")));
}
return Ok(vec![d]);
}
}
}
// otherwise: full rebuild (type change, NOT NULL, drop, PK/UNIQUE/FK add, reorder)
sqlite_rebuild_script(table, old, new)
}
pub fn sqlite_rebuild_script(table: &str, _old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
validate_object_name(table)?;
let tmp = format!("_gl_{}_tmp", table);
let create = sqlite_create_table_sql(&tmp, new, &[])?;
let cols = new.iter().map(|c| quote_ident(&c.name)).collect::<Vec<_>>().join(", ");
Ok(vec![
create,
format!("INSERT INTO \"main\".{} ({}) SELECT * FROM \"main\".{}", quote_ident(&tmp), cols, quote_ident(table)),
format!("DROP TABLE \"main\".{}", quote_ident(table)),
format!("ALTER TABLE \"main\".{} RENAME TO {}", quote_ident(&tmp), quote_ident(table)),
])
}
/// Fail-closed readiness reason, or None if rebuild is safe.
/// AUTOINCREMENT tables are refused in v0.7.8 (rowid counter would be lost).
pub fn sqlite_rebuild_refusal(old: &[SqliteColumn]) -> Option<String> {
if old.iter().any(|c| c.auto_increment) {
return Some("rebuild is not supported for AUTOINCREMENT tables in v0.7.8 (rowid counter would be lost)".into());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_ident_doubles_embedded_quotes() {
assert_eq!(quote_ident("public"), "\"public\"");
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
}
#[test]
fn validate_object_name_rejects_dangerous_chars() {
assert!(validate_object_name("public").is_ok());
assert!(validate_object_name("").is_err());
assert!(validate_object_name("a; DROP").is_err());
assert!(validate_object_name("a--b").is_err());
assert!(validate_object_name("a'b").is_err());
assert!(validate_object_name("a\"b").is_err());
assert!(validate_object_name("a\\b").is_err());
assert!(validate_object_name("/*x*/").is_err());
}
#[test]
fn create_schema_sql_quotes_name() {
assert_eq!(create_schema_sql("my_schema").unwrap(), "CREATE SCHEMA \"my_schema\"");
assert!(create_schema_sql("bad; name").is_err());
}
#[test]
fn rename_schema_sql_quotes_both() {
assert_eq!(
rename_schema_sql("old", "new").unwrap(),
"ALTER SCHEMA \"old\" RENAME TO \"new\""
);
}
#[test]
fn drop_schema_sql_cascade_flag() {
assert_eq!(drop_schema_sql("s", false).unwrap(), "DROP SCHEMA \"s\"");
assert_eq!(drop_schema_sql("s", true).unwrap(), "DROP SCHEMA \"s\" CASCADE");
}
#[test]
fn object_search_query_unions_types_and_uses_position_match() {
let sql = pg_object_search_query();
assert!(sql.contains("position(lower($1) in lower("), "case-insensitive substring, no wildcard escaping: {sql}");
assert!(sql.contains("$2"), "schema is $2");
// covers every browsable type
for t in ["'TABLE'", "'VIEW'", "'MATERIALIZED VIEW'", "'FUNCTION'", "'PROCEDURE'", "'TRIGGER'", "'SEQUENCE'", "'ENUM'", "'EXTENSION'", "'INDEX'", "'CONSTRAINT'"] {
assert!(sql.contains(t), "search must cover {t}");
}
assert!(sql.contains("LIMIT 100"));
}
#[test]
fn object_oid_query_branches_per_type() {
assert!(pg_object_oid_query("table").contains("pg_class"));
assert!(pg_object_oid_query("function").contains("pg_proc"));
assert!(pg_object_oid_query("trigger").contains("pg_trigger"));
assert!(pg_object_oid_query("enum").contains("pg_type"));
assert!(pg_object_oid_query("extension").contains("pg_extension"));
assert!(pg_object_oid_query("constraint").contains("pg_constraint"));
}
#[test]
fn depend_query_filters_internal_and_resolves_names() {
let sql = pg_depend_query();
assert!(sql.contains("refobjid = $1"));
assert!(sql.contains("deptype IN ('n', 'a')"), "exclude internal 'i' / pinned 'p'");
assert!(sql.contains("pg_proc'::regclass"));
assert!(sql.contains("pg_trigger'::regclass"));
assert!(sql.contains("pg_constraint'::regclass"));
}
#[test]
fn depend_query_maps_rewrite_rules_to_the_view_class() {
// View dependencies surface in pg_depend as rewrite-rule rows
// (classid = pg_rewrite). The dependent object a user cares about is
// the VIEW itself, so the class must be reported as pg_class and the
// name resolved through ev_class (the view's relation).
let sql = pg_depend_query();
assert!(
sql.contains("WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'"),
"pg_rewrite rows must be reported as pg_class: {sql}"
);
assert!(
sql.contains("pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)"),
"pg_rewrite name resolves through ev_class: {sql}"
);
}
#[test]
fn sequence_ddl_is_synthesized() {
let s = SequenceInfo { name: "users_id_seq".into(), schema: "public".into(),
start_value: "1".into(), min_value: "1".into(), max_value: "9".into(),
increment: "1".into(), current_value: "5".into(), cycle: false };
let ddl = sequence_ddl(&s);
assert!(ddl.starts_with("CREATE SEQUENCE \"public\".\"users_id_seq\""));
assert!(ddl.contains("INCREMENT BY 1"));
assert!(ddl.contains("NO CYCLE"));
}
#[test]
fn enum_ddl_lists_labels_quoted() {
let e = EnumInfo { name: "role".into(), schema: "public".into(), labels: vec!["admin".into(), "user's".into()] };
let ddl = enum_ddl(&e);
assert!(ddl.starts_with("CREATE TYPE \"public\".\"role\" AS ENUM ("));
assert!(ddl.contains("'admin'"));
assert!(ddl.contains("'user''s'"), "single quotes doubled");
}
#[test]
fn extension_ddl_is_synthesized() {
let x = ExtensionInfo { name: "pgcrypto".into(), schema: "public".into(), version: "1.3".into(), comment: None };
let ddl = extension_ddl(&x);
assert!(ddl.contains("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""));
assert!(ddl.contains("WITH SCHEMA \"public\""));
assert!(ddl.contains("VERSION '1.3'"));
}
#[test]
fn view_ddl_wraps_selectdef() {
assert_eq!(view_ddl("public", "v_users", "SELECT * FROM users"),
"CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users");
}
#[test]
fn constraint_ddl_wraps_definition() {
let c = ConstraintInfo { name: "ck_pos".into(), schema: "public".into(), table: "orders".into(),
contype: "CHECK".into(), definition: "CHECK (amount > 0)".into(), deferrable: false, validated: true, columns: vec!["amount".into()] };
let ddl = constraint_ddl(&c);
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
}
#[test]
fn sqlite_create_with_autoincrement_pk() {
let cols = vec![
SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false },
SqliteColumn { name: "name".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
];
let sql = sqlite_create_table_sql("users", &cols, &[]).unwrap();
assert!(sql.contains("\"id\" INTEGER PRIMARY KEY AUTOINCREMENT"));
assert!(sql.contains("\"name\" TEXT"));
assert!(sql.starts_with("CREATE TABLE \"main\".\"users\""));
}
#[test]
fn sqlite_create_rejects_bad_identifier() {
let cols = vec![SqliteColumn { name: "a;b".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
assert!(sqlite_create_table_sql("bad name", &cols, &[]).is_err());
}
#[test]
fn sqlite_edit_add_column_when_safe() {
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false }];
let new = vec![
old[0].clone(),
SqliteColumn { name: "email".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
];
let stmts = sqlite_column_diff_sql("users", &old, &new).unwrap();
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("ALTER TABLE \"main\".\"users\" ADD COLUMN \"email\" TEXT"));
}
#[test]
fn sqlite_edit_rename_column() {
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let new = vec![SqliteColumn { name: "id2".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
assert!(stmts.iter().any(|s| s.contains("RENAME COLUMN \"id\" TO \"id2\"")));
}
#[test]
fn sqlite_edit_typechange_requires_rebuild() {
let old = vec![SqliteColumn { name: "v".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let new = vec![SqliteColumn { name: "v".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
assert!(stmts.iter().any(|s| s.contains("CREATE TABLE \"main\".\"_gl_t_tmp\"")), "type change must rebuild; got {stmts:?}");
}
}
+443
View File
@@ -0,0 +1,443 @@
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, Default, 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>,
#[serde(default)]
pub ssh_host: Option<String>,
#[serde(default)]
pub ssh_port: Option<i64>,
#[serde(default)]
pub ssh_user: Option<String>,
#[serde(default)]
pub ssh_auth_method: Option<String>,
#[serde(default)]
pub ssh_password: Option<String>,
#[serde(default)]
pub ssh_private_key_path: Option<String>,
#[serde(default)]
pub ssh_passphrase: 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,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_password: None,
ssh_private_key_path: None,
ssh_passphrase: None,
}
}
/// Build an `SshConfig` from the flat SSH fields, or `None` if no SSH host is set.
pub fn ssh_config(&self) -> Option<crate::models::SshConfig> {
let host = self.ssh_host.clone()?;
if host.is_empty() {
return None;
}
Some(crate::models::SshConfig {
host,
port: self.ssh_port.unwrap_or(22) as u16,
user: self.ssh_user.clone().unwrap_or_default(),
auth_method: self
.ssh_auth_method
.clone()
.unwrap_or_else(|| "password".to_string()),
password: self.ssh_password.clone(),
private_key_path: self.ssh_private_key_path.clone(),
passphrase: self.ssh_passphrase.clone(),
})
}
}
/// A handle to an active database connection.
///
/// Supports `Sqlite` (synchronous via `rusqlite`), `Postgresql` (async via
/// `tokio-postgres`), and `MySql` (async via `sqlx`). Redis has no DB-viewer
/// support. Eviction relies on each variant's `Drop`: `MySqlPool` closes its
/// connections when dropped (mirroring `Postgresql`'s `JoinHandle` abort).
#[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<()>),
/// An asynchronous MySQL connection pool via `sqlx`.
MySql(sqlx::MySqlPool),
}
/// 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,
/// Invoked with the id of every pool that gets evicted (LRU overflow in
/// `register` or shrinkage in `set_max_pools`). Lets callers free
/// associated resources (e.g. SSH tunnels).
on_evict: Option<Box<dyn Fn(&str) + Send + Sync>>,
}
impl ConnectionPoolManager {
/// Create a new manager with a maximum of 5 pools.
pub fn new() -> Self {
Self {
pools: indexmap::IndexMap::new(),
max_pools: 5,
on_evict: None,
}
}
/// Register a callback invoked with the id of every evicted pool.
pub fn set_on_evict(&mut self, cb: Box<dyn Fn(&str) + Send + Sync>) {
self.on_evict = Some(cb);
}
/// 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 {
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
if let Some(cb) = &self.on_evict {
cb(&evicted_id);
}
}
}
}
/// 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 {
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
if let Some(cb) = &self.on_evict {
cb(&evicted_id);
}
}
}
}
/// 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()),
..Default::default()
};
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());
}
#[test]
fn db_config_ssh_config_is_none_when_no_host() {
let cfg = DbConfig {
db_type: "PostgreSQL".into(),
host: "h".into(),
port: Some(5432),
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_password: None,
ssh_private_key_path: None,
ssh_passphrase: None,
};
assert!(cfg.ssh_config().is_none());
}
#[test]
fn db_config_ssh_config_builds_from_flat_fields() {
let cfg = DbConfig {
db_type: "PostgreSQL".into(),
host: "db".into(),
port: Some(5432),
username: None,
password: None,
database: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
ssh_host: Some("jump".into()),
ssh_port: Some(2222),
ssh_user: Some("u".into()),
ssh_auth_method: Some("password".into()),
ssh_password: Some("pw".into()),
ssh_private_key_path: None,
ssh_passphrase: None,
};
let s = cfg.ssh_config().expect("ssh config present");
assert_eq!(s.host, "jump");
assert_eq!(s.port, 2222);
assert_eq!(s.user, "u");
assert_eq!(s.auth_method, "password");
assert_eq!(s.password.as_deref(), Some("pw"));
}
// ------------------------------------------------------------------
// 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"));
}
#[test]
fn pool_invokes_on_evict_with_evicted_id() {
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(1);
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
let evicted_cb = evicted.clone();
manager.set_on_evict(Box::new(move |id: &str| {
evicted_cb.lock().unwrap().push(id.to_string());
}));
manager.register(
"a",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
manager.register(
"b",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
}
#[test]
fn pool_invokes_on_evict_on_max_pools_shrink() {
let mut manager = ConnectionPoolManager::new();
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
let evicted_cb = evicted.clone();
manager.set_on_evict(Box::new(move |id: &str| {
evicted_cb.lock().unwrap().push(id.to_string());
}));
manager.register(
"a",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
manager.register(
"b",
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
);
// Shrinking max_pools below the current count evicts oldest first.
manager.set_max_pools(1);
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
}
#[tokio::test]
async fn mysql_handle_can_be_registered_and_evicted() {
// Lazy pool: parses the URL without connecting (no network touch).
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_lazy("mysql://__gridline_test__:3306/__none__")
.expect("lazy pool parses url without connecting");
let mut manager = ConnectionPoolManager::new();
manager.set_max_pools(1);
manager.register("mysql-conn", DbHandle::MySql(pool));
assert!(matches!(
manager.get("mysql-conn"),
Some(DbHandle::MySql(_))
));
// Registering a second connection evicts the first (max_pools=1).
let sqlite = rusqlite::Connection::open_in_memory().unwrap();
manager.register("sqlite-conn", DbHandle::Sqlite(sqlite));
assert!(manager.get("mysql-conn").is_none());
assert!(matches!(
manager.get("sqlite-conn"),
Some(DbHandle::Sqlite(_))
));
}
}
+264
View File
@@ -0,0 +1,264 @@
//! TLS connector factory for tokio-postgres.
//!
//! Maps the user-facing SSL modes to rustls `ClientConfig` values:
//! - `disable` -> no TLS (returns `None`)
//! - `require` -> encrypt without verifying the server certificate (custom `NoVerifier`)
//! - `verify-ca` / `verify-full` -> standard rustls webpki verification (chain AND
//! hostname; `verify-ca` is intentionally identical to `verify-full` in v1)
//!
//! Client certificates are supported via optional `cert_path` / `key_path` pair.
use std::sync::Arc;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TlsDecision {
Disable,
Require,
Verify,
}
pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
match ssl_mode {
Some("require") => TlsDecision::Require,
Some("verify-ca") | Some("verify-full") => TlsDecision::Verify,
_ => TlsDecision::Disable,
}
}
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
pub fn build_tls_config(
decision: TlsDecision,
ca_path: Option<&str>,
cert_path: Option<&str>,
key_path: Option<&str>,
) -> Result<Option<Arc<ClientConfig>>, String> {
if matches!(decision, TlsDecision::Disable) {
return Ok(None);
}
let client_auth = match (cert_path, key_path) {
(Some(c), Some(k)) => Some(load_client_identity(c, k)?),
(Some(_), None) | (None, Some(_)) => {
return Err("both ssl_cert_path and ssl_key_path must be set for client auth".into())
}
(None, None) => None,
};
let config = match decision {
TlsDecision::Require => {
// Encrypt without verifying the server certificate.
let builder = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier));
match client_auth {
Some((certs, key)) => builder
.with_client_auth_cert(certs, key)
.map_err(|e| format!("client cert: {e}"))?,
None => builder.with_no_client_auth(),
}
}
TlsDecision::Verify => {
let mut roots = RootCertStore::empty();
if let Some(ca) = ca_path {
add_ca_file(&mut roots, ca)?;
} else {
return Err("ssl_ca_path is required for verify-ca / verify-full".into());
}
for ta in rustls_native_certs::load_native_certs()
.map_err(|e| format!("native certs: {e}"))?
{
let _ = roots.add(ta);
}
let builder = ClientConfig::builder().with_root_certificates(roots);
match client_auth {
Some((certs, key)) => builder
.with_client_auth_cert(certs, key)
.map_err(|e| format!("client cert: {e}"))?,
None => builder.with_no_client_auth(),
}
}
TlsDecision::Disable => unreachable!(),
};
Ok(Some(Arc::new(config)))
}
fn add_ca_file(roots: &mut RootCertStore, path: &str) -> Result<(), String> {
let bytes = std::fs::read(path).map_err(|e| format!("failed to read CA file {path}: {e}"))?;
let mut reader = std::io::BufReader::new(bytes.as_slice());
let parsed = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("parse CA: {e}"))?;
let added = parsed.into_iter().filter_map(|c| roots.add(c).ok()).count();
if added == 0 {
return Err("no usable CA certificates found".into());
}
Ok(())
}
fn load_client_identity(
cert_path: &str,
key_path: &str,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), String> {
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so reject them up
// front with a clear message before touching the certificate file.
let kb = std::fs::read(key_path).map_err(|e| format!("read key: {e}"))?;
if String::from_utf8_lossy(&kb).contains("ENCRYPTED PRIVATE KEY") {
return Err(
"encrypted client keys are not supported in v1; use an unencrypted PEM key".into(),
);
}
let cb = std::fs::read(cert_path).map_err(|e| format!("read cert: {e}"))?;
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut std::io::BufReader::new(cb.as_slice()))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("parse cert: {e}"))?
.into_iter()
.map(|c| c.into_owned())
.collect();
if certs.is_empty() {
return Err("no client certificates parsed".into());
}
let key = rustls_pemfile::private_key(&mut std::io::BufReader::new(kb.as_slice()))
.map_err(|e| format!("parse key: {e}"))?
.ok_or_else(|| "no private key parsed".to_string())?
.clone_key();
Ok((certs, key))
}
/// Accepts every certificate: TLS encryption without authentication (`require` mode).
#[derive(Debug)]
struct NoVerifier;
impl ServerCertVerifier for NoVerifier {
fn verify_server_cert(
&self,
_ee: &CertificateDer<'_>,
_ic: &[CertificateDer<'_>],
_n: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tls_decision_maps_modes() {
assert!(matches!(tls_decision(None), TlsDecision::Disable));
assert!(matches!(
tls_decision(Some("disable")),
TlsDecision::Disable
));
assert!(matches!(
tls_decision(Some("require")),
TlsDecision::Require
));
assert!(matches!(
tls_decision(Some("verify-ca")),
TlsDecision::Verify
));
assert!(matches!(
tls_decision(Some("verify-full")),
TlsDecision::Verify
));
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
}
#[test]
fn build_tls_disable_returns_none() {
assert!(build_tls_config(TlsDecision::Disable, None, None, None)
.unwrap()
.is_none());
}
#[test]
fn build_tls_require_returns_some_without_files() {
assert!(build_tls_config(TlsDecision::Require, None, None, None)
.unwrap()
.is_some());
}
#[test]
fn build_tls_verify_missing_ca_errors() {
let err = build_tls_config(TlsDecision::Verify, Some("/nonexistent/ca.pem"), None, None)
.unwrap_err();
assert!(err.to_lowercase().contains("ca"), "got: {err}");
}
#[test]
fn build_tls_client_cert_missing_key_errors() {
// cert set without key
let err = build_tls_config(
TlsDecision::Require,
None,
Some("/nonexistent/cert.pem"),
None,
)
.unwrap_err();
assert!(err.to_lowercase().contains("cert") || err.to_lowercase().contains("key"));
}
#[test]
fn build_tls_rejects_encrypted_key_marker() {
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so an ENCRYPTED
// PRIVATE KEY header must be rejected with a clear error. The cert file
// is a dummy: the key check fires before the cert is read.
let dir = std::env::temp_dir();
let cert_path = dir.join("gl_tls_cert_dummy.pem");
let key_path = dir.join("gl_tls_enc_key.pem");
std::fs::write(
&cert_path,
"-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n",
)
.unwrap();
std::fs::write(
&key_path,
"-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\n-----END ENCRYPTED PRIVATE KEY-----\n",
)
.unwrap();
let r = build_tls_config(
TlsDecision::Require,
None,
Some(cert_path.to_str().unwrap()),
Some(key_path.to_str().unwrap()),
);
assert!(r.is_err());
assert!(
r.unwrap_err().to_lowercase().contains("encrypt"),
"must mention encryption"
);
}
}