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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user