Skip to main content

grafeo_engine/
config.rs

1//! Database configuration.
2
3use std::fmt;
4use std::path::PathBuf;
5use std::time::Duration;
6
7/// The graph data model for a database.
8///
9/// Each database uses exactly one model, chosen at creation time and immutable
10/// after that. The engine initializes only the relevant store, saving memory.
11///
12/// Schema variants (OWL, RDFS, JSON Schema) are a server-level concern - from
13/// the engine's perspective those map to either `Lpg` or `Rdf`.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
15pub enum GraphModel {
16    /// Labeled Property Graph (default). Supports GQL, Cypher, Gremlin, GraphQL.
17    #[default]
18    Lpg,
19    /// RDF triple store. Supports SPARQL.
20    Rdf,
21}
22
23impl fmt::Display for GraphModel {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::Lpg => write!(f, "LPG"),
27            Self::Rdf => write!(f, "RDF"),
28        }
29    }
30}
31
32/// Storage format for persistent databases.
33///
34/// Controls whether the database uses a single `.grafeo` file or a legacy
35/// WAL directory. The default (`Auto`) auto-detects based on the path:
36/// files ending in `.grafeo` use single-file format, directories use WAL.
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
38pub enum StorageFormat {
39    /// Auto-detect based on path: `.grafeo` extension = single file,
40    /// existing directory = WAL directory, new path without extension = WAL directory.
41    #[default]
42    Auto,
43    /// Legacy WAL directory format (directory with `wal/` subdirectory).
44    WalDirectory,
45    /// Single `.grafeo` file with a sidecar `.grafeo.wal/` directory during operation.
46    /// At rest (after checkpoint), only the `.grafeo` file exists.
47    SingleFile,
48}
49
50impl fmt::Display for StorageFormat {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::Auto => write!(f, "auto"),
54            Self::WalDirectory => write!(f, "wal-directory"),
55            Self::SingleFile => write!(f, "single-file"),
56        }
57    }
58}
59
60/// WAL durability mode controlling the trade-off between safety and speed.
61///
62/// This enum lives in config so that `Config` can always carry the desired
63/// durability regardless of whether the `wal` feature is compiled in. When
64/// WAL is enabled, the engine maps this to the adapter-level durability mode.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum DurabilityMode {
67    /// Fsync after every commit. Slowest but safest.
68    Sync,
69    /// Batch fsync periodically. Good balance of performance and durability.
70    Batch {
71        /// Maximum time between syncs in milliseconds.
72        max_delay_ms: u64,
73        /// Maximum records between syncs.
74        max_records: u64,
75    },
76    /// Adaptive sync via a background flusher thread.
77    Adaptive {
78        /// Target interval between flushes in milliseconds.
79        target_interval_ms: u64,
80    },
81    /// No sync - rely on OS buffer flushing. Fastest but may lose recent data.
82    NoSync,
83}
84
85impl Default for DurabilityMode {
86    fn default() -> Self {
87        Self::Batch {
88            max_delay_ms: 100,
89            max_records: 1000,
90        }
91    }
92}
93
94/// Errors from [`Config::validate()`].
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum ConfigError {
97    /// Memory limit must be greater than zero.
98    ZeroMemoryLimit,
99    /// Thread count must be greater than zero.
100    ZeroThreads,
101    /// WAL flush interval must be greater than zero.
102    ZeroWalFlushInterval,
103    /// RDF graph model requires the `rdf` feature flag.
104    RdfFeatureRequired,
105}
106
107impl fmt::Display for ConfigError {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            Self::ZeroMemoryLimit => write!(f, "memory_limit must be greater than zero"),
111            Self::ZeroThreads => write!(f, "threads must be greater than zero"),
112            Self::ZeroWalFlushInterval => {
113                write!(f, "wal_flush_interval_ms must be greater than zero")
114            }
115            Self::RdfFeatureRequired => {
116                write!(
117                    f,
118                    "RDF graph model requires the `rdf` feature flag to be enabled"
119                )
120            }
121        }
122    }
123}
124
125impl std::error::Error for ConfigError {}
126
127/// Database configuration.
128#[derive(Debug, Clone)]
129#[allow(clippy::struct_excessive_bools)] // Config structs naturally have many boolean flags
130pub struct Config {
131    /// Graph data model (LPG or RDF). Immutable after database creation.
132    pub graph_model: GraphModel,
133    /// Path to the database directory (None for in-memory only).
134    pub path: Option<PathBuf>,
135
136    /// Memory limit in bytes (None for unlimited).
137    pub memory_limit: Option<usize>,
138
139    /// Path for spilling data to disk under memory pressure.
140    pub spill_path: Option<PathBuf>,
141
142    /// Number of worker threads for query execution.
143    pub threads: usize,
144
145    /// Whether to enable WAL for durability.
146    pub wal_enabled: bool,
147
148    /// WAL flush interval in milliseconds.
149    pub wal_flush_interval_ms: u64,
150
151    /// Whether to maintain backward edges.
152    pub backward_edges: bool,
153
154    /// Whether to enable query logging.
155    pub query_logging: bool,
156
157    /// Adaptive execution configuration.
158    pub adaptive: AdaptiveConfig,
159
160    /// Whether to use factorized execution for multi-hop queries.
161    ///
162    /// When enabled, consecutive MATCH expansions are executed using factorized
163    /// representation which avoids Cartesian product materialization. This provides
164    /// 5-100x speedup for multi-hop queries with high fan-out.
165    ///
166    /// Enabled by default.
167    pub factorized_execution: bool,
168
169    /// WAL durability mode. Only used when `wal_enabled` is true.
170    pub wal_durability: DurabilityMode,
171
172    /// Storage format for persistent databases.
173    ///
174    /// `Auto` (default) detects the format from the path: `.grafeo` extension
175    /// uses single-file format, directories use the legacy WAL directory.
176    pub storage_format: StorageFormat,
177
178    /// Whether to enable catalog schema constraint enforcement.
179    ///
180    /// When true, the catalog enforces label, edge type, and property constraints
181    /// (e.g. required properties, uniqueness). The server sets this for JSON
182    /// Schema databases and populates constraints after creation.
183    pub schema_constraints: bool,
184
185    /// Maximum time a single query may run before being cancelled.
186    ///
187    /// When set, the executor checks the deadline between operator batches and
188    /// returns `QueryError::timeout()` if the wall-clock limit is exceeded.
189    /// `None` means no timeout (queries may run indefinitely).
190    pub query_timeout: Option<Duration>,
191
192    /// Run MVCC version garbage collection every N commits.
193    ///
194    /// Old versions that are no longer visible to any active transaction are
195    /// pruned to reclaim memory. Set to 0 to disable automatic GC.
196    pub gc_interval: usize,
197}
198
199/// Configuration for adaptive query execution.
200///
201/// Adaptive execution monitors actual row counts during query processing and
202/// can trigger re-optimization when estimates are significantly wrong.
203#[derive(Debug, Clone)]
204pub struct AdaptiveConfig {
205    /// Whether adaptive execution is enabled.
206    pub enabled: bool,
207
208    /// Deviation threshold that triggers re-optimization.
209    ///
210    /// A value of 3.0 means re-optimization is triggered when actual cardinality
211    /// is more than 3x or less than 1/3x the estimated value.
212    pub threshold: f64,
213
214    /// Minimum number of rows before considering re-optimization.
215    ///
216    /// Helps avoid thrashing on small result sets.
217    pub min_rows: u64,
218
219    /// Maximum number of re-optimizations allowed per query.
220    pub max_reoptimizations: usize,
221}
222
223impl Default for AdaptiveConfig {
224    fn default() -> Self {
225        Self {
226            enabled: true,
227            threshold: 3.0,
228            min_rows: 1000,
229            max_reoptimizations: 3,
230        }
231    }
232}
233
234impl AdaptiveConfig {
235    /// Creates a disabled adaptive config.
236    #[must_use]
237    pub fn disabled() -> Self {
238        Self {
239            enabled: false,
240            ..Default::default()
241        }
242    }
243
244    /// Sets the deviation threshold.
245    #[must_use]
246    pub fn with_threshold(mut self, threshold: f64) -> Self {
247        self.threshold = threshold;
248        self
249    }
250
251    /// Sets the minimum rows before re-optimization.
252    #[must_use]
253    pub fn with_min_rows(mut self, min_rows: u64) -> Self {
254        self.min_rows = min_rows;
255        self
256    }
257
258    /// Sets the maximum number of re-optimizations.
259    #[must_use]
260    pub fn with_max_reoptimizations(mut self, max: usize) -> Self {
261        self.max_reoptimizations = max;
262        self
263    }
264}
265
266impl Default for Config {
267    fn default() -> Self {
268        Self {
269            graph_model: GraphModel::default(),
270            path: None,
271            memory_limit: None,
272            spill_path: None,
273            threads: num_cpus::get(),
274            wal_enabled: true,
275            wal_flush_interval_ms: 100,
276            backward_edges: true,
277            query_logging: false,
278            adaptive: AdaptiveConfig::default(),
279            factorized_execution: true,
280            wal_durability: DurabilityMode::default(),
281            storage_format: StorageFormat::default(),
282            schema_constraints: false,
283            query_timeout: None,
284            gc_interval: 100,
285        }
286    }
287}
288
289impl Config {
290    /// Creates a new configuration for an in-memory database.
291    #[must_use]
292    pub fn in_memory() -> Self {
293        Self {
294            path: None,
295            wal_enabled: false,
296            ..Default::default()
297        }
298    }
299
300    /// Creates a new configuration for a persistent database.
301    #[must_use]
302    pub fn persistent(path: impl Into<PathBuf>) -> Self {
303        Self {
304            path: Some(path.into()),
305            wal_enabled: true,
306            ..Default::default()
307        }
308    }
309
310    /// Sets the memory limit.
311    #[must_use]
312    pub fn with_memory_limit(mut self, limit: usize) -> Self {
313        self.memory_limit = Some(limit);
314        self
315    }
316
317    /// Sets the number of worker threads.
318    #[must_use]
319    pub fn with_threads(mut self, threads: usize) -> Self {
320        self.threads = threads;
321        self
322    }
323
324    /// Disables backward edges.
325    #[must_use]
326    pub fn without_backward_edges(mut self) -> Self {
327        self.backward_edges = false;
328        self
329    }
330
331    /// Enables query logging.
332    #[must_use]
333    pub fn with_query_logging(mut self) -> Self {
334        self.query_logging = true;
335        self
336    }
337
338    /// Sets the memory budget as a fraction of system RAM.
339    #[must_use]
340    pub fn with_memory_fraction(mut self, fraction: f64) -> Self {
341        use grafeo_common::memory::buffer::BufferManagerConfig;
342        let system_memory = BufferManagerConfig::detect_system_memory();
343        self.memory_limit = Some((system_memory as f64 * fraction) as usize);
344        self
345    }
346
347    /// Sets the spill directory for out-of-core processing.
348    #[must_use]
349    pub fn with_spill_path(mut self, path: impl Into<PathBuf>) -> Self {
350        self.spill_path = Some(path.into());
351        self
352    }
353
354    /// Sets the adaptive execution configuration.
355    #[must_use]
356    pub fn with_adaptive(mut self, adaptive: AdaptiveConfig) -> Self {
357        self.adaptive = adaptive;
358        self
359    }
360
361    /// Disables adaptive execution.
362    #[must_use]
363    pub fn without_adaptive(mut self) -> Self {
364        self.adaptive.enabled = false;
365        self
366    }
367
368    /// Disables factorized execution for multi-hop queries.
369    ///
370    /// This reverts to the traditional flat execution model where each expansion
371    /// creates a full Cartesian product. Only use this if you encounter issues
372    /// with factorized execution.
373    #[must_use]
374    pub fn without_factorized_execution(mut self) -> Self {
375        self.factorized_execution = false;
376        self
377    }
378
379    /// Sets the graph data model.
380    #[must_use]
381    pub fn with_graph_model(mut self, model: GraphModel) -> Self {
382        self.graph_model = model;
383        self
384    }
385
386    /// Sets the WAL durability mode.
387    #[must_use]
388    pub fn with_wal_durability(mut self, mode: DurabilityMode) -> Self {
389        self.wal_durability = mode;
390        self
391    }
392
393    /// Sets the storage format for persistent databases.
394    #[must_use]
395    pub fn with_storage_format(mut self, format: StorageFormat) -> Self {
396        self.storage_format = format;
397        self
398    }
399
400    /// Enables catalog schema constraint enforcement.
401    #[must_use]
402    pub fn with_schema_constraints(mut self) -> Self {
403        self.schema_constraints = true;
404        self
405    }
406
407    /// Sets the maximum time a query may run before being cancelled.
408    #[must_use]
409    pub fn with_query_timeout(mut self, timeout: Duration) -> Self {
410        self.query_timeout = Some(timeout);
411        self
412    }
413
414    /// Sets the MVCC garbage collection interval (every N commits).
415    ///
416    /// Set to 0 to disable automatic GC.
417    #[must_use]
418    pub fn with_gc_interval(mut self, interval: usize) -> Self {
419        self.gc_interval = interval;
420        self
421    }
422
423    /// Validates the configuration, returning an error for invalid combinations.
424    ///
425    /// Called automatically by [`GrafeoDB::with_config()`](crate::GrafeoDB::with_config).
426    ///
427    /// # Errors
428    ///
429    /// Returns [`ConfigError`] if any setting is invalid.
430    pub fn validate(&self) -> std::result::Result<(), ConfigError> {
431        if let Some(limit) = self.memory_limit
432            && limit == 0
433        {
434            return Err(ConfigError::ZeroMemoryLimit);
435        }
436
437        if self.threads == 0 {
438            return Err(ConfigError::ZeroThreads);
439        }
440
441        if self.wal_flush_interval_ms == 0 {
442            return Err(ConfigError::ZeroWalFlushInterval);
443        }
444
445        #[cfg(not(feature = "rdf"))]
446        if self.graph_model == GraphModel::Rdf {
447            return Err(ConfigError::RdfFeatureRequired);
448        }
449
450        Ok(())
451    }
452}
453
454/// Helper function to get CPU count (fallback implementation).
455mod num_cpus {
456    #[cfg(not(target_arch = "wasm32"))]
457    pub fn get() -> usize {
458        std::thread::available_parallelism()
459            .map(|n| n.get())
460            .unwrap_or(4)
461    }
462
463    #[cfg(target_arch = "wasm32")]
464    pub fn get() -> usize {
465        1
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn test_config_default() {
475        let config = Config::default();
476        assert_eq!(config.graph_model, GraphModel::Lpg);
477        assert!(config.path.is_none());
478        assert!(config.memory_limit.is_none());
479        assert!(config.spill_path.is_none());
480        assert!(config.threads > 0);
481        assert!(config.wal_enabled);
482        assert_eq!(config.wal_flush_interval_ms, 100);
483        assert!(config.backward_edges);
484        assert!(!config.query_logging);
485        assert!(config.factorized_execution);
486        assert_eq!(config.wal_durability, DurabilityMode::default());
487        assert!(!config.schema_constraints);
488        assert!(config.query_timeout.is_none());
489        assert_eq!(config.gc_interval, 100);
490    }
491
492    #[test]
493    fn test_config_in_memory() {
494        let config = Config::in_memory();
495        assert!(config.path.is_none());
496        assert!(!config.wal_enabled);
497        assert!(config.backward_edges);
498    }
499
500    #[test]
501    fn test_config_persistent() {
502        let config = Config::persistent("/tmp/test_db");
503        assert_eq!(
504            config.path.as_deref(),
505            Some(std::path::Path::new("/tmp/test_db"))
506        );
507        assert!(config.wal_enabled);
508    }
509
510    #[test]
511    fn test_config_with_memory_limit() {
512        let config = Config::in_memory().with_memory_limit(1024 * 1024);
513        assert_eq!(config.memory_limit, Some(1024 * 1024));
514    }
515
516    #[test]
517    fn test_config_with_threads() {
518        let config = Config::in_memory().with_threads(8);
519        assert_eq!(config.threads, 8);
520    }
521
522    #[test]
523    fn test_config_without_backward_edges() {
524        let config = Config::in_memory().without_backward_edges();
525        assert!(!config.backward_edges);
526    }
527
528    #[test]
529    fn test_config_with_query_logging() {
530        let config = Config::in_memory().with_query_logging();
531        assert!(config.query_logging);
532    }
533
534    #[test]
535    fn test_config_with_spill_path() {
536        let config = Config::in_memory().with_spill_path("/tmp/spill");
537        assert_eq!(
538            config.spill_path.as_deref(),
539            Some(std::path::Path::new("/tmp/spill"))
540        );
541    }
542
543    #[test]
544    fn test_config_with_memory_fraction() {
545        let config = Config::in_memory().with_memory_fraction(0.5);
546        assert!(config.memory_limit.is_some());
547        assert!(config.memory_limit.unwrap() > 0);
548    }
549
550    #[test]
551    fn test_config_with_adaptive() {
552        let adaptive = AdaptiveConfig::default().with_threshold(5.0);
553        let config = Config::in_memory().with_adaptive(adaptive);
554        assert!((config.adaptive.threshold - 5.0).abs() < f64::EPSILON);
555    }
556
557    #[test]
558    fn test_config_without_adaptive() {
559        let config = Config::in_memory().without_adaptive();
560        assert!(!config.adaptive.enabled);
561    }
562
563    #[test]
564    fn test_config_without_factorized_execution() {
565        let config = Config::in_memory().without_factorized_execution();
566        assert!(!config.factorized_execution);
567    }
568
569    #[test]
570    fn test_config_builder_chaining() {
571        let config = Config::persistent("/tmp/db")
572            .with_memory_limit(512 * 1024 * 1024)
573            .with_threads(4)
574            .with_query_logging()
575            .without_backward_edges()
576            .with_spill_path("/tmp/spill");
577
578        assert!(config.path.is_some());
579        assert_eq!(config.memory_limit, Some(512 * 1024 * 1024));
580        assert_eq!(config.threads, 4);
581        assert!(config.query_logging);
582        assert!(!config.backward_edges);
583        assert!(config.spill_path.is_some());
584    }
585
586    #[test]
587    fn test_adaptive_config_default() {
588        let config = AdaptiveConfig::default();
589        assert!(config.enabled);
590        assert!((config.threshold - 3.0).abs() < f64::EPSILON);
591        assert_eq!(config.min_rows, 1000);
592        assert_eq!(config.max_reoptimizations, 3);
593    }
594
595    #[test]
596    fn test_adaptive_config_disabled() {
597        let config = AdaptiveConfig::disabled();
598        assert!(!config.enabled);
599    }
600
601    #[test]
602    fn test_adaptive_config_with_threshold() {
603        let config = AdaptiveConfig::default().with_threshold(10.0);
604        assert!((config.threshold - 10.0).abs() < f64::EPSILON);
605    }
606
607    #[test]
608    fn test_adaptive_config_with_min_rows() {
609        let config = AdaptiveConfig::default().with_min_rows(500);
610        assert_eq!(config.min_rows, 500);
611    }
612
613    #[test]
614    fn test_adaptive_config_with_max_reoptimizations() {
615        let config = AdaptiveConfig::default().with_max_reoptimizations(5);
616        assert_eq!(config.max_reoptimizations, 5);
617    }
618
619    #[test]
620    fn test_adaptive_config_builder_chaining() {
621        let config = AdaptiveConfig::default()
622            .with_threshold(2.0)
623            .with_min_rows(100)
624            .with_max_reoptimizations(10);
625        assert!((config.threshold - 2.0).abs() < f64::EPSILON);
626        assert_eq!(config.min_rows, 100);
627        assert_eq!(config.max_reoptimizations, 10);
628    }
629
630    // --- GraphModel tests ---
631
632    #[test]
633    fn test_graph_model_default_is_lpg() {
634        assert_eq!(GraphModel::default(), GraphModel::Lpg);
635    }
636
637    #[test]
638    fn test_graph_model_display() {
639        assert_eq!(GraphModel::Lpg.to_string(), "LPG");
640        assert_eq!(GraphModel::Rdf.to_string(), "RDF");
641    }
642
643    #[test]
644    fn test_config_with_graph_model() {
645        let config = Config::in_memory().with_graph_model(GraphModel::Rdf);
646        assert_eq!(config.graph_model, GraphModel::Rdf);
647    }
648
649    // --- DurabilityMode tests ---
650
651    #[test]
652    fn test_durability_mode_default_is_batch() {
653        let mode = DurabilityMode::default();
654        assert_eq!(
655            mode,
656            DurabilityMode::Batch {
657                max_delay_ms: 100,
658                max_records: 1000
659            }
660        );
661    }
662
663    #[test]
664    fn test_config_with_wal_durability() {
665        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::Sync);
666        assert_eq!(config.wal_durability, DurabilityMode::Sync);
667    }
668
669    #[test]
670    fn test_config_with_wal_durability_nosync() {
671        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::NoSync);
672        assert_eq!(config.wal_durability, DurabilityMode::NoSync);
673    }
674
675    #[test]
676    fn test_config_with_wal_durability_adaptive() {
677        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::Adaptive {
678            target_interval_ms: 50,
679        });
680        assert_eq!(
681            config.wal_durability,
682            DurabilityMode::Adaptive {
683                target_interval_ms: 50
684            }
685        );
686    }
687
688    // --- schema_constraints tests ---
689
690    #[test]
691    fn test_config_with_schema_constraints() {
692        let config = Config::in_memory().with_schema_constraints();
693        assert!(config.schema_constraints);
694    }
695
696    // --- query_timeout tests ---
697
698    #[test]
699    fn test_config_with_query_timeout() {
700        let config = Config::in_memory().with_query_timeout(Duration::from_secs(30));
701        assert_eq!(config.query_timeout, Some(Duration::from_secs(30)));
702    }
703
704    // --- gc_interval tests ---
705
706    #[test]
707    fn test_config_with_gc_interval() {
708        let config = Config::in_memory().with_gc_interval(50);
709        assert_eq!(config.gc_interval, 50);
710    }
711
712    #[test]
713    fn test_config_gc_disabled() {
714        let config = Config::in_memory().with_gc_interval(0);
715        assert_eq!(config.gc_interval, 0);
716    }
717
718    // --- validate() tests ---
719
720    #[test]
721    fn test_validate_default_config() {
722        assert!(Config::default().validate().is_ok());
723    }
724
725    #[test]
726    fn test_validate_in_memory_config() {
727        assert!(Config::in_memory().validate().is_ok());
728    }
729
730    #[test]
731    fn test_validate_rejects_zero_memory_limit() {
732        let config = Config::in_memory().with_memory_limit(0);
733        assert_eq!(config.validate(), Err(ConfigError::ZeroMemoryLimit));
734    }
735
736    #[test]
737    fn test_validate_rejects_zero_threads() {
738        let config = Config::in_memory().with_threads(0);
739        assert_eq!(config.validate(), Err(ConfigError::ZeroThreads));
740    }
741
742    #[test]
743    fn test_validate_rejects_zero_wal_flush_interval() {
744        let mut config = Config::in_memory();
745        config.wal_flush_interval_ms = 0;
746        assert_eq!(config.validate(), Err(ConfigError::ZeroWalFlushInterval));
747    }
748
749    #[cfg(not(feature = "rdf"))]
750    #[test]
751    fn test_validate_rejects_rdf_without_feature() {
752        let config = Config::in_memory().with_graph_model(GraphModel::Rdf);
753        assert_eq!(config.validate(), Err(ConfigError::RdfFeatureRequired));
754    }
755
756    #[test]
757    fn test_config_error_display() {
758        assert_eq!(
759            ConfigError::ZeroMemoryLimit.to_string(),
760            "memory_limit must be greater than zero"
761        );
762        assert_eq!(
763            ConfigError::ZeroThreads.to_string(),
764            "threads must be greater than zero"
765        );
766        assert_eq!(
767            ConfigError::ZeroWalFlushInterval.to_string(),
768            "wal_flush_interval_ms must be greater than zero"
769        );
770        assert_eq!(
771            ConfigError::RdfFeatureRequired.to_string(),
772            "RDF graph model requires the `rdf` feature flag to be enabled"
773        );
774    }
775
776    // --- Builder chaining with new fields ---
777
778    #[test]
779    fn test_config_full_builder_chaining() {
780        let config = Config::persistent("/tmp/db")
781            .with_graph_model(GraphModel::Lpg)
782            .with_memory_limit(512 * 1024 * 1024)
783            .with_threads(4)
784            .with_query_logging()
785            .with_wal_durability(DurabilityMode::Sync)
786            .with_schema_constraints()
787            .without_backward_edges()
788            .with_spill_path("/tmp/spill")
789            .with_query_timeout(Duration::from_secs(60));
790
791        assert_eq!(config.graph_model, GraphModel::Lpg);
792        assert!(config.path.is_some());
793        assert_eq!(config.memory_limit, Some(512 * 1024 * 1024));
794        assert_eq!(config.threads, 4);
795        assert!(config.query_logging);
796        assert_eq!(config.wal_durability, DurabilityMode::Sync);
797        assert!(config.schema_constraints);
798        assert!(!config.backward_edges);
799        assert!(config.spill_path.is_some());
800        assert_eq!(config.query_timeout, Some(Duration::from_secs(60)));
801        assert!(config.validate().is_ok());
802    }
803}