Skip to main content

oxirs_arq/optimizer/
materialized_view.rs

1//! Materialized Query Result Views
2//!
3//! This module manages cached subquery result sets that can be reused when the same
4//! (or structurally equivalent) subquery is repeated in different queries.
5//! Views are automatically invalidated when their underlying RDF patterns change.
6
7use anyhow::{anyhow, Result};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::{Arc, RwLock};
11use std::time::{Duration, Instant};
12
13/// An RDF term appearing in a query result binding
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub enum RdfTerm {
16    /// Named node (IRI)
17    Iri(String),
18    /// Blank node
19    BlankNode(String),
20    /// Literal with optional datatype and language tag
21    Literal {
22        value: String,
23        datatype: Option<String>,
24        lang: Option<String>,
25    },
26}
27
28impl RdfTerm {
29    /// Convenience constructor for a plain string literal
30    pub fn plain_literal(value: impl Into<String>) -> Self {
31        Self::Literal {
32            value: value.into(),
33            datatype: None,
34            lang: None,
35        }
36    }
37
38    /// Convenience constructor for an IRI term
39    pub fn iri(value: impl Into<String>) -> Self {
40        Self::Iri(value.into())
41    }
42
43    /// Convenience constructor for a blank node
44    pub fn blank_node(value: impl Into<String>) -> Self {
45        Self::BlankNode(value.into())
46    }
47
48    /// Returns true if this term is an IRI
49    pub fn is_iri(&self) -> bool {
50        matches!(self, RdfTerm::Iri(_))
51    }
52
53    /// Returns true if this term is a literal
54    pub fn is_literal(&self) -> bool {
55        matches!(self, RdfTerm::Literal { .. })
56    }
57}
58
59impl std::fmt::Display for RdfTerm {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            RdfTerm::Iri(iri) => write!(f, "<{iri}>"),
63            RdfTerm::BlankNode(id) => write!(f, "_:{id}"),
64            RdfTerm::Literal {
65                value,
66                datatype,
67                lang,
68            } => {
69                write!(f, "\"{value}\"")?;
70                if let Some(dt) = datatype {
71                    write!(f, "^^<{dt}>")?;
72                } else if let Some(lang_tag) = lang {
73                    write!(f, "@{lang_tag}")?;
74                }
75                Ok(())
76            }
77        }
78    }
79}
80
81/// A single result row from a SPARQL query - maps variable names to RDF terms
82pub type BindingRow = HashMap<String, RdfTerm>;
83
84/// Threshold in rows: views with fewer rows are kept in memory
85const MEMORY_ROW_THRESHOLD: usize = 10_000;
86
87/// Where the view data is stored
88pub enum ViewData {
89    /// Result set small enough to hold in memory
90    InMemory(Vec<BindingRow>),
91    /// Large result set persisted to a temporary file
92    OnDisk { path: PathBuf, row_count: usize },
93}
94
95impl std::fmt::Debug for ViewData {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            ViewData::InMemory(rows) => write!(f, "InMemory({} rows)", rows.len()),
99            ViewData::OnDisk { path, row_count } => {
100                write!(f, "OnDisk({row_count} rows @ {})", path.display())
101            }
102        }
103    }
104}
105
106/// A single materialized view entry
107#[derive(Debug)]
108pub struct MaterializedView {
109    /// Hash of the query pattern that produced this view
110    pub query_hash: String,
111    /// Textual representation of the source query pattern
112    pub query_pattern: String,
113    /// Number of result rows
114    pub result_size: usize,
115    /// When the view was first created
116    pub created_at: Instant,
117    /// When the view was last accessed (for LRU eviction)
118    pub last_accessed: Instant,
119    /// Time-to-live for this view
120    pub ttl: Duration,
121    /// How many times this view has been accessed (hit count)
122    pub access_count: u64,
123    /// The actual data
124    pub data: ViewData,
125    /// Set of predicate IRIs that this view depends on (for targeted invalidation)
126    pub dependent_predicates: Vec<String>,
127}
128
129impl MaterializedView {
130    /// Returns true if the view's TTL has elapsed
131    pub fn is_expired(&self) -> bool {
132        self.created_at.elapsed() >= self.ttl
133    }
134
135    /// Returns the in-memory rows if available
136    pub fn in_memory_rows(&self) -> Option<&[BindingRow]> {
137        match &self.data {
138            ViewData::InMemory(rows) => Some(rows),
139            ViewData::OnDisk { .. } => None,
140        }
141    }
142}
143
144/// Global hit/miss counters shared across the manager
145#[derive(Debug, Default)]
146struct HitCounters {
147    hits: u64,
148    misses: u64,
149}
150
151/// Summary statistics for the view manager
152#[derive(Debug, Clone)]
153pub struct ViewManagerStats {
154    pub total_views: usize,
155    pub hit_count: u64,
156    pub miss_count: u64,
157    pub hit_ratio: f64,
158    pub total_rows_cached: usize,
159    pub on_disk_views: usize,
160    pub in_memory_views: usize,
161}
162
163/// Configuration for the materialized view manager
164#[derive(Debug, Clone)]
165pub struct ViewManagerConfig {
166    /// Maximum number of views to keep in the cache
167    pub max_views: usize,
168    /// Default TTL for new views
169    pub default_ttl: Duration,
170    /// Directory for on-disk spill files (uses std::env::temp_dir() by default)
171    pub spill_dir: PathBuf,
172    /// Row threshold below which views are kept in memory
173    pub memory_row_threshold: usize,
174}
175
176impl Default for ViewManagerConfig {
177    fn default() -> Self {
178        Self {
179            max_views: 256,
180            default_ttl: Duration::from_secs(300),
181            spill_dir: std::env::temp_dir().join("oxirs_view_cache"),
182            memory_row_threshold: MEMORY_ROW_THRESHOLD,
183        }
184    }
185}
186
187/// Manager for materialized query result views
188///
189/// Thread-safe: all public methods acquire only short-lived locks.
190pub struct MaterializedViewManager {
191    views: Arc<RwLock<HashMap<String, MaterializedView>>>,
192    counters: Arc<RwLock<HitCounters>>,
193    config: ViewManagerConfig,
194}
195
196impl MaterializedViewManager {
197    /// Create a new view manager with explicit configuration
198    pub fn with_config(config: ViewManagerConfig) -> Self {
199        // Pre-create spill directory (ignore errors if it already exists)
200        let _ = std::fs::create_dir_all(&config.spill_dir);
201        Self {
202            views: Arc::new(RwLock::new(HashMap::new())),
203            counters: Arc::new(RwLock::new(HitCounters::default())),
204            config,
205        }
206    }
207
208    /// Create a new view manager with default settings
209    pub fn new(max_views: usize, default_ttl: Duration) -> Self {
210        let config = ViewManagerConfig {
211            max_views,
212            default_ttl,
213            ..ViewManagerConfig::default()
214        };
215        Self::with_config(config)
216    }
217
218    /// Store a new view.
219    ///
220    /// If the cache is full, the least-recently-used view is evicted first.
221    /// Large result sets (exceeding the memory row threshold) are spilled to disk.
222    pub fn store_view(
223        &self,
224        query_hash: &str,
225        pattern: &str,
226        results: Vec<BindingRow>,
227        dependent_predicates: Vec<String>,
228    ) -> Result<()> {
229        let result_size = results.len();
230        let ttl = self.config.default_ttl;
231
232        let data = if result_size > self.config.memory_row_threshold {
233            self.spill_to_disk(query_hash, &results)?
234        } else {
235            ViewData::InMemory(results)
236        };
237
238        let view = MaterializedView {
239            query_hash: query_hash.to_string(),
240            query_pattern: pattern.to_string(),
241            result_size,
242            created_at: Instant::now(),
243            last_accessed: Instant::now(),
244            ttl,
245            access_count: 0,
246            data,
247            dependent_predicates,
248        };
249
250        let mut views = self
251            .views
252            .write()
253            .map_err(|e| anyhow!("Failed to acquire write lock: {e}"))?;
254
255        // Evict if at capacity
256        if views.len() >= self.config.max_views && !views.contains_key(query_hash) {
257            self.evict_lru_locked(&mut views);
258        }
259
260        views.insert(query_hash.to_string(), view);
261        Ok(())
262    }
263
264    /// Retrieve a view by query hash.  Returns None on cache miss or if expired.
265    pub fn get_view(&self, query_hash: &str) -> Option<Vec<BindingRow>> {
266        let mut views = match self.views.write().ok() {
267            Some(v) => v,
268            None => {
269                self.record_miss();
270                return None;
271            }
272        };
273
274        // Key not found -> cache miss
275        if !views.contains_key(query_hash) {
276            self.record_miss();
277            return None;
278        }
279
280        // Check expiry before borrowing mutably
281        let is_expired = views.get(query_hash).is_some_and(|v| v.is_expired());
282        if is_expired {
283            views.remove(query_hash);
284            self.record_miss();
285            return None;
286        }
287
288        let view = views.get_mut(query_hash)?;
289
290        // Update access metadata
291        view.last_accessed = Instant::now();
292        view.access_count += 1;
293
294        let result = match &view.data {
295            ViewData::InMemory(rows) => Some(rows.clone()),
296            ViewData::OnDisk { path, .. } => self.load_from_disk(path).ok(),
297        };
298
299        if result.is_some() {
300            self.record_hit();
301        } else {
302            self.record_miss();
303        }
304        result
305    }
306
307    /// Invalidate all views that depend on a given predicate IRI.
308    ///
309    /// Returns the number of views removed.
310    pub fn invalidate_by_predicate(&self, predicate_iri: &str) -> usize {
311        let Ok(mut views) = self.views.write() else {
312            return 0;
313        };
314        let before = views.len();
315        views.retain(|_, view| {
316            !view
317                .dependent_predicates
318                .contains(&predicate_iri.to_string())
319        });
320        before - views.len()
321    }
322
323    /// Invalidate views whose query pattern contains the given substring.
324    /// Useful for invalidating based on graph pattern structure changes.
325    pub fn invalidate_pattern(&self, affected_pattern: &str) -> usize {
326        let Ok(mut views) = self.views.write() else {
327            return 0;
328        };
329        let before = views.len();
330        views.retain(|_, view| !view.query_pattern.contains(affected_pattern));
331        before - views.len()
332    }
333
334    /// Remove all views whose TTL has elapsed.
335    ///
336    /// Returns the number of expired views removed.
337    pub fn evict_expired(&self) -> usize {
338        let Ok(mut views) = self.views.write() else {
339            return 0;
340        };
341        let before = views.len();
342        // Collect paths to delete on disk
343        let to_delete: Vec<PathBuf> = views
344            .values()
345            .filter(|v| v.is_expired())
346            .filter_map(|v| {
347                if let ViewData::OnDisk { path, .. } = &v.data {
348                    Some(path.clone())
349                } else {
350                    None
351                }
352            })
353            .collect();
354        views.retain(|_, v| !v.is_expired());
355        let removed = before - views.len();
356
357        // Best-effort deletion of spill files
358        for path in to_delete {
359            let _ = std::fs::remove_file(&path);
360        }
361        removed
362    }
363
364    /// Explicitly remove a single view by hash
365    pub fn invalidate_view(&self, query_hash: &str) -> bool {
366        let Ok(mut views) = self.views.write() else {
367            return false;
368        };
369        if let Some(view) = views.remove(query_hash) {
370            if let ViewData::OnDisk { path, .. } = view.data {
371                let _ = std::fs::remove_file(&path);
372            }
373            true
374        } else {
375            false
376        }
377    }
378
379    /// Retrieve current statistics snapshot
380    pub fn get_stats(&self) -> ViewManagerStats {
381        let views = self.views.read().unwrap_or_else(|e| e.into_inner());
382        let counters = self.counters.read().unwrap_or_else(|e| e.into_inner());
383
384        let total_rows_cached: usize = views.values().map(|v| v.result_size).sum();
385        let on_disk_views = views
386            .values()
387            .filter(|v| matches!(&v.data, ViewData::OnDisk { .. }))
388            .count();
389        let in_memory_views = views.len() - on_disk_views;
390
391        let total = counters.hits + counters.misses;
392        let hit_ratio = if total > 0 {
393            counters.hits as f64 / total as f64
394        } else {
395            0.0
396        };
397
398        ViewManagerStats {
399            total_views: views.len(),
400            hit_count: counters.hits,
401            miss_count: counters.misses,
402            hit_ratio,
403            total_rows_cached,
404            on_disk_views,
405            in_memory_views,
406        }
407    }
408
409    // -----------------------------------------------------------------------
410    // Private helpers
411    // -----------------------------------------------------------------------
412
413    fn evict_lru_locked(&self, views: &mut HashMap<String, MaterializedView>) {
414        // Find the key with the oldest last_accessed time
415        let oldest_key = views
416            .iter()
417            .min_by_key(|(_, v)| v.last_accessed)
418            .map(|(k, _)| k.clone());
419
420        if let Some(key) = oldest_key {
421            if let Some(view) = views.remove(&key) {
422                if let ViewData::OnDisk { path, .. } = view.data {
423                    let _ = std::fs::remove_file(&path);
424                }
425            }
426        }
427    }
428
429    fn spill_to_disk(&self, query_hash: &str, results: &[BindingRow]) -> Result<ViewData> {
430        let safe_hash = query_hash
431            .chars()
432            .filter(|c| c.is_alphanumeric() || *c == '_')
433            .take(32)
434            .collect::<String>();
435        let file_name = format!("view_{safe_hash}.json");
436        let path = self.config.spill_dir.join(file_name);
437
438        let json = serde_json::to_vec(
439            &results
440                .iter()
441                .map(|row| {
442                    row.iter()
443                        .map(|(k, v)| (k.clone(), format!("{v}")))
444                        .collect::<HashMap<String, String>>()
445                })
446                .collect::<Vec<_>>(),
447        )
448        .map_err(|e| anyhow!("Serialization error: {e}"))?;
449
450        std::fs::write(&path, &json)
451            .map_err(|e| anyhow!("Failed to write spill file {}: {e}", path.display()))?;
452
453        Ok(ViewData::OnDisk {
454            path,
455            row_count: results.len(),
456        })
457    }
458
459    fn load_from_disk(&self, path: &PathBuf) -> Result<Vec<BindingRow>> {
460        let bytes = std::fs::read(path).map_err(|e| anyhow!("Failed to read spill file: {e}"))?;
461        let raw: Vec<HashMap<String, String>> =
462            serde_json::from_slice(&bytes).map_err(|e| anyhow!("Deserialization error: {e}"))?;
463
464        let rows = raw
465            .into_iter()
466            .map(|row| {
467                row.into_iter()
468                    .map(|(k, v)| (k, RdfTerm::plain_literal(v)))
469                    .collect::<BindingRow>()
470            })
471            .collect();
472        Ok(rows)
473    }
474
475    fn record_hit(&self) {
476        if let Ok(mut c) = self.counters.write() {
477            c.hits += 1;
478        }
479    }
480
481    fn record_miss(&self) {
482        if let Ok(mut c) = self.counters.write() {
483            c.misses += 1;
484        }
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use std::time::Duration;
492
493    fn make_row(key: &str, val: &str) -> BindingRow {
494        let mut row = BindingRow::new();
495        row.insert(key.to_string(), RdfTerm::plain_literal(val));
496        row
497    }
498
499    fn temp_manager() -> MaterializedViewManager {
500        let config = ViewManagerConfig {
501            max_views: 10,
502            default_ttl: Duration::from_secs(60),
503            spill_dir: std::env::temp_dir().join("oxirs_view_cache_test"),
504            ..Default::default()
505        };
506        MaterializedViewManager::with_config(config)
507    }
508
509    #[test]
510    fn test_store_and_retrieve_in_memory_view() {
511        let manager = temp_manager();
512        let rows = vec![make_row("s", "http://example.org/a")];
513        manager
514            .store_view("hash1", "SELECT * WHERE { ?s ?p ?o }", rows.clone(), vec![])
515            .unwrap();
516
517        let retrieved = manager.get_view("hash1");
518        assert!(retrieved.is_some());
519        assert_eq!(retrieved.unwrap().len(), 1);
520    }
521
522    #[test]
523    fn test_cache_miss_returns_none() {
524        let manager = temp_manager();
525        assert!(manager.get_view("nonexistent_hash").is_none());
526    }
527
528    #[test]
529    fn test_expired_view_returns_none() {
530        // Extremely short TTL
531        let config = ViewManagerConfig {
532            max_views: 10,
533            default_ttl: Duration::from_nanos(1),
534            spill_dir: std::env::temp_dir().join("oxirs_view_cache_test_ttl"),
535            ..Default::default()
536        };
537        let manager = MaterializedViewManager::with_config(config);
538
539        let rows = vec![make_row("s", "http://example.org/a")];
540        manager
541            .store_view("hash_ttl", "pattern", rows, vec![])
542            .unwrap();
543
544        // Sleep to ensure TTL expires
545        std::thread::sleep(Duration::from_millis(5));
546
547        assert!(
548            manager.get_view("hash_ttl").is_none(),
549            "Expired view should return None"
550        );
551    }
552
553    #[test]
554    fn test_invalidate_pattern() {
555        let manager = temp_manager();
556        let rows = vec![make_row("s", "http://example.org/a")];
557        manager
558            .store_view(
559                "hash2",
560                "SELECT * WHERE { ?s foaf:name ?name }",
561                rows.clone(),
562                vec![],
563            )
564            .unwrap();
565        manager
566            .store_view(
567                "hash3",
568                "SELECT * WHERE { ?s rdf:type ?type }",
569                rows,
570                vec![],
571            )
572            .unwrap();
573
574        let removed = manager.invalidate_pattern("foaf:name");
575        assert_eq!(removed, 1, "Should remove exactly one view");
576        assert!(
577            manager.get_view("hash2").is_none(),
578            "Invalidated view should be gone"
579        );
580        assert!(
581            manager.get_view("hash3").is_some(),
582            "Other view should remain"
583        );
584    }
585
586    #[test]
587    fn test_invalidate_by_predicate() {
588        let manager = temp_manager();
589        let rows = vec![make_row("s", "http://example.org/a")];
590        manager
591            .store_view(
592                "hash_pred1",
593                "pattern_a",
594                rows.clone(),
595                vec!["http://xmlns.com/foaf/0.1/name".to_string()],
596            )
597            .unwrap();
598        manager
599            .store_view(
600                "hash_pred2",
601                "pattern_b",
602                rows,
603                vec!["http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string()],
604            )
605            .unwrap();
606
607        let removed = manager.invalidate_by_predicate("http://xmlns.com/foaf/0.1/name");
608        assert_eq!(removed, 1);
609        assert!(manager.get_view("hash_pred1").is_none());
610        assert!(manager.get_view("hash_pred2").is_some());
611    }
612
613    #[test]
614    fn test_evict_expired() {
615        let config = ViewManagerConfig {
616            max_views: 10,
617            default_ttl: Duration::from_nanos(1),
618            spill_dir: std::env::temp_dir().join("oxirs_view_cache_test_evict"),
619            ..Default::default()
620        };
621        let manager = MaterializedViewManager::with_config(config);
622
623        let rows = vec![make_row("x", "val")];
624        manager
625            .store_view("exp1", "pat", rows.clone(), vec![])
626            .unwrap();
627        manager.store_view("exp2", "pat2", rows, vec![]).unwrap();
628
629        std::thread::sleep(Duration::from_millis(5));
630        let removed = manager.evict_expired();
631        assert_eq!(removed, 2, "Both expired views should be evicted");
632    }
633
634    #[test]
635    fn test_lru_eviction_when_full() {
636        let config = ViewManagerConfig {
637            max_views: 2,
638            default_ttl: Duration::from_secs(300),
639            spill_dir: std::env::temp_dir().join("oxirs_view_cache_test_lru"),
640            ..Default::default()
641        };
642        let manager = MaterializedViewManager::with_config(config);
643
644        let rows = vec![make_row("a", "val")];
645        manager
646            .store_view("lru1", "pat1", rows.clone(), vec![])
647            .unwrap();
648        // Touch lru1 to make it more recently used
649        let _ = manager.get_view("lru1");
650        manager
651            .store_view("lru2", "pat2", rows.clone(), vec![])
652            .unwrap();
653        // Now add a third - lru1 was accessed most recently, lru2 second
654        // lru1 is most recent (was accessed after creation)
655        manager.store_view("lru3", "pat3", rows, vec![]).unwrap();
656
657        let stats = manager.get_stats();
658        assert_eq!(
659            stats.total_views, 2,
660            "Manager should enforce max_views capacity"
661        );
662    }
663
664    #[test]
665    fn test_hit_miss_stats() {
666        let manager = temp_manager();
667        let rows = vec![make_row("s", "http://example.org/a")];
668        manager
669            .store_view("stat_hash", "pattern", rows, vec![])
670            .unwrap();
671
672        let _ = manager.get_view("stat_hash"); // hit
673        let _ = manager.get_view("missing"); // miss
674
675        let stats = manager.get_stats();
676        assert_eq!(stats.hit_count, 1);
677        assert_eq!(stats.miss_count, 1);
678        assert!((stats.hit_ratio - 0.5).abs() < 0.001);
679    }
680
681    #[test]
682    fn test_explicit_invalidate_single_view() {
683        let manager = temp_manager();
684        let rows = vec![make_row("s", "val")];
685        manager.store_view("del_hash", "pat", rows, vec![]).unwrap();
686        assert!(manager.get_view("del_hash").is_some());
687
688        let removed = manager.invalidate_view("del_hash");
689        assert!(removed, "Should report successful removal");
690        assert!(manager.get_view("del_hash").is_none());
691    }
692
693    #[test]
694    fn test_rdf_term_display() {
695        let iri = RdfTerm::iri("http://example.org/s");
696        assert_eq!(format!("{iri}"), "<http://example.org/s>");
697
698        let blank = RdfTerm::blank_node("b1");
699        assert_eq!(format!("{blank}"), "_:b1");
700
701        let lit = RdfTerm::plain_literal("hello");
702        assert_eq!(format!("{lit}"), "\"hello\"");
703
704        let typed = RdfTerm::Literal {
705            value: "42".to_string(),
706            datatype: Some("http://www.w3.org/2001/XMLSchema#integer".to_string()),
707            lang: None,
708        };
709        assert!(format!("{typed}").contains("^^"));
710    }
711}
712
713#[cfg(test)]
714mod extended_tests {
715    use super::*;
716    use std::time::Duration;
717
718    fn make_row(key: &str, val: &str) -> BindingRow {
719        let mut row = BindingRow::new();
720        row.insert(key.to_string(), RdfTerm::plain_literal(val));
721        row
722    }
723
724    fn make_iri_row(key: &str, iri: &str) -> BindingRow {
725        let mut row = BindingRow::new();
726        row.insert(key.to_string(), RdfTerm::iri(iri));
727        row
728    }
729
730    fn long_ttl_manager() -> MaterializedViewManager {
731        let config = ViewManagerConfig {
732            max_views: 20,
733            default_ttl: Duration::from_secs(3600),
734            spill_dir: std::env::temp_dir().join("oxirs_view_ext_test_long"),
735            ..Default::default()
736        };
737        MaterializedViewManager::with_config(config)
738    }
739
740    // --- RdfTerm tests ---
741
742    #[test]
743    fn test_rdf_term_iri_is_iri() {
744        let term = RdfTerm::iri("http://example.org/s");
745        assert!(term.is_iri());
746        assert!(!term.is_literal());
747    }
748
749    #[test]
750    fn test_rdf_term_literal_is_literal() {
751        let term = RdfTerm::plain_literal("hello");
752        assert!(term.is_literal());
753        assert!(!term.is_iri());
754    }
755
756    #[test]
757    fn test_rdf_term_blank_node_is_neither() {
758        let term = RdfTerm::blank_node("b0");
759        assert!(!term.is_iri());
760        assert!(!term.is_literal());
761    }
762
763    #[test]
764    fn test_rdf_term_literal_with_lang_display() {
765        let term = RdfTerm::Literal {
766            value: "hello".to_string(),
767            datatype: None,
768            lang: Some("en".to_string()),
769        };
770        let s = format!("{term}");
771        assert!(
772            s.contains("@en"),
773            "Lang-tagged literal should include @lang"
774        );
775    }
776
777    #[test]
778    fn test_rdf_term_equality() {
779        let a = RdfTerm::iri("http://example.org/x");
780        let b = RdfTerm::iri("http://example.org/x");
781        let c = RdfTerm::iri("http://example.org/y");
782        assert_eq!(a, b);
783        assert_ne!(a, c);
784    }
785
786    // --- MaterializedViewManager store/retrieve ---
787
788    #[test]
789    fn test_store_multiple_views_and_retrieve_all() {
790        let manager = long_ttl_manager();
791        for i in 0..5 {
792            let rows = vec![make_row("x", &format!("val{i}"))];
793            manager
794                .store_view(&format!("hash_{i}"), &format!("pattern_{i}"), rows, vec![])
795                .unwrap();
796        }
797        for i in 0..5 {
798            assert!(
799                manager.get_view(&format!("hash_{i}")).is_some(),
800                "View {i} should be retrievable"
801            );
802        }
803    }
804
805    #[test]
806    fn test_get_view_increments_hit_count() {
807        let manager = long_ttl_manager();
808        let rows = vec![make_row("k", "v")];
809        manager.store_view("h_hit", "pat", rows, vec![]).unwrap();
810
811        let _ = manager.get_view("h_hit");
812        let _ = manager.get_view("h_hit");
813
814        let stats = manager.get_stats();
815        assert_eq!(
816            stats.hit_count, 2,
817            "Two successful gets should count as two hits"
818        );
819    }
820
821    #[test]
822    fn test_get_missing_view_increments_miss_count() {
823        let manager = long_ttl_manager();
824        let _ = manager.get_view("does_not_exist_1");
825        let _ = manager.get_view("does_not_exist_2");
826
827        let stats = manager.get_stats();
828        assert_eq!(stats.miss_count, 2, "Two misses should be recorded");
829    }
830
831    #[test]
832    fn test_stats_total_rows_cached() {
833        let manager = long_ttl_manager();
834        let rows: Vec<BindingRow> = (0..5).map(|i| make_row("k", &i.to_string())).collect();
835        manager.store_view("rows5", "pat", rows, vec![]).unwrap();
836
837        let stats = manager.get_stats();
838        assert_eq!(stats.total_rows_cached, 5, "Should track total cached rows");
839    }
840
841    #[test]
842    fn test_stats_in_memory_vs_on_disk_count() {
843        let manager = long_ttl_manager();
844        let rows = vec![make_iri_row("s", "http://example.org/a")];
845        manager.store_view("in_mem", "pat", rows, vec![]).unwrap();
846
847        let stats = manager.get_stats();
848        assert!(
849            stats.in_memory_views >= 1 || stats.on_disk_views >= 1,
850            "View should be tracked in stats"
851        );
852    }
853
854    // --- Invalidation tests ---
855
856    #[test]
857    fn test_invalidate_nonexistent_view_returns_false() {
858        let manager = long_ttl_manager();
859        let removed = manager.invalidate_view("no_such_hash");
860        assert!(!removed, "Removing non-existent view should return false");
861    }
862
863    #[test]
864    fn test_invalidate_pattern_with_no_matching_views() {
865        let manager = long_ttl_manager();
866        let removed = manager.invalidate_pattern("some_predicate");
867        assert_eq!(removed, 0, "No views should be removed when none match");
868    }
869
870    #[test]
871    fn test_invalidate_by_predicate_removes_only_matching() {
872        let manager = long_ttl_manager();
873        let rows = vec![make_row("s", "val")];
874        // Pass the target predicate in dependent_predicates for the matching view
875        manager
876            .store_view(
877                "pred_match",
878                "pat_with_target",
879                rows.clone(),
880                vec!["http://example.org/target_pred".to_string()],
881            )
882            .unwrap();
883        // No dependent predicates for the non-matching view
884        manager
885            .store_view("no_match", "pat_without_target", rows, vec![])
886            .unwrap();
887
888        let removed = manager.invalidate_by_predicate("http://example.org/target_pred");
889        assert_eq!(removed, 1, "Only the matching view should be invalidated");
890        assert!(
891            manager.get_view("no_match").is_some(),
892            "Non-matching view should remain"
893        );
894    }
895
896    // --- Expiry tests ---
897
898    #[test]
899    fn test_evict_expired_on_empty_manager() {
900        let manager = long_ttl_manager();
901        let removed = manager.evict_expired();
902        assert_eq!(removed, 0, "Evicting empty manager should remove 0 views");
903    }
904
905    #[test]
906    fn test_non_expired_view_not_evicted() {
907        let manager = long_ttl_manager();
908        let rows = vec![make_row("k", "v")];
909        manager.store_view("live", "pat", rows, vec![]).unwrap();
910
911        let removed = manager.evict_expired();
912        assert_eq!(removed, 0, "Live view should not be evicted");
913        assert!(manager.get_view("live").is_some());
914    }
915
916    // --- ViewManagerConfig ---
917
918    #[test]
919    fn test_view_manager_config_default_values() {
920        let config = ViewManagerConfig::default();
921        assert!(config.max_views > 0, "Default max_views should be positive");
922        assert!(
923            config.default_ttl.as_secs() > 0,
924            "Default TTL should be positive"
925        );
926    }
927
928    #[test]
929    fn test_new_constructor_equivalent_to_with_config() {
930        let mgr1 = MaterializedViewManager::new(50, Duration::from_secs(120));
931        let mgr2 = MaterializedViewManager::with_config(ViewManagerConfig {
932            max_views: 50,
933            default_ttl: Duration::from_secs(120),
934            ..Default::default()
935        });
936
937        // Both should start empty
938        assert!(mgr1.get_view("x").is_none());
939        assert!(mgr2.get_view("x").is_none());
940    }
941}