Skip to main content

grafeo_engine/
admin.rs

1//! Admin API types for database inspection, backup, and maintenance.
2//!
3//! These types support both LPG (Labeled Property Graph) and RDF (Resource Description Framework)
4//! data models.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11/// Database mode - either LPG (Labeled Property Graph) or RDF (Triple Store).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14#[non_exhaustive]
15pub enum DatabaseMode {
16    /// Labeled Property Graph mode (nodes with labels and properties, typed edges).
17    Lpg,
18    /// RDF mode (subject-predicate-object triples).
19    Rdf,
20}
21
22impl std::fmt::Display for DatabaseMode {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            DatabaseMode::Lpg => write!(f, "lpg"),
26            DatabaseMode::Rdf => write!(f, "rdf"),
27        }
28    }
29}
30
31/// High-level database information returned by `db.info()`.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DatabaseInfo {
34    /// Database mode (LPG or RDF).
35    pub mode: DatabaseMode,
36    /// Number of nodes (LPG) or subjects (RDF).
37    pub node_count: usize,
38    /// Number of edges (LPG) or triples (RDF).
39    pub edge_count: usize,
40    /// Whether the database is backed by a file.
41    pub is_persistent: bool,
42    /// Database file path, if persistent.
43    pub path: Option<PathBuf>,
44    /// Whether WAL is enabled.
45    pub wal_enabled: bool,
46    /// Database version.
47    pub version: String,
48    /// Compiled feature flags (e.g. "gql", "cypher", "algos", "vector-index").
49    pub features: Vec<String>,
50}
51
52/// Detailed database statistics returned by `db.stats()`.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct DatabaseStats {
55    /// Number of nodes (LPG) or subjects (RDF).
56    pub node_count: usize,
57    /// Number of edges (LPG) or triples (RDF).
58    pub edge_count: usize,
59    /// Number of distinct labels (LPG) or classes (RDF).
60    pub label_count: usize,
61    /// Number of distinct edge types (LPG) or predicates (RDF).
62    pub edge_type_count: usize,
63    /// Number of distinct property keys.
64    pub property_key_count: usize,
65    /// Number of indexes.
66    pub index_count: usize,
67    /// Memory usage in bytes (approximate).
68    pub memory_bytes: usize,
69    /// Disk usage in bytes (if persistent).
70    pub disk_bytes: Option<usize>,
71}
72
73/// Schema information for LPG databases.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct LpgSchemaInfo {
76    /// All labels used in the database.
77    pub labels: Vec<LabelInfo>,
78    /// All edge types used in the database.
79    pub edge_types: Vec<EdgeTypeInfo>,
80    /// All property keys used in the database.
81    pub property_keys: Vec<String>,
82}
83
84/// Information about a label.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct LabelInfo {
87    /// The label name.
88    pub name: String,
89    /// Number of nodes with this label.
90    pub count: usize,
91}
92
93/// Information about an edge type.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct EdgeTypeInfo {
96    /// The edge type name.
97    pub name: String,
98    /// Number of edges with this type.
99    pub count: usize,
100}
101
102/// Schema information for RDF databases.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct RdfSchemaInfo {
105    /// All predicates used in the database.
106    pub predicates: Vec<PredicateInfo>,
107    /// All named graphs.
108    pub named_graphs: Vec<String>,
109    /// Number of distinct subjects.
110    pub subject_count: usize,
111    /// Number of distinct objects.
112    pub object_count: usize,
113}
114
115/// Information about an RDF predicate.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct PredicateInfo {
118    /// The predicate IRI.
119    pub iri: String,
120    /// Number of triples using this predicate.
121    pub count: usize,
122}
123
124/// Combined schema information supporting both LPG and RDF.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(tag = "mode")]
127#[non_exhaustive]
128pub enum SchemaInfo {
129    /// LPG schema information.
130    #[serde(rename = "lpg")]
131    Lpg(LpgSchemaInfo),
132    /// RDF schema information.
133    #[serde(rename = "rdf")]
134    Rdf(RdfSchemaInfo),
135}
136
137/// Index information.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct IndexInfo {
140    /// Index name.
141    pub name: String,
142    /// Index type (hash, btree, fulltext, etc.).
143    pub index_type: String,
144    /// Target (label:property for LPG, predicate for RDF).
145    pub target: String,
146    /// Whether the index is unique.
147    pub unique: bool,
148    /// Estimated cardinality.
149    pub cardinality: Option<usize>,
150    /// Size in bytes.
151    pub size_bytes: Option<usize>,
152}
153
154/// WAL (Write-Ahead Log) status.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct WalStatus {
157    /// Whether WAL is enabled.
158    pub enabled: bool,
159    /// WAL file path.
160    pub path: Option<PathBuf>,
161    /// WAL size in bytes.
162    pub size_bytes: usize,
163    /// Number of WAL records.
164    pub record_count: usize,
165    /// Last checkpoint timestamp (Unix epoch seconds).
166    pub last_checkpoint: Option<u64>,
167    /// Current epoch/LSN.
168    pub current_epoch: u64,
169}
170
171/// Validation result.
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173pub struct ValidationResult {
174    /// List of validation errors (empty = valid).
175    pub errors: Vec<ValidationError>,
176    /// List of validation warnings.
177    pub warnings: Vec<ValidationWarning>,
178}
179
180impl ValidationResult {
181    /// Returns true if validation passed (no errors).
182    #[must_use]
183    pub fn is_valid(&self) -> bool {
184        self.errors.is_empty()
185    }
186}
187
188/// A validation error.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ValidationError {
191    /// Error code.
192    pub code: String,
193    /// Human-readable error message.
194    pub message: String,
195    /// Optional context (e.g., affected entity ID).
196    pub context: Option<String>,
197}
198
199/// A validation warning.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct ValidationWarning {
202    /// Warning code.
203    pub code: String,
204    /// Human-readable warning message.
205    pub message: String,
206    /// Optional context.
207    pub context: Option<String>,
208}
209
210/// Dump format for export operations.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "lowercase")]
213#[non_exhaustive]
214pub enum DumpFormat {
215    /// Apache Parquet format (default for LPG).
216    Parquet,
217    /// RDF Turtle format (default for RDF).
218    Turtle,
219    /// JSON Lines format.
220    Json,
221    /// Arrow IPC stream format (zero-copy interop with DuckDB, Polars, pandas).
222    Arrow,
223    /// GEXF 1.3 format (Gephi, Gephi Lite, NetworkX).
224    Gexf,
225    /// GraphML format (Gephi, Cytoscape, yEd, igraph).
226    GraphMl,
227}
228
229impl Default for DumpFormat {
230    fn default() -> Self {
231        DumpFormat::Parquet
232    }
233}
234
235impl std::fmt::Display for DumpFormat {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        match self {
238            DumpFormat::Parquet => write!(f, "parquet"),
239            DumpFormat::Turtle => write!(f, "turtle"),
240            DumpFormat::Json => write!(f, "json"),
241            DumpFormat::Arrow => write!(f, "arrow"),
242            DumpFormat::Gexf => write!(f, "gexf"),
243            DumpFormat::GraphMl => write!(f, "graphml"),
244        }
245    }
246}
247
248impl std::str::FromStr for DumpFormat {
249    type Err = String;
250
251    fn from_str(s: &str) -> Result<Self, Self::Err> {
252        match s.to_lowercase().as_str() {
253            "parquet" => Ok(DumpFormat::Parquet),
254            "turtle" | "ttl" => Ok(DumpFormat::Turtle),
255            "json" | "jsonl" => Ok(DumpFormat::Json),
256            "arrow" | "arrow-ipc" | "ipc" => Ok(DumpFormat::Arrow),
257            "gexf" => Ok(DumpFormat::Gexf),
258            "graphml" => Ok(DumpFormat::GraphMl),
259            _ => Err(format!("Unknown dump format: {}", s)),
260        }
261    }
262}
263
264/// Compaction statistics returned after a compact operation.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct CompactionStats {
267    /// Bytes reclaimed.
268    pub bytes_reclaimed: usize,
269    /// Number of nodes compacted.
270    pub nodes_compacted: usize,
271    /// Number of edges compacted.
272    pub edges_compacted: usize,
273    /// Duration in milliseconds.
274    pub duration_ms: u64,
275}
276
277/// Metadata for dump files.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct DumpMetadata {
280    /// Grafeo version that created the dump.
281    pub version: String,
282    /// Database mode.
283    pub mode: DatabaseMode,
284    /// Dump format.
285    pub format: DumpFormat,
286    /// Number of nodes.
287    pub node_count: usize,
288    /// Number of edges.
289    pub edge_count: usize,
290    /// Timestamp (ISO 8601).
291    pub created_at: String,
292    /// Additional metadata.
293    #[serde(default)]
294    pub extra: HashMap<String, String>,
295}
296
297/// Trait for administrative database operations.
298///
299/// Provides a uniform interface for introspection, validation, and
300/// maintenance operations. Used by the CLI, REST API, and bindings
301/// to inspect and manage a Grafeo database.
302///
303/// Implemented by [`GrafeoDB`](crate::GrafeoDB).
304pub trait AdminService {
305    /// Returns high-level database information (counts, mode, persistence).
306    fn info(&self) -> DatabaseInfo;
307
308    /// Returns detailed database statistics (memory, disk, indexes).
309    fn detailed_stats(&self) -> DatabaseStats;
310
311    /// Returns schema information (labels, edge types, property keys).
312    fn schema(&self) -> SchemaInfo;
313
314    /// Validates database integrity, returning errors and warnings.
315    fn validate(&self) -> ValidationResult;
316
317    /// Returns WAL (Write-Ahead Log) status.
318    fn wal_status(&self) -> WalStatus;
319
320    /// Forces a WAL checkpoint, flushing pending records to storage.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if the checkpoint fails.
325    fn wal_checkpoint(&self) -> grafeo_common::utils::error::Result<()>;
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    // ---- DatabaseMode ----
333
334    #[test]
335    fn test_database_mode_display() {
336        assert_eq!(DatabaseMode::Lpg.to_string(), "lpg");
337        assert_eq!(DatabaseMode::Rdf.to_string(), "rdf");
338    }
339
340    #[test]
341    fn test_database_mode_serde_roundtrip() {
342        let json = serde_json::to_string(&DatabaseMode::Lpg).unwrap();
343        let mode: DatabaseMode = serde_json::from_str(&json).unwrap();
344        assert_eq!(mode, DatabaseMode::Lpg);
345
346        let json = serde_json::to_string(&DatabaseMode::Rdf).unwrap();
347        let mode: DatabaseMode = serde_json::from_str(&json).unwrap();
348        assert_eq!(mode, DatabaseMode::Rdf);
349    }
350
351    #[test]
352    fn test_database_mode_equality() {
353        assert_eq!(DatabaseMode::Lpg, DatabaseMode::Lpg);
354        assert_ne!(DatabaseMode::Lpg, DatabaseMode::Rdf);
355    }
356
357    // ---- DumpFormat ----
358
359    #[test]
360    fn test_dump_format_default() {
361        assert_eq!(DumpFormat::default(), DumpFormat::Parquet);
362    }
363
364    #[test]
365    fn test_dump_format_display() {
366        assert_eq!(DumpFormat::Parquet.to_string(), "parquet");
367        assert_eq!(DumpFormat::Turtle.to_string(), "turtle");
368        assert_eq!(DumpFormat::Json.to_string(), "json");
369        assert_eq!(DumpFormat::Arrow.to_string(), "arrow");
370        assert_eq!(DumpFormat::Gexf.to_string(), "gexf");
371        assert_eq!(DumpFormat::GraphMl.to_string(), "graphml");
372    }
373
374    #[test]
375    fn test_dump_format_from_str() {
376        assert_eq!(
377            "parquet".parse::<DumpFormat>().unwrap(),
378            DumpFormat::Parquet
379        );
380        assert_eq!("turtle".parse::<DumpFormat>().unwrap(), DumpFormat::Turtle);
381        assert_eq!("ttl".parse::<DumpFormat>().unwrap(), DumpFormat::Turtle);
382        assert_eq!("json".parse::<DumpFormat>().unwrap(), DumpFormat::Json);
383        assert_eq!("jsonl".parse::<DumpFormat>().unwrap(), DumpFormat::Json);
384        assert_eq!("arrow".parse::<DumpFormat>().unwrap(), DumpFormat::Arrow);
385        assert_eq!(
386            "arrow-ipc".parse::<DumpFormat>().unwrap(),
387            DumpFormat::Arrow
388        );
389        assert_eq!("ipc".parse::<DumpFormat>().unwrap(), DumpFormat::Arrow);
390        assert_eq!("gexf".parse::<DumpFormat>().unwrap(), DumpFormat::Gexf);
391        assert_eq!(
392            "graphml".parse::<DumpFormat>().unwrap(),
393            DumpFormat::GraphMl
394        );
395        assert_eq!(
396            "PARQUET".parse::<DumpFormat>().unwrap(),
397            DumpFormat::Parquet
398        );
399    }
400
401    #[test]
402    fn test_dump_format_from_str_invalid() {
403        let result = "xml".parse::<DumpFormat>();
404        assert!(result.is_err());
405        assert!(result.unwrap_err().contains("Unknown dump format"));
406    }
407
408    #[test]
409    fn test_dump_format_serde_roundtrip() {
410        for format in [
411            DumpFormat::Parquet,
412            DumpFormat::Turtle,
413            DumpFormat::Json,
414            DumpFormat::Arrow,
415            DumpFormat::Gexf,
416            DumpFormat::GraphMl,
417        ] {
418            let json = serde_json::to_string(&format).unwrap();
419            let parsed: DumpFormat = serde_json::from_str(&json).unwrap();
420            assert_eq!(parsed, format);
421        }
422    }
423
424    // ---- ValidationResult ----
425
426    #[test]
427    fn test_validation_result_default_is_valid() {
428        let result = ValidationResult::default();
429        assert!(result.is_valid());
430        assert!(result.errors.is_empty());
431        assert!(result.warnings.is_empty());
432    }
433
434    #[test]
435    fn test_validation_result_with_errors() {
436        let result = ValidationResult {
437            errors: vec![ValidationError {
438                code: "E001".to_string(),
439                message: "Orphaned edge".to_string(),
440                context: Some("edge_42".to_string()),
441            }],
442            warnings: Vec::new(),
443        };
444        assert!(!result.is_valid());
445    }
446
447    #[test]
448    fn test_validation_result_with_warnings_still_valid() {
449        let result = ValidationResult {
450            errors: Vec::new(),
451            warnings: vec![ValidationWarning {
452                code: "W001".to_string(),
453                message: "Unused index".to_string(),
454                context: None,
455            }],
456        };
457        assert!(result.is_valid());
458    }
459
460    // ---- Serde roundtrips for complex types ----
461
462    #[test]
463    fn test_database_info_serde() {
464        let info = DatabaseInfo {
465            mode: DatabaseMode::Lpg,
466            node_count: 100,
467            edge_count: 200,
468            is_persistent: true,
469            path: Some(PathBuf::from("/tmp/db")),
470            wal_enabled: true,
471            version: "0.4.1".to_string(),
472            features: vec!["gql".into(), "cypher".into()],
473        };
474        let json = serde_json::to_string(&info).unwrap();
475        let parsed: DatabaseInfo = serde_json::from_str(&json).unwrap();
476        assert_eq!(parsed.node_count, 100);
477        assert_eq!(parsed.edge_count, 200);
478        assert!(parsed.is_persistent);
479    }
480
481    #[test]
482    fn test_database_stats_serde() {
483        let stats = DatabaseStats {
484            node_count: 50,
485            edge_count: 75,
486            label_count: 3,
487            edge_type_count: 2,
488            property_key_count: 10,
489            index_count: 4,
490            memory_bytes: 1024,
491            disk_bytes: Some(2048),
492        };
493        let json = serde_json::to_string(&stats).unwrap();
494        let parsed: DatabaseStats = serde_json::from_str(&json).unwrap();
495        assert_eq!(parsed.node_count, 50);
496        assert_eq!(parsed.disk_bytes, Some(2048));
497    }
498
499    #[test]
500    fn test_schema_info_lpg_serde() {
501        let schema = SchemaInfo::Lpg(LpgSchemaInfo {
502            labels: vec![LabelInfo {
503                name: "Person".to_string(),
504                count: 10,
505            }],
506            edge_types: vec![EdgeTypeInfo {
507                name: "KNOWS".to_string(),
508                count: 20,
509            }],
510            property_keys: vec!["name".to_string(), "age".to_string()],
511        });
512        let json = serde_json::to_string(&schema).unwrap();
513        let parsed: SchemaInfo = serde_json::from_str(&json).unwrap();
514        match parsed {
515            SchemaInfo::Lpg(lpg) => {
516                assert_eq!(lpg.labels.len(), 1);
517                assert_eq!(lpg.labels[0].name, "Person");
518                assert_eq!(lpg.edge_types[0].count, 20);
519            }
520            SchemaInfo::Rdf(_) => panic!("Expected LPG schema"),
521        }
522    }
523
524    #[test]
525    fn test_schema_info_rdf_serde() {
526        let schema = SchemaInfo::Rdf(RdfSchemaInfo {
527            predicates: vec![PredicateInfo {
528                iri: "http://xmlns.com/foaf/0.1/knows".to_string(),
529                count: 5,
530            }],
531            named_graphs: vec!["default".to_string()],
532            subject_count: 10,
533            object_count: 15,
534        });
535        let json = serde_json::to_string(&schema).unwrap();
536        let parsed: SchemaInfo = serde_json::from_str(&json).unwrap();
537        match parsed {
538            SchemaInfo::Rdf(rdf) => {
539                assert_eq!(rdf.predicates.len(), 1);
540                assert_eq!(rdf.subject_count, 10);
541            }
542            SchemaInfo::Lpg(_) => panic!("Expected RDF schema"),
543        }
544    }
545
546    #[test]
547    fn test_index_info_serde() {
548        let info = IndexInfo {
549            name: "idx_person_name".to_string(),
550            index_type: "btree".to_string(),
551            target: "Person:name".to_string(),
552            unique: true,
553            cardinality: Some(1000),
554            size_bytes: Some(4096),
555        };
556        let json = serde_json::to_string(&info).unwrap();
557        let parsed: IndexInfo = serde_json::from_str(&json).unwrap();
558        assert_eq!(parsed.name, "idx_person_name");
559        assert!(parsed.unique);
560    }
561
562    #[test]
563    fn test_wal_status_serde() {
564        let status = WalStatus {
565            enabled: true,
566            path: Some(PathBuf::from("/tmp/wal")),
567            size_bytes: 8192,
568            record_count: 42,
569            last_checkpoint: Some(1700000000),
570            current_epoch: 100,
571        };
572        let json = serde_json::to_string(&status).unwrap();
573        let parsed: WalStatus = serde_json::from_str(&json).unwrap();
574        assert_eq!(parsed.record_count, 42);
575        assert_eq!(parsed.current_epoch, 100);
576    }
577
578    #[test]
579    fn test_compaction_stats_serde() {
580        let stats = CompactionStats {
581            bytes_reclaimed: 1024,
582            nodes_compacted: 10,
583            edges_compacted: 20,
584            duration_ms: 150,
585        };
586        let json = serde_json::to_string(&stats).unwrap();
587        let parsed: CompactionStats = serde_json::from_str(&json).unwrap();
588        assert_eq!(parsed.bytes_reclaimed, 1024);
589        assert_eq!(parsed.duration_ms, 150);
590    }
591
592    #[test]
593    fn test_dump_metadata_serde() {
594        let metadata = DumpMetadata {
595            version: "0.4.1".to_string(),
596            mode: DatabaseMode::Lpg,
597            format: DumpFormat::Parquet,
598            node_count: 1000,
599            edge_count: 5000,
600            created_at: "2025-01-15T12:00:00Z".to_string(),
601            extra: HashMap::new(),
602        };
603        let json = serde_json::to_string(&metadata).unwrap();
604        let parsed: DumpMetadata = serde_json::from_str(&json).unwrap();
605        assert_eq!(parsed.node_count, 1000);
606        assert_eq!(parsed.format, DumpFormat::Parquet);
607    }
608
609    #[test]
610    fn test_dump_metadata_with_extra() {
611        let mut extra = HashMap::new();
612        extra.insert("compression".to_string(), "zstd".to_string());
613        let metadata = DumpMetadata {
614            version: "0.4.1".to_string(),
615            mode: DatabaseMode::Rdf,
616            format: DumpFormat::Turtle,
617            node_count: 0,
618            edge_count: 0,
619            created_at: "2025-01-15T12:00:00Z".to_string(),
620            extra,
621        };
622        let json = serde_json::to_string(&metadata).unwrap();
623        let parsed: DumpMetadata = serde_json::from_str(&json).unwrap();
624        assert_eq!(parsed.extra.get("compression").unwrap(), "zstd");
625    }
626
627    #[test]
628    fn test_validation_error_serde() {
629        let error = ValidationError {
630            code: "E001".to_string(),
631            message: "Broken reference".to_string(),
632            context: Some("node_id=42".to_string()),
633        };
634        let json = serde_json::to_string(&error).unwrap();
635        let parsed: ValidationError = serde_json::from_str(&json).unwrap();
636        assert_eq!(parsed.code, "E001");
637        assert_eq!(parsed.context, Some("node_id=42".to_string()));
638    }
639
640    #[test]
641    fn test_validation_warning_serde() {
642        let warning = ValidationWarning {
643            code: "W001".to_string(),
644            message: "High memory usage".to_string(),
645            context: None,
646        };
647        let json = serde_json::to_string(&warning).unwrap();
648        let parsed: ValidationWarning = serde_json::from_str(&json).unwrap();
649        assert_eq!(parsed.code, "W001");
650        assert!(parsed.context.is_none());
651    }
652}