feat: Schema Visualizer, docs update & competitor comparison

* chore: add @xyflow/react, dagre, @types/dagre (Task 1)

* feat: add SchemaGraph, TableNode, GraphColumn, Relationship types (Task 2)

* feat: add SchemaGraph, TableNode, GraphColumn, Relationship Rust models (Task 3)

* feat: add cardinality color/label helpers and legend data (Task 4)

* feat: add schema_graph command skeleton with validation (Task 5)

* feat: add PostgreSQL schema graph query builder + cardinality inference (Task 6)

* feat: implement get_schema_graph for PostgreSQL + SQLite (Task 7)

* feat: add getSchemaGraph IPC wrapper (Task 8)

* feat: un-stub Schema Visualizer nav + add view branch placeholder (Task 9)

* feat: add SchemaVisualizerNode custom React Flow table card (Task 10)

* feat: add SchemaVisualizerPage with React Flow canvas (Task 11)

* feat: wire SchemaVisualizerPage + error handling + style polish (Tasks 12-14)

* perf: replace information_schema with pg_catalog for schema graph query (500x+ faster on remote PG)

* fix: add DB selector, ghost-style dropdowns, dark controls, minimap styling, attribution

* fix: always show schema selector, move attribution to top-left

* fix: SelectDropdown close on outside click works in React Flow (capture phase)

* style: remove border from attribution badge

* style: restore bg on attribution, no border

* fix: lower attribution z-index so dropdowns render above

* fix: ensure toolbar + dropdowns stack above canvas attribution

* feat: crow's foot markers on edges + collapsible legend

* fix: restore missing tableCount state (was overwritten by legendOpen)

* fix: move crow's foot marker defs inside ReactFlow SVG, remove duplicate external SVGs

* fix: inject crow's foot SVG markers into ReactFlow SVG via DOM ref

* fix: use hidden SVG before ReactFlow for crow's foot markers, remove DOM injection

* feat: custom CrowsFootEdge component with inline crow's foot markers

* feat: custom CrowsFootEdge with zero-or-one/zero-or-many notation + nullability-based cardinality

* fix: strip markers, use clean text labels only on edges

* fix: add SVG marker defs directly inside ReactFlow + url() references for crow's foot

* fix: use custom CrowsFootEdge with BaseEdge + inline SVG symbols (no marker defs needed)

* debug: add red/green circles at edge endpoints to verify custom edge renders

* fix: remove stale duplicate edge data, use clean data.startMarker/endMarker

* fix: use getSmoothStepPath offset points for correct tangent angle at endpoints

* fix: thicker strokeWidth, position at handle coords, use path tangents

* fix: use straight-line angle (not curve tangent) for marker rotation

* fix: compute marker positions directly with raw math, no SVG transforms

* fix: fixed-orientation symbols — | always vertical, crow's foot fans toward node

* fix: dead simple — | vertical line, ← or → horizontal crow's foot based on edge direction

* fix: increase marker gap to 12px so symbols aren't hidden behind handle dots

* fix: correct offset direction (away from card into gap), G=4

* fix: remove duplicate G offset inside Mark (was canceling out the call-site offset)

* fix: crow's foot back to fork shape — three lines converging to a tip

* fix: flip crow's foot direction

* fix: wider crow's foot spread (4→6)

* feat: add crow's foot symbols to relationship legend

* style: cleaner legend — horizontal edge with endpoint symbols + label

* feat: handles on both sides, edge builder picks closest side based on dagre layout

* fix: compute actual handle distances to pick shortest path

* revert: PK always left, FK always right — one handle per column only

* fix: lock edge marker direction via origRight, TB layout for horizontal spread, truncate long types

* fix: semi-transparent minimap mask, border stroke for viewport visibility

* feat: click edge to highlight (amber glow), all others dim to 15% opacity

* feat: legend highlights matching cardinality row when edge is clicked

* fix: crow's foot symbols now read color from edge style (amber when highlighted)

* fix: highlighted edge gets zIndex 1000 to render on top

* fix: folder empty message now checks unfiltered store, shows filter hint when connections exist but filtered out

* feat: flat SVG DB icons from simple-icons (PostgreSQL, MySQL, SQLite, Redis) replacing emoji

* chore: add *.db, *.sqlite, *.sqlite3 to .gitignore

