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