Skip to main content

oxirs_tdb/
database_ops.rs

1//! Database operations and management utilities
2//!
3//! Provides comprehensive database management operations inspired by Apache Jena's DatabaseOps.
4//! Includes database lifecycle management, maintenance operations, and administrative tasks.
5
6use crate::error::{Result, TdbError};
7use crate::store::{StoreParams, TdbStore};
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::time::{Duration, SystemTime};
11
12/// Database metadata
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DatabaseMetadata {
15    /// Database name
16    pub name: String,
17    /// Database location
18    pub location: PathBuf,
19    /// Creation timestamp
20    pub created_at: SystemTime,
21    /// Last modified timestamp
22    pub modified_at: SystemTime,
23    /// Database version
24    pub version: String,
25    /// Database size in bytes
26    pub size_bytes: u64,
27    /// Number of triples
28    pub triple_count: u64,
29    /// Database status
30    pub status: DatabaseStatus,
31}
32
33/// Database status
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub enum DatabaseStatus {
36    /// Database is active and available
37    Active,
38    /// Database is being created
39    Creating,
40    /// Database is being compacted
41    Compacting,
42    /// Database is being backed up
43    BackingUp,
44    /// Database is being repaired
45    Repairing,
46    /// Database is offline
47    Offline,
48    /// Database has errors
49    Error,
50}
51
52/// Database operations manager
53pub struct DatabaseOps {
54    /// Base directory for all databases
55    base_dir: PathBuf,
56}
57
58impl DatabaseOps {
59    /// Create new database operations manager
60    pub fn new<P: AsRef<Path>>(base_dir: P) -> Result<Self> {
61        let base_dir = base_dir.as_ref().to_path_buf();
62
63        // Create base directory if it doesn't exist
64        if !base_dir.exists() {
65            std::fs::create_dir_all(&base_dir)?;
66        }
67
68        Ok(Self { base_dir })
69    }
70
71    /// Create a new database with the given parameters
72    pub fn create_database(&self, name: &str, params: StoreParams) -> Result<DatabaseMetadata> {
73        let db_path = self.base_dir.join(name);
74
75        // Check if database already exists
76        if db_path.exists() {
77            return Err(TdbError::InvalidInput(format!(
78                "Database '{}' already exists",
79                name
80            )));
81        }
82
83        // Validate database name
84        self.validate_database_name(name)?;
85
86        // Validate the caller-supplied parameters before persisting them.
87        params.validate()?;
88
89        // Create database directory
90        std::fs::create_dir_all(&db_path)?;
91
92        // Save store parameters so a later reopen (compact/repair, or any
93        // `open_database`) can restore exactly these settings.
94        let params_file = db_path.join("store_params.json");
95        params.save_to_file(&params_file)?;
96
97        // Initialize the database with the supplied parameters (not defaults),
98        // so the created store actually honors them.
99        let _store = TdbStore::open_with_params(&db_path, params)?;
100
101        // Create metadata
102        let metadata = DatabaseMetadata {
103            name: name.to_string(),
104            location: db_path.clone(),
105            created_at: SystemTime::now(),
106            modified_at: SystemTime::now(),
107            version: crate::VERSION.to_string(),
108            size_bytes: self.calculate_database_size(&db_path)?,
109            triple_count: 0,
110            status: DatabaseStatus::Active,
111        };
112
113        // Save metadata
114        self.save_metadata(&metadata)?;
115
116        log::info!("Created database '{}' at {:?}", name, db_path);
117
118        Ok(metadata)
119    }
120
121    /// Open an existing database's store, restoring its persisted
122    /// [`StoreParams`] when present.
123    ///
124    /// A reopened store must keep the settings it was created with, so if a
125    /// `store_params.json` is present it is loaded (and validated) and the store
126    /// is opened with [`TdbStore::open_with_params`]; otherwise the store is
127    /// opened with engine defaults. A malformed params file fails loudly rather
128    /// than being silently ignored.
129    fn open_store(&self, db_path: &Path) -> Result<TdbStore> {
130        let params_file = db_path.join("store_params.json");
131        if params_file.exists() {
132            let params = StoreParams::load_from_file(&params_file)?;
133            TdbStore::open_with_params(db_path, params)
134        } else {
135            TdbStore::open(db_path)
136        }
137    }
138
139    /// Delete a database
140    pub fn delete_database(&self, name: &str) -> Result<()> {
141        let db_path = self.base_dir.join(name);
142
143        if !db_path.exists() {
144            return Err(TdbError::InvalidInput(format!(
145                "Database '{}' does not exist",
146                name
147            )));
148        }
149
150        // Delete database directory and all contents
151        std::fs::remove_dir_all(&db_path)?;
152
153        log::info!("Deleted database '{}'", name);
154
155        Ok(())
156    }
157
158    /// List all databases
159    pub fn list_databases(&self) -> Result<Vec<DatabaseMetadata>> {
160        let mut databases = Vec::new();
161
162        if !self.base_dir.exists() {
163            return Ok(databases);
164        }
165
166        for entry in std::fs::read_dir(&self.base_dir)? {
167            let entry = entry?;
168            let path = entry.path();
169
170            if path.is_dir() {
171                // Try to load metadata
172                if let Ok(metadata) = self.load_metadata(&path) {
173                    databases.push(metadata);
174                }
175            }
176        }
177
178        Ok(databases)
179    }
180
181    /// Get metadata for a specific database
182    pub fn get_metadata(&self, name: &str) -> Result<DatabaseMetadata> {
183        let db_path = self.base_dir.join(name);
184
185        if !db_path.exists() {
186            return Err(TdbError::InvalidInput(format!(
187                "Database '{}' does not exist",
188                name
189            )));
190        }
191
192        self.load_metadata(&db_path)
193    }
194
195    /// Compact a database
196    pub fn compact_database(&self, name: &str) -> Result<CompactionStats> {
197        let db_path = self.base_dir.join(name);
198
199        if !db_path.exists() {
200            return Err(TdbError::InvalidInput(format!(
201                "Database '{}' does not exist",
202                name
203            )));
204        }
205
206        // Update status
207        let mut metadata = self.load_metadata(&db_path)?;
208        metadata.status = DatabaseStatus::Compacting;
209        self.save_metadata(&metadata)?;
210
211        let start_time = SystemTime::now();
212        let size_before = self.calculate_database_size(&db_path)?;
213
214        // Open database (restoring its persisted params) and compact.
215        let mut store = self.open_store(&db_path)?;
216        store.compact()?;
217
218        let size_after = self.calculate_database_size(&db_path)?;
219        let duration = start_time.elapsed().unwrap_or(Duration::from_secs(0));
220
221        // Update metadata
222        metadata.status = DatabaseStatus::Active;
223        metadata.modified_at = SystemTime::now();
224        metadata.size_bytes = size_after;
225        self.save_metadata(&metadata)?;
226
227        let stats = CompactionStats {
228            size_before,
229            size_after,
230            space_saved: size_before.saturating_sub(size_after),
231            duration_secs: duration.as_secs_f64(),
232            compression_ratio: if size_before > 0 {
233                size_after as f64 / size_before as f64
234            } else {
235                1.0
236            },
237        };
238
239        log::info!(
240            "Compacted database '{}': saved {} bytes ({:.1}% reduction)",
241            name,
242            stats.space_saved,
243            (1.0 - stats.compression_ratio) * 100.0
244        );
245
246        Ok(stats)
247    }
248
249    /// Repair a database (check and fix corruption)
250    pub fn repair_database(&self, name: &str) -> Result<RepairReport> {
251        let db_path = self.base_dir.join(name);
252
253        if !db_path.exists() {
254            return Err(TdbError::InvalidInput(format!(
255                "Database '{}' does not exist",
256                name
257            )));
258        }
259
260        // Update status
261        let mut metadata = self.load_metadata(&db_path)?;
262        metadata.status = DatabaseStatus::Repairing;
263        self.save_metadata(&metadata)?;
264
265        let start_time = SystemTime::now();
266
267        // Open database (restoring its persisted params) and run diagnostics.
268        let store = self.open_store(&db_path)?;
269        let diagnostic_report = store.run_diagnostics(crate::diagnostics::DiagnosticLevel::Deep);
270
271        // Count issues from diagnostic report
272        let issues_found =
273            diagnostic_report.summary.error_count + diagnostic_report.summary.critical_count;
274        let issues_fixed = 0; // Currently no automatic repair implemented
275
276        let duration = start_time.elapsed().unwrap_or(Duration::from_secs(0));
277
278        // Update metadata
279        metadata.status = if issues_found == issues_fixed {
280            DatabaseStatus::Active
281        } else {
282            DatabaseStatus::Error
283        };
284        metadata.modified_at = SystemTime::now();
285        self.save_metadata(&metadata)?;
286
287        let report = RepairReport {
288            issues_found,
289            issues_fixed,
290            duration_secs: duration.as_secs_f64(),
291            success: issues_found == issues_fixed,
292        };
293
294        log::info!(
295            "Repaired database '{}': {} issues found, {} fixed",
296            name,
297            report.issues_found,
298            report.issues_fixed
299        );
300
301        Ok(report)
302    }
303
304    /// Copy a database
305    pub fn copy_database(&self, source: &str, destination: &str) -> Result<()> {
306        let source_path = self.base_dir.join(source);
307        let dest_path = self.base_dir.join(destination);
308
309        if !source_path.exists() {
310            return Err(TdbError::InvalidInput(format!(
311                "Source database '{}' does not exist",
312                source
313            )));
314        }
315
316        if dest_path.exists() {
317            return Err(TdbError::InvalidInput(format!(
318                "Destination database '{}' already exists",
319                destination
320            )));
321        }
322
323        // Validate destination name
324        self.validate_database_name(destination)?;
325
326        // Copy directory recursively
327        self.copy_dir_recursive(&source_path, &dest_path)?;
328
329        // Update metadata for destination
330        if let Ok(mut metadata) = self.load_metadata(&dest_path) {
331            metadata.name = destination.to_string();
332            metadata.location = dest_path.clone();
333            metadata.created_at = SystemTime::now();
334            self.save_metadata(&metadata)?;
335        }
336
337        log::info!("Copied database '{}' to '{}'", source, destination);
338
339        Ok(())
340    }
341
342    /// Get database size in bytes
343    pub fn get_database_size(&self, name: &str) -> Result<u64> {
344        let db_path = self.base_dir.join(name);
345
346        if !db_path.exists() {
347            return Err(TdbError::InvalidInput(format!(
348                "Database '{}' does not exist",
349                name
350            )));
351        }
352
353        self.calculate_database_size(&db_path)
354    }
355
356    /// Validate database name
357    fn validate_database_name(&self, name: &str) -> Result<()> {
358        if name.is_empty() {
359            return Err(TdbError::InvalidInput(
360                "Database name cannot be empty".to_string(),
361            ));
362        }
363
364        // Check for invalid characters
365        if name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
366            return Err(TdbError::InvalidInput(format!(
367                "Database name '{}' contains invalid characters",
368                name
369            )));
370        }
371
372        Ok(())
373    }
374
375    /// Calculate total size of database directory
376    #[allow(clippy::only_used_in_recursion)]
377    fn calculate_database_size(&self, path: &Path) -> Result<u64> {
378        let mut total_size = 0u64;
379
380        if path.is_dir() {
381            for entry in std::fs::read_dir(path)? {
382                let entry = entry?;
383                let entry_path = entry.path();
384
385                if entry_path.is_dir() {
386                    total_size += self.calculate_database_size(&entry_path)?;
387                } else if entry_path.is_file() {
388                    total_size += entry.metadata()?.len();
389                }
390            }
391        }
392
393        Ok(total_size)
394    }
395
396    /// Save database metadata
397    fn save_metadata(&self, metadata: &DatabaseMetadata) -> Result<()> {
398        let metadata_file = metadata.location.join("metadata.json");
399        let json = serde_json::to_string_pretty(metadata)
400            .map_err(|e| TdbError::Serialization(format!("Failed to serialize metadata: {}", e)))?;
401        std::fs::write(metadata_file, json)?;
402        Ok(())
403    }
404
405    /// Load database metadata
406    fn load_metadata(&self, db_path: &Path) -> Result<DatabaseMetadata> {
407        let metadata_file = db_path.join("metadata.json");
408
409        if !metadata_file.exists() {
410            // Create default metadata if file doesn't exist
411            let metadata = DatabaseMetadata {
412                name: db_path
413                    .file_name()
414                    .and_then(|n| n.to_str())
415                    .unwrap_or("unknown")
416                    .to_string(),
417                location: db_path.to_path_buf(),
418                created_at: SystemTime::now(),
419                modified_at: SystemTime::now(),
420                version: crate::VERSION.to_string(),
421                size_bytes: self.calculate_database_size(db_path)?,
422                triple_count: 0,
423                status: DatabaseStatus::Active,
424            };
425            self.save_metadata(&metadata)?;
426            return Ok(metadata);
427        }
428
429        let json = std::fs::read_to_string(metadata_file)?;
430        let metadata: DatabaseMetadata = serde_json::from_str(&json)
431            .map_err(|e| TdbError::Deserialization(format!("Failed to parse metadata: {}", e)))?;
432        Ok(metadata)
433    }
434
435    /// Copy directory recursively
436    #[allow(clippy::only_used_in_recursion)]
437    fn copy_dir_recursive(&self, src: &Path, dst: &Path) -> Result<()> {
438        std::fs::create_dir_all(dst)?;
439
440        for entry in std::fs::read_dir(src)? {
441            let entry = entry?;
442            let src_path = entry.path();
443            let dst_path = dst.join(entry.file_name());
444
445            if src_path.is_dir() {
446                self.copy_dir_recursive(&src_path, &dst_path)?;
447            } else {
448                std::fs::copy(&src_path, &dst_path)?;
449            }
450        }
451
452        Ok(())
453    }
454}
455
456/// Statistics from database compaction
457#[derive(Debug, Clone)]
458pub struct CompactionStats {
459    /// Database size before compaction
460    pub size_before: u64,
461    /// Database size after compaction
462    pub size_after: u64,
463    /// Space saved in bytes
464    pub space_saved: u64,
465    /// Duration in seconds
466    pub duration_secs: f64,
467    /// Compression ratio (after/before)
468    pub compression_ratio: f64,
469}
470
471impl CompactionStats {
472    /// Get space savings percentage
473    pub fn savings_percentage(&self) -> f64 {
474        if self.size_before > 0 {
475            (self.space_saved as f64 / self.size_before as f64) * 100.0
476        } else {
477            0.0
478        }
479    }
480}
481
482/// Report from database repair operation
483#[derive(Debug, Clone)]
484pub struct RepairReport {
485    /// Number of issues found
486    pub issues_found: usize,
487    /// Number of issues fixed
488    pub issues_fixed: usize,
489    /// Duration in seconds
490    pub duration_secs: f64,
491    /// Whether repair was successful
492    pub success: bool,
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::store::{StoreParamsBuilder, StorePresets};
499    use std::env;
500
501    fn create_test_base_dir() -> PathBuf {
502        env::temp_dir().join(format!("oxirs_dbops_test_{}", uuid::Uuid::new_v4()))
503    }
504
505    #[test]
506    fn test_create_database() {
507        let base_dir = create_test_base_dir();
508        let ops = DatabaseOps::new(&base_dir).unwrap();
509
510        let params = StorePresets::minimal(base_dir.join("test_db"))
511            .build()
512            .unwrap();
513
514        let metadata = ops.create_database("test_db", params).unwrap();
515
516        assert_eq!(metadata.name, "test_db");
517        assert_eq!(metadata.status, DatabaseStatus::Active);
518    }
519
520    #[test]
521    fn test_list_databases() {
522        let base_dir = create_test_base_dir();
523        let ops = DatabaseOps::new(&base_dir).unwrap();
524
525        let params1 = StorePresets::minimal(base_dir.join("db1")).build().unwrap();
526        let params2 = StorePresets::minimal(base_dir.join("db2")).build().unwrap();
527
528        ops.create_database("db1", params1).unwrap();
529        ops.create_database("db2", params2).unwrap();
530
531        let databases = ops.list_databases().unwrap();
532        assert_eq!(databases.len(), 2);
533    }
534
535    #[test]
536    fn test_delete_database() {
537        let base_dir = create_test_base_dir();
538        let ops = DatabaseOps::new(&base_dir).unwrap();
539
540        let params = StorePresets::minimal(base_dir.join("test_db"))
541            .build()
542            .unwrap();
543
544        ops.create_database("test_db", params).unwrap();
545        assert!(ops.get_metadata("test_db").is_ok());
546
547        ops.delete_database("test_db").unwrap();
548        assert!(ops.get_metadata("test_db").is_err());
549    }
550
551    #[test]
552    fn test_get_database_size() {
553        let base_dir = create_test_base_dir();
554        let ops = DatabaseOps::new(&base_dir).unwrap();
555
556        let params = StorePresets::minimal(base_dir.join("test_db"))
557            .build()
558            .unwrap();
559
560        ops.create_database("test_db", params).unwrap();
561
562        let size = ops.get_database_size("test_db").unwrap();
563        assert!(size > 0);
564    }
565
566    #[test]
567    fn test_copy_database() {
568        let base_dir = create_test_base_dir();
569        let ops = DatabaseOps::new(&base_dir).unwrap();
570
571        let params = StorePresets::minimal(base_dir.join("source_db"))
572            .build()
573            .unwrap();
574
575        ops.create_database("source_db", params).unwrap();
576        ops.copy_database("source_db", "dest_db").unwrap();
577
578        assert!(ops.get_metadata("source_db").is_ok());
579        assert!(ops.get_metadata("dest_db").is_ok());
580    }
581
582    #[test]
583    fn test_validate_database_name() {
584        let base_dir = create_test_base_dir();
585        let ops = DatabaseOps::new(&base_dir).unwrap();
586
587        assert!(ops.validate_database_name("valid_name").is_ok());
588        assert!(ops.validate_database_name("").is_err());
589        assert!(ops.validate_database_name("invalid/name").is_err());
590        assert!(ops.validate_database_name("invalid:name").is_err());
591    }
592
593    #[test]
594    fn test_compaction_stats() {
595        let stats = CompactionStats {
596            size_before: 1000,
597            size_after: 600,
598            space_saved: 400,
599            duration_secs: 1.5,
600            compression_ratio: 0.6,
601        };
602
603        assert_eq!(stats.savings_percentage(), 40.0);
604    }
605
606    #[test]
607    fn test_reopen_preserves_store_params() {
608        let base_dir = create_test_base_dir();
609        let ops = DatabaseOps::new(&base_dir).unwrap();
610
611        // Create a database with a distinctly non-default buffer pool size.
612        let params = StoreParamsBuilder::new(base_dir.join("pdb"))
613            .buffer_pool_size(3333)
614            .build()
615            .unwrap();
616        ops.create_database("pdb", params).unwrap();
617
618        // Reopening the database restores the persisted params (the buffer pool
619        // size reaches the BufferPool), not engine defaults.
620        let db_path = base_dir.join("pdb");
621        let store = ops.open_store(&db_path).unwrap();
622        assert_eq!(store.buffer_pool.pool_size(), 3333);
623
624        drop(store);
625        std::fs::remove_dir_all(&base_dir).ok();
626    }
627
628    #[test]
629    fn test_repair_report() {
630        let report = RepairReport {
631            issues_found: 5,
632            issues_fixed: 5,
633            duration_secs: 2.0,
634            success: true,
635        };
636
637        assert!(report.success);
638        assert_eq!(report.issues_found, 5);
639    }
640}