1use anyhow::{anyhow, Result};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::{Arc, RwLock};
11use std::time::{Duration, Instant};
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub enum RdfTerm {
16 Iri(String),
18 BlankNode(String),
20 Literal {
22 value: String,
23 datatype: Option<String>,
24 lang: Option<String>,
25 },
26}
27
28impl RdfTerm {
29 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 pub fn iri(value: impl Into<String>) -> Self {
40 Self::Iri(value.into())
41 }
42
43 pub fn blank_node(value: impl Into<String>) -> Self {
45 Self::BlankNode(value.into())
46 }
47
48 pub fn is_iri(&self) -> bool {
50 matches!(self, RdfTerm::Iri(_))
51 }
52
53 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
81pub type BindingRow = HashMap<String, RdfTerm>;
83
84const MEMORY_ROW_THRESHOLD: usize = 10_000;
86
87pub enum ViewData {
89 InMemory(Vec<BindingRow>),
91 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#[derive(Debug)]
108pub struct MaterializedView {
109 pub query_hash: String,
111 pub query_pattern: String,
113 pub result_size: usize,
115 pub created_at: Instant,
117 pub last_accessed: Instant,
119 pub ttl: Duration,
121 pub access_count: u64,
123 pub data: ViewData,
125 pub dependent_predicates: Vec<String>,
127}
128
129impl MaterializedView {
130 pub fn is_expired(&self) -> bool {
132 self.created_at.elapsed() >= self.ttl
133 }
134
135 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#[derive(Debug, Default)]
146struct HitCounters {
147 hits: u64,
148 misses: u64,
149}
150
151#[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#[derive(Debug, Clone)]
165pub struct ViewManagerConfig {
166 pub max_views: usize,
168 pub default_ttl: Duration,
170 pub spill_dir: PathBuf,
172 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
187pub struct MaterializedViewManager {
191 views: Arc<RwLock<HashMap<String, MaterializedView>>>,
192 counters: Arc<RwLock<HitCounters>>,
193 config: ViewManagerConfig,
194}
195
196impl MaterializedViewManager {
197 pub fn with_config(config: ViewManagerConfig) -> Self {
199 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 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 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 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 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 if !views.contains_key(query_hash) {
276 self.record_miss();
277 return None;
278 }
279
280 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 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 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 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 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 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 for path in to_delete {
359 let _ = std::fs::remove_file(&path);
360 }
361 removed
362 }
363
364 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 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 fn evict_lru_locked(&self, views: &mut HashMap<String, MaterializedView>) {
414 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 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 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 let _ = manager.get_view("lru1");
650 manager
651 .store_view("lru2", "pat2", rows.clone(), vec![])
652 .unwrap();
653 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"); let _ = manager.get_view("missing"); 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 #[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 #[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 #[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 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 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 #[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 #[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 assert!(mgr1.get_view("x").is_none());
939 assert!(mgr2.get_view("x").is_none());
940 }
941}