use serde::{Deserialize, Serialize}; /// A single filter rule sent from the frontend. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FilterRule { pub id: String, pub column: String, pub operator: String, // "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull" pub value: String, } /// A single sort rule sent from the frontend. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SortRule { pub id: String, pub column: String, pub order: String, // "asc" | "desc" } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TableInfo { pub name: String, pub schema: String, pub table_type: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ColumnInfo { pub name: String, pub data_type: String, pub is_nullable: bool, pub is_pk: bool, pub is_fk: bool, pub fk_ref: Option<(String, String)>, pub default_value: Option, pub editable: bool, pub is_generated: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IndexInfo { pub name: String, pub schema: String, pub table: String, pub definition: String, pub is_unique: bool, pub method: String, pub columns: Vec, pub size_bytes: Option, pub tablespace: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConstraintInfo { pub name: String, pub schema: String, pub table: String, pub contype: String, // "CHECK" | "UNIQUE" | "EXCLUSION" pub definition: String, pub deferrable: bool, pub validated: bool, pub columns: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueryResult { pub columns: Vec, pub rows: Vec>, pub total_rows: i64, pub page: i64, pub page_size: i64, pub execution_time_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatementNotice { pub statement_index: usize, /// "dml" | "ddl" | "error" pub kind: String, pub text: String, pub affected: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MultiQueryResult { pub result_sets: Vec, pub notices: Vec, pub execution_time_ms: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Pagination { pub page: i64, pub page_size: i64, pub total_rows: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FunctionInfo { pub name: String, pub schema: String, pub return_type: String, pub argument_types: Vec, pub argument_names: Vec, pub argument_modes: Vec, pub language: String, pub source: Option, pub kind: String, // 'f' = function, 'p' = procedure } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TriggerInfo { pub name: String, pub schema: String, pub table_schema: String, pub table_name: String, pub event_manipulation: String, pub action_timing: String, pub action_orientation: String, pub action_statement: String, pub enabled: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SequenceInfo { pub name: String, pub schema: String, pub start_value: String, pub min_value: String, pub max_value: String, pub increment: String, pub current_value: String, pub cycle: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EnumInfo { pub name: String, pub schema: String, pub labels: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExtensionInfo { pub name: String, pub schema: String, pub version: String, pub comment: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HypertableInfo { pub name: String, pub schema: String, pub num_dimensions: i64, pub compression_enabled: bool, pub num_chunks: i64, pub total_size_bytes: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HypertableListResponse { pub available: bool, pub reason: Option, pub items: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObjectSearchHit { pub name: String, pub schema: String, pub object_type: String, // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DependencyInfo { pub deptype: String, // "n" (normal) | "a" (auto) pub class: String, // pg_class | pg_proc | pg_trigger | pg_type | pg_constraint | pg_rewrite pub name: String, // resolved dependent object name } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RoleMembership { pub role: String, pub member: String, pub grantor: String, pub admin_option: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RoleInfo { pub name: String, pub superuser: bool, pub inherit: bool, pub create_db: bool, pub create_role: bool, pub can_login: bool, pub replication: bool, pub bypass_rls: bool, pub connection_limit: i64, pub valid_until: Option, pub memberships: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrivilegeEntry { pub object_class: String, // "table" | "sequence" | "routine" | "schema" | "database" pub schema: Option, pub name: String, pub privileges: Vec, pub grantable: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RebuildReadiness { pub ok: bool, pub reasons: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MaintenanceResult { pub duration_ms: i64, pub message: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TablespaceInfo { pub name: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Change { Update { id: String, schema: String, table: String, primary_key: String, old_data: String, new_data: String, }, Insert { id: String, schema: String, table: String, data: String, }, Delete { id: String, schema: String, table: String, primary_key: String, }, AlterTable { id: String, schema: String, table: String, sql: String, rollback_sql: String, }, BulkInsert { id: String, schema: String, table: String, columns: Vec, rows: Vec>, }, DropTable { id: String, schema: String, table: String, }, EmptyTable { id: String, schema: String, table: String, }, Ddl { id: String, sql: String, }, RebuildTable { id: String, sql: String, }, } impl Change { pub fn id(&self) -> &str { match self { Change::Update { id, .. } | Change::Insert { id, .. } | Change::Delete { id, .. } | Change::AlterTable { id, .. } | Change::BulkInsert { id, .. } | Change::DropTable { id, .. } | Change::EmptyTable { id, .. } | Change::Ddl { id, .. } | Change::RebuildTable { id, .. } => id, } } } /// Complete schema graph for the ER diagram visualizer. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchemaGraph { pub tables: Vec, pub relationships: Vec, } /// 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, } /// 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::*; #[test] fn table_info_serialization() { let info = TableInfo { name: "users".to_string(), schema: "public".to_string(), table_type: "TABLE".to_string(), }; let json = serde_json::to_string(&info).unwrap(); assert!(json.contains("users")); assert!(json.contains("public")); assert!(json.contains("TABLE")); } #[test] fn query_result_can_be_empty() { let result = QueryResult { columns: vec![], rows: vec![], total_rows: 0, page: 1, page_size: 100, execution_time_ms: None, }; let json = serde_json::to_string(&result).unwrap(); assert!(json.contains(r#""rows":[]"#)); } #[test] fn change_id_method() { let update = Change::Update { id: "chg-1".to_string(), schema: "public".to_string(), table: "users".to_string(), primary_key: "{\"id\": 1}".to_string(), old_data: "{\"name\": \"old\"}".to_string(), new_data: "{\"name\": \"new\"}".to_string(), }; assert_eq!(update.id(), "chg-1"); let inserted = Change::Insert { id: "chg-2".to_string(), schema: "public".to_string(), table: "users".to_string(), data: "{\"name\": \"alice\"}".to_string(), }; assert_eq!(inserted.id(), "chg-2"); let deleted = Change::Delete { id: "chg-3".to_string(), schema: "public".to_string(), table: "users".to_string(), primary_key: "{\"id\": 2}".to_string(), }; assert_eq!(deleted.id(), "chg-3"); let alter = Change::AlterTable { id: "chg-4".to_string(), schema: "public".to_string(), table: "users".to_string(), sql: "ALTER TABLE users ADD COLUMN age INT".to_string(), rollback_sql: "ALTER TABLE users DROP COLUMN age".to_string(), }; assert_eq!(alter.id(), "chg-4"); } #[test] fn change_serde_tag() { let update = Change::Update { id: "chg-1".to_string(), schema: "public".to_string(), table: "users".to_string(), primary_key: "{\"id\": 1}".to_string(), old_data: "{\"name\": \"old\"}".to_string(), new_data: "{\"name\": \"new\"}".to_string(), }; let json = serde_json::to_string(&update).unwrap(); assert!( json.contains(r#""type":"update""#), "serialized Change::Update should use snake_case tag 'update'; got: {}", json ); } #[test] fn change_bulk_insert_roundtrip() { let json = serde_json::json!({ "type": "bulk_insert", "id": "x", "schema": "public", "table": "t", "columns": ["a", "b"], "rows": [[1, "y"], [2, "z"]] }); let c: Change = serde_json::from_value(json).unwrap(); match c { Change::BulkInsert { columns, rows, .. } => { assert_eq!(columns, vec!["a".to_string(), "b".to_string()]); assert_eq!(rows.len(), 2); } _ => panic!("expected BulkInsert"), } } #[test] fn change_drop_and_empty_roundtrip() { let drop: Change = serde_json::from_value(serde_json::json!({ "type": "drop_table", "id": "d", "schema": "public", "table": "t" })) .unwrap(); assert_eq!(drop.id(), "d"); let empty: Change = serde_json::from_value(serde_json::json!({ "type": "empty_table", "id": "e", "schema": "public", "table": "t" })) .unwrap(); assert_eq!(empty.id(), "e"); } #[test] fn column_info_fk_ref() { let col = ColumnInfo { name: "user_id".to_string(), data_type: "integer".to_string(), is_nullable: true, is_pk: false, is_fk: true, fk_ref: Some(("users".to_string(), "id".to_string())), default_value: None, editable: true, is_generated: false, }; let json = serde_json::to_string(&col).unwrap(); assert!(json.contains("user_id")); assert!(json.contains("users")); } #[test] fn column_info_has_editability_fields() { let c = ColumnInfo { name: "id".into(), data_type: "integer".into(), is_nullable: false, is_pk: true, is_fk: false, fk_ref: None, default_value: None, editable: false, is_generated: false, }; let json = serde_json::to_string(&c).unwrap(); assert!(json.contains("\"editable\":false")); assert!(json.contains("\"is_generated\":false")); } #[test] fn pagination_serialization() { let pagination = Pagination { page: 2, page_size: 50, total_rows: 250, }; let json = serde_json::to_string(&pagination).unwrap(); assert!(json.contains(r#""page":2"#)); assert!(json.contains(r#""page_size":50"#)); assert!(json.contains(r#""total_rows":250"#)); } #[test] fn function_info_serialization() { let info = FunctionInfo { name: "get_user".into(), schema: "public".into(), return_type: "TABLE(id integer, name text)".into(), argument_types: vec!["integer".into()], argument_names: vec!["p_id".into()], argument_modes: vec!["IN".into()], language: "plpgsql".into(), source: Some("BEGIN RETURN; END;".into()), kind: "f".into(), }; let json = serde_json::to_string(&info).unwrap(); assert!(json.contains("get_user")); assert!(json.contains("plpgsql")); } #[test] fn trigger_info_serialization() { let info = TriggerInfo { name: "trg_audit".into(), schema: "public".into(), table_schema: "public".into(), table_name: "users".into(), event_manipulation: "INSERT".into(), action_timing: "AFTER".into(), action_orientation: "ROW".into(), action_statement: "EXECUTE FUNCTION audit_log()".into(), enabled: "O".into(), }; let json = serde_json::to_string(&info).unwrap(); assert!(json.contains("trg_audit")); } #[test] fn sequence_info_serialization() { let info = SequenceInfo { name: "users_id_seq".into(), schema: "public".into(), start_value: "1".into(), min_value: "1".into(), max_value: "9223372036854775807".into(), increment: "1".into(), current_value: "42".into(), cycle: false, }; let json = serde_json::to_string(&info).unwrap(); assert!(json.contains("users_id_seq")); } #[test] fn enum_info_serialization() { let info = EnumInfo { name: "user_role".into(), schema: "public".into(), labels: vec!["admin".into(), "editor".into(), "viewer".into()], }; let json = serde_json::to_string(&info).unwrap(); assert!(json.contains("admin")); } #[test] fn extension_info_serialization() { let info = ExtensionInfo { name: "pg_stat_statements".into(), schema: "public".into(), version: "1.10".into(), comment: Some("track SQL statistics".into()), }; 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"); } #[test] fn object_search_hit_tagged_roundtrip() { let hit = ObjectSearchHit { name: "users".into(), schema: "public".into(), object_type: "TABLE".into() }; let json = serde_json::to_string(&hit).unwrap(); assert!(json.contains("\"object_type\":\"TABLE\"")); let back: ObjectSearchHit = serde_json::from_str(&json).unwrap(); assert_eq!(back.name, "users"); } #[test] fn dependency_info_roundtrip() { let d = DependencyInfo { deptype: "n".into(), class: "pg_class".into(), name: "v_users".into() }; let json = serde_json::to_string(&d).unwrap(); assert!(json.contains("\"deptype\":\"n\"")); assert!(json.contains("v_users")); } #[test] fn change_rebuild_table_serializes_with_snake_case_tag() { let change = Change::RebuildTable { id: "chg-rb1".to_string(), sql: "CREATE TABLE _t ...".to_string(), }; let json = serde_json::to_string(&change).unwrap(); assert!( json.contains(r#""type":"rebuild_table""#), "RebuildTable should serialize with tag 'rebuild_table'; got: {json}" ); assert_eq!(change.id(), "chg-rb1"); } #[test] fn change_rebuild_table_roundtrip() { let json = serde_json::json!({ "type": "rebuild_table", "id": "rb", "sql": "SELECT 1" }); let c: Change = serde_json::from_value(json).unwrap(); match c { Change::RebuildTable { sql, .. } => assert_eq!(sql, "SELECT 1"), _ => panic!("expected RebuildTable"), } } #[test] fn role_info_roundtrip() { let r = RoleInfo { name: "app".into(), superuser: false, inherit: true, create_db: false, create_role: false, can_login: true, replication: false, bypass_rls: false, connection_limit: -1, valid_until: None, memberships: vec![RoleMembership { role: "parent".into(), member: "app".into(), grantor: "admin".into(), admin_option: false, }], }; let json = serde_json::to_string(&r).unwrap(); assert!(json.contains(r#""name":"app""#)); assert!(json.contains(r#""can_login":true"#)); let back: RoleInfo = serde_json::from_str(&json).unwrap(); assert_eq!(back.memberships.len(), 1); } #[test] fn privilege_entry_and_readiness_roundtrip() { let pe = PrivilegeEntry { object_class: "table".into(), schema: Some("public".into()), name: "users".into(), privileges: vec!["SELECT".into(), "INSERT".into()], grantable: false, }; assert!(serde_json::to_string(&pe).unwrap().contains(r#""object_class":"table""#)); let rr = RebuildReadiness { ok: false, reasons: vec!["has triggers".into()] }; assert!(serde_json::to_string(&rr).unwrap().contains(r#""ok":false"#)); let mr = MaintenanceResult { duration_ms: 42, message: "ok".into() }; assert!(serde_json::to_string(&mr).unwrap().contains(r#""duration_ms":42"#)); let ts = TablespaceInfo { name: "pg_default".into() }; assert!(serde_json::to_string(&ts).unwrap().contains("pg_default")); } }