* docs: update README with 3-way competitor comparison + current roadmap; update AGENTS.md schema visualizer status
This commit is contained in:
2026-07-29 22:36:59 +08:00
committed by GitHub
parent 3dab09f25d
commit 1f081a66e0
30 changed files with 1788 additions and 74 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ use tokio_postgres::types::ToSql;
/// tokio-postgres's `Display` only prints "db error", so we walk the
/// `std::error::Error::source()` chain and also use `Debug` to surface the
/// real message (e.g. "password authentication failed for user \"x\"").
fn pg_error_message(err: &tokio_postgres::Error) -> String {
pub(crate) fn pg_error_message(err: &tokio_postgres::Error) -> String {
// Prefer the Debug representation, which includes severity + message + code.
let raw = format!("{:?}", err);
// Redact postgres URL fragments and password=... sequences.
@@ -486,7 +486,7 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
}
}
fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value {
pub(crate) fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value {
// Integer types
if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) {
return serde_json::json!(v);
+2 -1
View File
@@ -8,4 +8,5 @@ pub mod test_connection;
pub mod ssh;
pub mod keychain;
pub mod demo;
pub mod backup;
pub mod backup;
pub mod schema_graph;
+459
View File
@@ -0,0 +1,459 @@
#[allow(unused_imports)]
use crate::db::pool::DbHandle;
use crate::models::db_viewer::{SchemaGraph, TableNode, GraphColumn, Relationship};
use std::collections::HashMap;
use tauri::State;
/// Validate a schema name for safe use in parameterized queries.
/// Rejects empty strings and names containing SQL metacharacters.
pub fn validate_schema_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Schema name cannot be empty".into());
}
if name.contains(';')
|| name.contains("--")
|| name.contains("/*")
|| name.contains('\'')
|| name.contains('"')
|| name.contains('\\')
{
return Err(format!("Invalid schema name: {}", name));
}
Ok(())
}
/// Build a parameterized query that fetches all tables, columns, and
/// PK/FK/UNIQUE metadata for a PostgreSQL schema in a single round-trip.
pub fn build_pg_schema_graph_query(_schema: &str) -> String {
// Uses pg_catalog directly instead of information_schema views.
// information_schema views are extremely slow on some servers (remote/
// cloud) because they scan all databases' catalogs. pg_catalog with
// LATERAL joins is typically 500x+ faster (~200ms vs 120s for 55 tables).
r#"SELECT
c.relname AS table_name,
n.nspname AS table_schema,
CASE WHEN c.relkind = 'v' THEN 'VIEW' ELSE 'BASE TABLE' END AS table_type,
a.attname AS column_name,
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
NOT a.attnotnull AS is_nullable,
a.attnum AS ordinal_position,
COALESCE(pk.is_pk, false) AS is_pk,
COALESCE(fk.is_fk, false) AS is_fk,
fk.foreign_table_schema,
fk.foreign_table_name,
fk.foreign_column_name,
COALESCE(uq.is_unique, false) AS is_unique
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
LEFT JOIN LATERAL (
SELECT true AS is_pk
FROM pg_catalog.pg_constraint pk2
WHERE pk2.conrelid = c.oid AND pk2.contype = 'p' AND a.attnum = ANY(pk2.conkey)
LIMIT 1
) pk ON true
LEFT JOIN LATERAL (
SELECT true AS is_fk,
ref_n.nspname AS foreign_table_schema,
ref_c.relname AS foreign_table_name,
ref_a.attname AS foreign_column_name
FROM pg_catalog.pg_constraint fk2
JOIN pg_catalog.pg_class ref_c ON fk2.confrelid = ref_c.oid
JOIN pg_catalog.pg_namespace ref_n ON ref_c.relnamespace = ref_n.oid
JOIN pg_catalog.pg_attribute ref_a
ON ref_a.attrelid = ref_c.oid AND ref_a.attnum = ANY(fk2.confkey)
WHERE fk2.conrelid = c.oid AND fk2.contype = 'f'
AND a.attnum = ANY(fk2.conkey)
LIMIT 1
) fk ON true
LEFT JOIN LATERAL (
SELECT true AS is_unique
FROM pg_catalog.pg_constraint uq2
WHERE uq2.conrelid = c.oid AND uq2.contype = 'u' AND a.attnum = ANY(uq2.conkey)
LIMIT 1
) uq ON true
WHERE n.nspname = $1
AND c.relkind IN ('r', 'v', 'p')
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY c.relname, a.attnum"#.to_string()
}
/// Infer relationship cardinality from constraint metadata.
///
/// - `is_pk`: the FK column is also part of the primary key
/// - `is_unique`: the FK column has a UNIQUE constraint
/// - `is_nullable`: the FK column allows NULL values
/// - `is_join_table_fk`: this FK belongs to a join table
pub fn infer_cardinality(
is_pk: bool,
is_unique: bool,
is_nullable: bool,
is_join_table_fk: bool,
) -> String {
if is_join_table_fk {
return "N:M".into();
}
let one_side = is_pk || is_unique;
match (one_side, is_nullable) {
(true, false) => "1:1".into(),
(true, true) => "0..1:0..1".into(),
(false, false) => "1:N".into(),
(false, true) => "0..N".into(),
}
}
pub fn parse_pg_schema_rows(
rows: &[Vec<serde_json::Value>],
) -> (Vec<TableNode>, Vec<Relationship>) {
let mut table_map: HashMap<(String, String), (String, Vec<GraphColumn>)> = HashMap::new();
let mut relationships: Vec<Relationship> = Vec::new();
for row in rows {
let table_name = row[0].as_str().unwrap_or_default().to_string();
let table_schema = row[1].as_str().unwrap_or_default().to_string();
let table_type = row[2].as_str().unwrap_or_default().to_string();
let col_name = row[3].as_str().unwrap_or_default().to_string();
let data_type = row[4].as_str().unwrap_or_default().to_string();
let is_nullable = row[5].as_bool().unwrap_or(false);
let is_pk = row[7].as_bool().unwrap_or(false);
let is_fk = row[8].as_bool().unwrap_or(false);
let fk_schema = row[9].as_str().map(String::from);
let fk_table = row[10].as_str().map(String::from);
let fk_column = row[11].as_str().map(String::from);
let is_unique = row[12].as_bool().unwrap_or(false);
let fk_ref = if is_fk {
match (&fk_schema, &fk_table, &fk_column) {
(Some(s), Some(t), Some(c)) => Some((s.clone(), t.clone(), c.clone())),
_ => None,
}
} else {
None
};
let col = GraphColumn {
name: col_name.clone(),
data_type,
is_pk,
is_fk,
is_unique: is_unique || is_pk,
is_nullable,
fk_ref: fk_ref.clone(),
};
let key = (table_schema.clone(), table_name.clone());
table_map
.entry(key)
.or_insert_with(|| (table_type.clone(), Vec::new()))
.1
.push(col);
if let Some((ref_schema, ref_table, ref_column)) = fk_ref {
relationships.push(Relationship {
source_schema: table_schema.clone(),
source_table: table_name.clone(),
source_column: col_name,
target_schema: ref_schema,
target_table: ref_table,
target_column: ref_column,
cardinality: String::new(),
});
}
}
// Detect N:M join tables: tables where ALL PK columns are also FK columns
let join_table_keys: Vec<(String, String)> = table_map
.iter()
.filter(|(_, (_, cols))| {
let pk_cols: Vec<&GraphColumn> = cols.iter().filter(|c| c.is_pk).collect();
!pk_cols.is_empty() && pk_cols.iter().all(|c| c.is_fk)
})
.map(|(k, _)| k.clone())
.collect();
// Assign cardinality to each relationship
for rel in &mut relationships {
let source_key = (rel.source_schema.clone(), rel.source_table.clone());
let is_join = join_table_keys.contains(&source_key);
let (is_pk_or_unique, is_nullable) = table_map
.get(&source_key)
.and_then(|(_, cols)| cols.iter().find(|c| c.name == rel.source_column))
.map(|c| (c.is_pk || c.is_unique, c.is_nullable))
.unwrap_or((false, false));
rel.cardinality = infer_cardinality(is_pk_or_unique, is_pk_or_unique, is_nullable, is_join);
}
let mut tables: Vec<TableNode> = table_map
.into_iter()
.map(|((schema, name), (table_type, columns))| TableNode {
name,
schema,
table_type,
columns,
})
.collect();
tables.sort_by(|a, b| a.name.cmp(&b.name));
(tables, relationships)
}
fn build_sqlite_schema_graph(
conn: &rusqlite::Connection,
schema: &str,
) -> Result<SchemaGraph, String> {
if schema != "main" {
return Err(format!("SQLite only supports schema 'main', got: {}", schema));
}
let mut stmt = conn
.prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
.map_err(|e| e.to_string())?;
let table_rows: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
let mut tables: Vec<TableNode> = Vec::new();
let mut relationships: Vec<Relationship> = Vec::new();
for (table_name, table_type) in &table_rows {
let pragma_sql = format!("PRAGMA table_info('{}')", table_name);
let mut ps = conn.prepare(&pragma_sql).map_err(|e| e.to_string())?;
let col_meta: Vec<(String, String, bool, bool)> = ps
.query_map([], |row| Ok((
row.get::<_, String>(1)?, row.get::<_, String>(2)?,
row.get::<_, bool>(3)?, row.get::<_, bool>(5)?,
)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
let fk_sql = format!("PRAGMA foreign_key_list('{}')", table_name);
let fk_cols: HashMap<String, (String, String)> = if let Ok(mut fs) = conn.prepare(&fk_sql) {
fs.query_map([], |row| Ok((
row.get::<_, String>(3)?, row.get::<_, String>(2)?, row.get::<_, String>(4)?,
)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|(col, ref_t, ref_c)| (col, (ref_t, ref_c)))
.collect()
} else {
HashMap::new()
};
let columns: Vec<GraphColumn> = col_meta.iter().map(|(name, dtype, _nn, is_pk)| {
let fk = fk_cols.get(name);
let is_fk = fk.is_some();
let fk_ref = fk.map(|(t, c)| ("main".into(), t.clone(), c.clone()));
if let Some((ref_t, ref_c)) = fk {
relationships.push(Relationship {
source_schema: "main".into(), source_table: table_name.clone(),
source_column: name.clone(),
target_schema: "main".into(), target_table: ref_t.clone(),
target_column: ref_c.clone(),
cardinality: infer_cardinality(*is_pk, false, !_nn, false),
});
}
GraphColumn {
name: name.clone(),
data_type: if dtype.is_empty() { "TEXT".into() } else { dtype.clone() },
is_pk: *is_pk, is_fk, is_unique: *is_pk,
is_nullable: !_nn,
fk_ref: fk_ref.map(|(s, t, c)| (s, t, c)),
}
}).collect();
tables.push(TableNode {
name: table_name.clone(), schema: "main".into(),
table_type: table_type.to_uppercase(), columns,
});
}
Ok(SchemaGraph { tables, relationships })
}
#[tauri::command]
pub async fn get_schema_graph(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<SchemaGraph, String> {
let schema = schema.unwrap_or_else(|| "public".to_string());
validate_schema_name(&schema)?;
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let query = build_pg_schema_graph_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| crate::commands::db_viewer::pg_error_message(&e))?;
let json_rows: Vec<Vec<serde_json::Value>> = rows
.iter()
.map(|row| {
(0..row.len())
.map(|i| crate::commands::db_viewer::pg_value_to_json(row, i))
.collect()
})
.collect();
let (tables, relationships) = parse_pg_schema_rows(&json_rows);
Ok(SchemaGraph { tables, relationships })
}
Some(DbHandle::Sqlite(conn)) => build_sqlite_schema_graph(conn, &schema),
None => Err("Connection not found".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_schema_name_rejects_empty() {
assert!(validate_schema_name("").is_err());
}
#[test]
fn validate_schema_name_rejects_semicolon() {
assert!(validate_schema_name("public; DROP TABLE users").is_err());
}
#[test]
fn validate_schema_name_rejects_sql_comment() {
assert!(validate_schema_name("public--comment").is_err());
assert!(validate_schema_name("public/*comment*/").is_err());
}
#[test]
fn validate_schema_name_rejects_quotes() {
assert!(validate_schema_name("pub'lic").is_err());
assert!(validate_schema_name("pub\"lic").is_err());
}
#[test]
fn validate_schema_name_rejects_backslash() {
assert!(validate_schema_name("public\\schema").is_err());
}
#[test]
fn validate_schema_name_accepts_valid_names() {
assert!(validate_schema_name("public").is_ok());
assert!(validate_schema_name("my_schema").is_ok());
assert!(validate_schema_name("schema123").is_ok());
assert!(validate_schema_name("auth").is_ok());
}
#[test]
fn build_pg_schema_graph_query_is_parameterized() {
let sql = build_pg_schema_graph_query("public");
// Must use $1 for schema parameter (parameterized)
assert!(sql.contains("$1"), "query should use $1 placeholder; got: {}", sql);
// Must not interpolate schema name directly in a potentially unsafe way
assert!(!sql.contains("'public'"), "query should not use literal 'public'");
}
#[test]
fn build_pg_schema_graph_query_queries_columns() {
let sql = build_pg_schema_graph_query("myschema");
assert!(sql.contains("pg_catalog.pg_class"), "should query pg_class");
assert!(sql.contains("pg_catalog.pg_attribute"), "should query pg_attribute");
assert!(sql.contains("pg_catalog.pg_constraint"), "should include constraint info");
}
#[test]
fn infer_cardinality_one_to_one_pk() {
assert_eq!(infer_cardinality(true, false, false, false), "1:1");
}
#[test]
fn infer_cardinality_zero_or_one() {
// UNIQUE + nullable → 0..1:0..1
assert_eq!(infer_cardinality(false, true, true, false), "0..1:0..1");
}
#[test]
fn infer_cardinality_one_to_many() {
assert_eq!(infer_cardinality(false, false, false, false), "1:N");
}
#[test]
fn infer_cardinality_zero_or_many() {
// not PK, not UNIQUE, nullable → 0..N
assert_eq!(infer_cardinality(false, false, true, false), "0..N");
}
#[test]
fn infer_cardinality_many_to_many() {
assert_eq!(infer_cardinality(false, false, false, true), "N:M");
}
#[test]
fn parse_pg_schema_rows_builds_correct_graph() {
let rows: Vec<Vec<serde_json::Value>> = vec![
// users.id (PK)
vec![
serde_json::json!("users"), serde_json::json!("public"), serde_json::json!("BASE TABLE"),
serde_json::json!("id"), serde_json::json!("integer"), serde_json::json!("NO"),
serde_json::json!(1), serde_json::json!(true), serde_json::json!(false),
serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null,
serde_json::json!(true),
],
// users.email (non-key)
vec![
serde_json::json!("users"), serde_json::json!("public"), serde_json::json!("BASE TABLE"),
serde_json::json!("email"), serde_json::json!("text"), serde_json::json!("NO"),
serde_json::json!(2), serde_json::json!(false), serde_json::json!(false),
serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null,
serde_json::json!(true),
],
// orders.id (PK)
vec![
serde_json::json!("orders"), serde_json::json!("public"), serde_json::json!("BASE TABLE"),
serde_json::json!("id"), serde_json::json!("integer"), serde_json::json!("NO"),
serde_json::json!(1), serde_json::json!(true), serde_json::json!(false),
serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null,
serde_json::json!(true),
],
// orders.user_id (FK → users.id)
vec![
serde_json::json!("orders"), serde_json::json!("public"), serde_json::json!("BASE TABLE"),
serde_json::json!("user_id"), serde_json::json!("integer"), serde_json::json!("NO"),
serde_json::json!(2), serde_json::json!(false), serde_json::json!(true),
serde_json::json!("public"), serde_json::json!("users"), serde_json::json!("id"),
serde_json::json!(false),
],
];
let (tables, relationships) = parse_pg_schema_rows(&rows);
assert_eq!(tables.len(), 2, "should have 2 tables");
assert_eq!(relationships.len(), 1, "should have 1 relationship");
let users = tables.iter().find(|t| t.name == "users").unwrap();
assert_eq!(users.columns.len(), 2);
assert!(users.columns[0].is_pk);
let orders = tables.iter().find(|t| t.name == "orders").unwrap();
assert_eq!(orders.columns.len(), 2);
let rel = &relationships[0];
assert_eq!(rel.source_table, "orders");
assert_eq!(rel.target_table, "users");
assert_eq!(rel.source_column, "user_id");
assert_eq!(rel.target_column, "id");
assert_eq!(rel.cardinality, "1:N");
}
#[test]
fn parse_pg_schema_rows_empty_yields_empty_graph() {
let rows: Vec<Vec<serde_json::Value>> = vec![];
let (tables, relationships) = parse_pg_schema_rows(&rows);
assert!(tables.is_empty());
assert!(relationships.is_empty());
}
}
+2 -1
View File
@@ -19,7 +19,7 @@ pub struct AppState {
pub ssh_manager: StdMutex<SshTunnelManager>,
}
use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup};
use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup, schema_graph};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
@@ -94,6 +94,7 @@ pub fn run() {
backup::pg_dump,
backup::pg_restore,
backup::db_sync,
schema_graph::get_schema_graph,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+142
View File
@@ -147,6 +147,50 @@ impl Change {
}
}
/// Complete schema graph for the ER diagram visualizer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaGraph {
pub tables: Vec<TableNode>,
pub relationships: Vec<Relationship>,
}
/// A table node in the schema graph, including all columns.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableNode {
pub name: String,
pub schema: String,
pub table_type: String,
pub columns: Vec<GraphColumn>,
}
/// Column metadata for schema graph visualization.
///
/// Includes PK/FK/UNIQUE flags and an optional foreign-key reference
/// (referenced_schema, referenced_table, referenced_column).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphColumn {
pub name: String,
pub data_type: String,
pub is_pk: bool,
pub is_fk: bool,
pub is_unique: bool,
pub is_nullable: bool,
pub fk_ref: Option<(String, String, String)>,
}
/// A foreign-key relationship between two tables.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Relationship {
pub source_schema: String,
pub source_table: String,
pub source_column: String,
pub target_schema: String,
pub target_table: String,
pub target_column: String,
/// Inferred cardinality: "1:1", "1:N", or "N:M"
pub cardinality: String,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -335,4 +379,102 @@ mod tests {
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("pg_stat_statements"));
}
#[test]
fn schema_graph_serialization() {
let graph = SchemaGraph {
tables: vec![TableNode {
name: "users".into(),
schema: "public".into(),
table_type: "TABLE".into(),
columns: vec![
GraphColumn {
name: "id".into(),
data_type: "integer".into(),
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: None,
},
GraphColumn {
name: "email".into(),
data_type: "text".into(),
is_pk: false,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: None,
},
],
}],
relationships: vec![Relationship {
source_schema: "public".into(),
source_table: "orders".into(),
source_column: "user_id".into(),
target_schema: "public".into(),
target_table: "users".into(),
target_column: "id".into(),
cardinality: "1:N".into(),
}],
};
let json = serde_json::to_string(&graph).unwrap();
assert!(json.contains("users"), "should contain table name");
assert!(json.contains("orders"), "should contain relationship source table");
assert!(json.contains("1:N"), "should contain cardinality");
assert!(json.contains("is_pk"), "should contain is_pk field");
assert!(json.contains("is_fk"), "should contain is_fk field");
assert!(json.contains("is_unique"), "should contain is_unique field");
// Round-trip deserialization
let parsed: SchemaGraph = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.tables.len(), 1);
assert_eq!(parsed.tables[0].columns.len(), 2);
assert_eq!(parsed.relationships.len(), 1);
assert_eq!(parsed.relationships[0].cardinality, "1:N");
}
#[test]
fn schema_graph_empty_is_valid() {
let graph = SchemaGraph {
tables: vec![],
relationships: vec![],
};
let json = serde_json::to_string(&graph).unwrap();
let parsed: SchemaGraph = serde_json::from_str(&json).unwrap();
assert!(parsed.tables.is_empty());
assert!(parsed.relationships.is_empty());
}
#[test]
fn graph_column_fk_ref_serialization() {
// fk_ref = None
let col_none = GraphColumn {
name: "name".into(),
data_type: "text".into(),
is_pk: false,
is_fk: false,
is_unique: false,
is_nullable: false,
fk_ref: None,
};
let json = serde_json::to_string(&col_none).unwrap();
assert!(json.contains("null"), "fk_ref=None should serialize as null");
// fk_ref = Some(...)
let col_some = GraphColumn {
name: "user_id".into(),
data_type: "integer".into(),
is_pk: false,
is_fk: true,
is_unique: false,
is_nullable: false,
fk_ref: Some(("public".into(), "users".into(), "id".into())),
};
let json = serde_json::to_string(&col_some).unwrap();
assert!(json.contains("public"), "should contain referenced schema");
assert!(json.contains("users"), "should contain referenced table");
assert!(json.contains("id"), "should contain referenced column");
}
}