Skip to main content

oxirs_core/view/
graph_view.rs

1//! Named graph views for OxiRS.
2//!
3//! Provides:
4//! - [`GraphView`]: Read-only view over a subset of RDF triples.
5//! - [`FilteredView`]: View with subject/predicate/object filters applied.
6//! - [`UnionView`]: Virtual union over multiple named graph views.
7//! - [`MergedView`]: Merges a default graph with one or more named graphs.
8//! - [`ViewMaterializer`]: Materializes and caches view data.
9
10use std::collections::HashMap;
11
12/// An RDF triple (subject, predicate, object) — all as string-encoded IRIs/literals.
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct RdfTriple {
15    pub subject: String,
16    pub predicate: String,
17    pub object: String,
18}
19
20impl RdfTriple {
21    pub fn new(s: &str, p: &str, o: &str) -> Self {
22        Self {
23            subject: s.to_string(),
24            predicate: p.to_string(),
25            object: o.to_string(),
26        }
27    }
28
29    /// Returns `true` if `pattern` matches this triple.
30    /// `None` in any slot is a wildcard.
31    pub fn matches(
32        &self,
33        subject: Option<&str>,
34        predicate: Option<&str>,
35        object: Option<&str>,
36    ) -> bool {
37        subject.map(|s| s == self.subject).unwrap_or(true)
38            && predicate.map(|p| p == self.predicate).unwrap_or(true)
39            && object.map(|o| o == self.object).unwrap_or(true)
40    }
41}
42
43// ---------------------------------------------------------------------------
44// GraphView
45// ---------------------------------------------------------------------------
46
47/// A read-only, named view over a set of RDF triples.
48///
49/// Conceptually represents a named graph in an RDF dataset.  The view holds an
50/// owned snapshot of the triples (or references to an external store via index).
51#[derive(Debug, Clone)]
52pub struct GraphView {
53    /// Name / IRI of this graph view.
54    pub name: String,
55    triples: Vec<RdfTriple>,
56    /// Optional base IRI for relative IRI resolution.
57    pub base_iri: Option<String>,
58}
59
60impl GraphView {
61    /// Create a new named graph view with the given triples.
62    pub fn new(name: &str, triples: Vec<RdfTriple>) -> Self {
63        Self {
64            name: name.to_string(),
65            triples,
66            base_iri: None,
67        }
68    }
69
70    /// Set the base IRI for this view.
71    pub fn with_base_iri(mut self, base: &str) -> Self {
72        self.base_iri = Some(base.to_string());
73        self
74    }
75
76    /// All triples in this view.
77    pub fn triples(&self) -> &[RdfTriple] {
78        &self.triples
79    }
80
81    /// Number of triples in this view.
82    pub fn len(&self) -> usize {
83        self.triples.len()
84    }
85
86    pub fn is_empty(&self) -> bool {
87        self.triples.is_empty()
88    }
89
90    /// Find all triples matching the given triple pattern.  `None` = wildcard.
91    pub fn find(
92        &self,
93        subject: Option<&str>,
94        predicate: Option<&str>,
95        object: Option<&str>,
96    ) -> Vec<&RdfTriple> {
97        self.triples
98            .iter()
99            .filter(|t| t.matches(subject, predicate, object))
100            .collect()
101    }
102
103    /// Returns `true` if the view contains `triple`.
104    pub fn contains(&self, triple: &RdfTriple) -> bool {
105        self.triples.contains(triple)
106    }
107
108    /// All distinct subjects in this view.
109    pub fn subjects(&self) -> Vec<&str> {
110        let mut v: Vec<&str> = self.triples.iter().map(|t| t.subject.as_str()).collect();
111        v.sort_unstable();
112        v.dedup();
113        v
114    }
115
116    /// All distinct predicates in this view.
117    pub fn predicates(&self) -> Vec<&str> {
118        let mut v: Vec<&str> = self.triples.iter().map(|t| t.predicate.as_str()).collect();
119        v.sort_unstable();
120        v.dedup();
121        v
122    }
123
124    /// All distinct objects in this view.
125    pub fn objects(&self) -> Vec<&str> {
126        let mut v: Vec<&str> = self.triples.iter().map(|t| t.object.as_str()).collect();
127        v.sort_unstable();
128        v.dedup();
129        v
130    }
131
132    /// Create a [`FilteredView`] over this view.
133    pub fn filter(
134        &self,
135        subject: Option<String>,
136        predicate: Option<String>,
137        object: Option<String>,
138    ) -> FilteredView {
139        FilteredView::new(self.clone(), subject, predicate, object)
140    }
141}
142
143// ---------------------------------------------------------------------------
144// FilteredView
145// ---------------------------------------------------------------------------
146
147/// A view that applies subject/predicate/object filters to a base [`GraphView`].
148///
149/// Filters are applied lazily — the underlying triples are not copied until
150/// [`FilteredView::materialize`] is called.
151#[derive(Debug, Clone)]
152pub struct FilteredView {
153    base: GraphView,
154    subject_filter: Option<String>,
155    predicate_filter: Option<String>,
156    object_filter: Option<String>,
157    /// Cached materialized result; `None` if not yet materialized.
158    materialized: Option<Vec<RdfTriple>>,
159}
160
161impl FilteredView {
162    /// Create a new filtered view.  Pass `None` for a wildcard slot.
163    pub fn new(
164        base: GraphView,
165        subject: Option<String>,
166        predicate: Option<String>,
167        object: Option<String>,
168    ) -> Self {
169        Self {
170            base,
171            subject_filter: subject,
172            predicate_filter: predicate,
173            object_filter: object,
174            materialized: None,
175        }
176    }
177
178    /// Evaluate the filter and return matching triples (without caching).
179    pub fn evaluate(&self) -> Vec<&RdfTriple> {
180        self.base.find(
181            self.subject_filter.as_deref(),
182            self.predicate_filter.as_deref(),
183            self.object_filter.as_deref(),
184        )
185    }
186
187    /// Materialize the filtered result into an owned `Vec<RdfTriple>` and cache it.
188    pub fn materialize(&mut self) -> &[RdfTriple] {
189        if self.materialized.is_none() {
190            self.materialized = Some(self.evaluate().into_iter().cloned().collect());
191        }
192        self.materialized.as_deref().unwrap_or(&[])
193    }
194
195    /// Invalidate the cached materialization (e.g. when the base view changes).
196    pub fn invalidate(&mut self) {
197        self.materialized = None;
198    }
199
200    /// Whether the cache is valid.
201    pub fn is_cached(&self) -> bool {
202        self.materialized.is_some()
203    }
204
205    /// Number of matching triples (evaluates without caching).
206    pub fn count(&self) -> usize {
207        self.evaluate().len()
208    }
209
210    /// Name of the underlying graph view.
211    pub fn graph_name(&self) -> &str {
212        &self.base.name
213    }
214
215    /// Add an additional predicate constraint (AND semantics).
216    pub fn and_predicate(mut self, predicate: &str) -> Self {
217        self.predicate_filter = Some(predicate.to_string());
218        self.materialized = None;
219        self
220    }
221
222    /// Add an additional object constraint (AND semantics).
223    pub fn and_object(mut self, object: &str) -> Self {
224        self.object_filter = Some(object.to_string());
225        self.materialized = None;
226        self
227    }
228}
229
230// ---------------------------------------------------------------------------
231// UnionView
232// ---------------------------------------------------------------------------
233
234/// A virtual view that presents the union of multiple named graph views.
235///
236/// Duplicate triples (appearing in more than one source graph) are deduplicated
237/// when `deduplicate` is set to `true`.
238#[derive(Debug, Clone)]
239pub struct UnionView {
240    /// Human-readable name for this union view.
241    pub name: String,
242    graphs: Vec<GraphView>,
243    deduplicate: bool,
244}
245
246impl UnionView {
247    /// Create a new union view over the given graphs.
248    pub fn new(name: &str, graphs: Vec<GraphView>, deduplicate: bool) -> Self {
249        Self {
250            name: name.to_string(),
251            graphs,
252            deduplicate,
253        }
254    }
255
256    /// Add a new graph to the union.
257    pub fn add_graph(&mut self, graph: GraphView) {
258        self.graphs.push(graph);
259    }
260
261    /// Enumerate all triples from all source graphs.
262    ///
263    /// If `deduplicate` is set, duplicate triples across graphs are returned only once.
264    pub fn triples(&self) -> Vec<&RdfTriple> {
265        let mut result: Vec<&RdfTriple> = Vec::new();
266        for g in &self.graphs {
267            for t in g.triples() {
268                if self.deduplicate {
269                    if !result.contains(&t) {
270                        result.push(t);
271                    }
272                } else {
273                    result.push(t);
274                }
275            }
276        }
277        result
278    }
279
280    /// Find triples matching a pattern across all source graphs.
281    pub fn find(
282        &self,
283        subject: Option<&str>,
284        predicate: Option<&str>,
285        object: Option<&str>,
286    ) -> Vec<&RdfTriple> {
287        let mut result: Vec<&RdfTriple> = Vec::new();
288        for g in &self.graphs {
289            for t in g.find(subject, predicate, object) {
290                if self.deduplicate {
291                    if !result.contains(&t) {
292                        result.push(t);
293                    }
294                } else {
295                    result.push(t);
296                }
297            }
298        }
299        result
300    }
301
302    /// Total triple count (may count duplicates if `deduplicate` is false).
303    pub fn len(&self) -> usize {
304        self.triples().len()
305    }
306
307    pub fn is_empty(&self) -> bool {
308        self.graphs.iter().all(|g| g.is_empty())
309    }
310
311    /// Number of source graphs.
312    pub fn graph_count(&self) -> usize {
313        self.graphs.len()
314    }
315
316    /// Names of all source graphs.
317    pub fn graph_names(&self) -> Vec<&str> {
318        self.graphs.iter().map(|g| g.name.as_str()).collect()
319    }
320}
321
322// ---------------------------------------------------------------------------
323// MergedView
324// ---------------------------------------------------------------------------
325
326/// Merges a default graph with one or more named graphs.
327///
328/// The result presents all triples from the default graph plus those from
329/// the named graphs.  The default graph takes priority: if a triple appears
330/// in both the default graph and a named graph, only the default version is
331/// returned when `deduplicate` is true.
332#[derive(Debug, Clone)]
333pub struct MergedView {
334    /// Name for this merged view.
335    pub name: String,
336    default_graph: GraphView,
337    named_graphs: Vec<GraphView>,
338    deduplicate: bool,
339    /// Materialized (cached) triple set.
340    materialized: Option<Vec<RdfTriple>>,
341}
342
343impl MergedView {
344    /// Create a new merged view from a default graph and a set of named graphs.
345    pub fn new(
346        name: &str,
347        default_graph: GraphView,
348        named_graphs: Vec<GraphView>,
349        deduplicate: bool,
350    ) -> Self {
351        Self {
352            name: name.to_string(),
353            default_graph,
354            named_graphs,
355            deduplicate,
356            materialized: None,
357        }
358    }
359
360    /// Add a named graph to the merge.
361    pub fn add_named_graph(&mut self, graph: GraphView) {
362        self.named_graphs.push(graph);
363        self.materialized = None;
364    }
365
366    /// Enumerate all triples.
367    pub fn triples(&self) -> Vec<RdfTriple> {
368        let mut result: Vec<RdfTriple> = self.default_graph.triples().to_vec();
369        for ng in &self.named_graphs {
370            for t in ng.triples() {
371                if self.deduplicate {
372                    if !result.contains(t) {
373                        result.push(t.clone());
374                    }
375                } else {
376                    result.push(t.clone());
377                }
378            }
379        }
380        result
381    }
382
383    /// Materialize the merged result into a cached `Vec<RdfTriple>`.
384    pub fn materialize(&mut self) -> &[RdfTriple] {
385        if self.materialized.is_none() {
386            self.materialized = Some(self.triples());
387        }
388        self.materialized.as_deref().unwrap_or(&[])
389    }
390
391    /// Invalidate the materialization cache.
392    pub fn invalidate(&mut self) {
393        self.materialized = None;
394    }
395
396    /// Find triples matching a pattern across default + named graphs.
397    pub fn find(
398        &self,
399        subject: Option<&str>,
400        predicate: Option<&str>,
401        object: Option<&str>,
402    ) -> Vec<RdfTriple> {
403        self.triples()
404            .into_iter()
405            .filter(|t| t.matches(subject, predicate, object))
406            .collect()
407    }
408
409    /// Total triple count in the merged view.
410    pub fn len(&self) -> usize {
411        self.triples().len()
412    }
413
414    pub fn is_empty(&self) -> bool {
415        self.default_graph.is_empty() && self.named_graphs.iter().all(|g| g.is_empty())
416    }
417
418    /// Summary of how many triples come from each source.
419    pub fn source_summary(&self) -> HashMap<String, usize> {
420        let mut map = HashMap::new();
421        map.insert(self.default_graph.name.clone(), self.default_graph.len());
422        for ng in &self.named_graphs {
423            map.insert(ng.name.clone(), ng.len());
424        }
425        map
426    }
427}
428
429// ---------------------------------------------------------------------------
430// ViewMaterializer
431// ---------------------------------------------------------------------------
432
433/// Caches and manages materialized views by name.
434///
435/// Each view is associated with an optional predicate dependency list.
436/// When a triple with a tracked predicate changes, the affected views
437/// are marked stale and must be re-materialized.
438#[derive(Debug, Default)]
439pub struct ViewMaterializer {
440    graph_views: HashMap<String, GraphView>,
441    filtered_cache: HashMap<String, Vec<RdfTriple>>,
442    predicate_deps: HashMap<String, Vec<String>>, // view_name → [predicates]
443    stale: std::collections::HashSet<String>,
444}
445
446impl ViewMaterializer {
447    pub fn new() -> Self {
448        Self::default()
449    }
450
451    /// Register a `GraphView` under its name.
452    pub fn register_graph_view(&mut self, view: GraphView, predicates: Vec<String>) {
453        let name = view.name.clone();
454        self.predicate_deps.insert(name.clone(), predicates);
455        self.graph_views.insert(name.clone(), view);
456        self.stale.remove(&name);
457    }
458
459    /// Retrieve a registered graph view by name.
460    pub fn get_graph_view(&self, name: &str) -> Option<&GraphView> {
461        self.graph_views.get(name)
462    }
463
464    /// Mark all views that depend on any of the given predicates as stale.
465    pub fn mark_stale_for_predicates(&mut self, changed_predicates: &[String]) -> Vec<String> {
466        let mut stale_views = Vec::new();
467        for (view_name, deps) in &self.predicate_deps {
468            let affected = deps.is_empty() || deps.iter().any(|d| changed_predicates.contains(d));
469            if affected {
470                self.stale.insert(view_name.clone());
471                stale_views.push(view_name.clone());
472            }
473        }
474        stale_views
475    }
476
477    /// Re-materialize a stale view with fresh triple data.
478    pub fn refresh_view(&mut self, name: &str, fresh_triples: Vec<RdfTriple>) {
479        if let Some(view) = self.graph_views.get_mut(name) {
480            *view = GraphView::new(name, fresh_triples.clone());
481        }
482        self.filtered_cache.insert(name.to_string(), fresh_triples);
483        self.stale.remove(name);
484    }
485
486    /// Whether `name` is currently marked stale.
487    pub fn is_stale(&self, name: &str) -> bool {
488        self.stale.contains(name)
489    }
490
491    /// All stale view names.
492    pub fn stale_views(&self) -> Vec<&str> {
493        self.stale.iter().map(|s| s.as_str()).collect()
494    }
495}
496
497// ---------------------------------------------------------------------------
498// Tests
499// ---------------------------------------------------------------------------
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    fn make_triple(s: &str, p: &str, o: &str) -> RdfTriple {
506        RdfTriple::new(s, p, o)
507    }
508
509    fn make_view(name: &str, triples: &[(&str, &str, &str)]) -> GraphView {
510        GraphView::new(
511            name,
512            triples
513                .iter()
514                .map(|(s, p, o)| make_triple(s, p, o))
515                .collect(),
516        )
517    }
518
519    // ---- RdfTriple ----
520
521    #[test]
522    fn test_rdf_triple_matches_wildcard() {
523        let t = make_triple("s", "p", "o");
524        assert!(t.matches(None, None, None));
525    }
526
527    #[test]
528    fn test_rdf_triple_matches_bound_subject() {
529        let t = make_triple("s", "p", "o");
530        assert!(t.matches(Some("s"), None, None));
531        assert!(!t.matches(Some("x"), None, None));
532    }
533
534    #[test]
535    fn test_rdf_triple_matches_all_bound() {
536        let t = make_triple("s", "p", "o");
537        assert!(t.matches(Some("s"), Some("p"), Some("o")));
538        assert!(!t.matches(Some("s"), Some("p"), Some("WRONG")));
539    }
540
541    // ---- GraphView ----
542
543    #[test]
544    fn test_graph_view_empty() {
545        let v = GraphView::new("g", vec![]);
546        assert!(v.is_empty());
547        assert_eq!(v.len(), 0);
548    }
549
550    #[test]
551    fn test_graph_view_triples() {
552        let v = make_view("g", &[("s", "p", "o")]);
553        assert_eq!(v.triples().len(), 1);
554    }
555
556    #[test]
557    fn test_graph_view_find_by_predicate() {
558        let v = make_view("g", &[("alice", "knows", "bob"), ("alice", "age", "30")]);
559        let found = v.find(None, Some("knows"), None);
560        assert_eq!(found.len(), 1);
561        assert_eq!(found[0].object, "bob");
562    }
563
564    #[test]
565    fn test_graph_view_find_no_match() {
566        let v = make_view("g", &[("s", "p", "o")]);
567        let found = v.find(None, Some("noSuchPredicate"), None);
568        assert!(found.is_empty());
569    }
570
571    #[test]
572    fn test_graph_view_contains() {
573        let v = make_view("g", &[("s", "p", "o")]);
574        assert!(v.contains(&make_triple("s", "p", "o")));
575        assert!(!v.contains(&make_triple("s", "p", "X")));
576    }
577
578    #[test]
579    fn test_graph_view_subjects() {
580        let v = make_view(
581            "g",
582            &[("alice", "p", "o"), ("bob", "p", "o"), ("alice", "q", "x")],
583        );
584        let mut subjects = v.subjects();
585        subjects.sort();
586        assert_eq!(subjects, vec!["alice", "bob"]);
587    }
588
589    #[test]
590    fn test_graph_view_predicates() {
591        let v = make_view("g", &[("s", "p1", "o"), ("s", "p2", "o"), ("s", "p1", "x")]);
592        let mut preds = v.predicates();
593        preds.sort();
594        assert_eq!(preds, vec!["p1", "p2"]);
595    }
596
597    #[test]
598    fn test_graph_view_objects() {
599        let v = make_view("g", &[("s", "p", "o1"), ("s", "p", "o2")]);
600        let mut objs = v.objects();
601        objs.sort();
602        assert_eq!(objs, vec!["o1", "o2"]);
603    }
604
605    #[test]
606    fn test_graph_view_with_base_iri() {
607        let v = GraphView::new("g", vec![]).with_base_iri("http://example.org/");
608        assert_eq!(v.base_iri.as_deref(), Some("http://example.org/"));
609    }
610
611    #[test]
612    fn test_graph_view_filter_returns_filtered_view() {
613        let v = make_view("g", &[("s", "p", "o"), ("s", "q", "x")]);
614        let mut fv = v.filter(None, Some("p".to_string()), None);
615        let mats = fv.materialize();
616        assert_eq!(mats.len(), 1);
617        assert_eq!(mats[0].predicate, "p");
618    }
619
620    // ---- FilteredView ----
621
622    #[test]
623    fn test_filtered_view_evaluate_empty() {
624        let v = make_view("g", &[]);
625        let fv = FilteredView::new(v, None, None, None);
626        assert_eq!(fv.evaluate().len(), 0);
627    }
628
629    #[test]
630    fn test_filtered_view_evaluate_subject_filter() {
631        let v = make_view("g", &[("alice", "p", "o"), ("bob", "p", "o")]);
632        let fv = FilteredView::new(v, Some("alice".to_string()), None, None);
633        assert_eq!(fv.count(), 1);
634    }
635
636    #[test]
637    fn test_filtered_view_materialize() {
638        let v = make_view("g", &[("s", "p1", "o"), ("s", "p2", "o")]);
639        let mut fv = FilteredView::new(v, None, Some("p1".to_string()), None);
640        let result = fv.materialize();
641        assert_eq!(result.len(), 1);
642        assert!(fv.is_cached());
643    }
644
645    #[test]
646    fn test_filtered_view_invalidate() {
647        let v = make_view("g", &[("s", "p", "o")]);
648        let mut fv = FilteredView::new(v, None, None, None);
649        fv.materialize();
650        assert!(fv.is_cached());
651        fv.invalidate();
652        assert!(!fv.is_cached());
653    }
654
655    #[test]
656    fn test_filtered_view_graph_name() {
657        let v = make_view("my_graph", &[]);
658        let fv = FilteredView::new(v, None, None, None);
659        assert_eq!(fv.graph_name(), "my_graph");
660    }
661
662    #[test]
663    fn test_filtered_view_and_predicate() {
664        let v = make_view("g", &[("s", "p1", "o"), ("s", "p2", "o")]);
665        let fv = FilteredView::new(v, None, None, None).and_predicate("p1");
666        assert_eq!(fv.count(), 1);
667    }
668
669    #[test]
670    fn test_filtered_view_and_object() {
671        let v = make_view("g", &[("s", "p", "o1"), ("s", "p", "o2")]);
672        let fv = FilteredView::new(v, None, None, None).and_object("o1");
673        assert_eq!(fv.count(), 1);
674    }
675
676    // ---- UnionView ----
677
678    #[test]
679    fn test_union_view_empty_graphs() {
680        let uv = UnionView::new("u", vec![], false);
681        assert!(uv.is_empty());
682        assert_eq!(uv.len(), 0);
683    }
684
685    #[test]
686    fn test_union_view_single_graph() {
687        let g = make_view("g1", &[("s", "p", "o")]);
688        let uv = UnionView::new("u", vec![g], false);
689        assert_eq!(uv.len(), 1);
690    }
691
692    #[test]
693    fn test_union_view_two_graphs_no_dedup() {
694        let g1 = make_view("g1", &[("s", "p", "o")]);
695        let g2 = make_view("g2", &[("s", "p", "o")]);
696        let uv = UnionView::new("u", vec![g1, g2], false);
697        assert_eq!(uv.len(), 2);
698    }
699
700    #[test]
701    fn test_union_view_two_graphs_with_dedup() {
702        let g1 = make_view("g1", &[("s", "p", "o")]);
703        let g2 = make_view("g2", &[("s", "p", "o"), ("s2", "p2", "o2")]);
704        let uv = UnionView::new("u", vec![g1, g2], true);
705        assert_eq!(uv.len(), 2); // ("s","p","o") deduplicated; ("s2","p2","o2") unique
706    }
707
708    #[test]
709    fn test_union_view_find() {
710        let g1 = make_view("g1", &[("alice", "knows", "bob")]);
711        let g2 = make_view("g2", &[("carol", "knows", "dave")]);
712        let uv = UnionView::new("u", vec![g1, g2], false);
713        let found = uv.find(None, Some("knows"), None);
714        assert_eq!(found.len(), 2);
715    }
716
717    #[test]
718    fn test_union_view_add_graph() {
719        let mut uv = UnionView::new("u", vec![], false);
720        assert_eq!(uv.graph_count(), 0);
721        uv.add_graph(make_view("g1", &[("s", "p", "o")]));
722        assert_eq!(uv.graph_count(), 1);
723    }
724
725    #[test]
726    fn test_union_view_graph_names() {
727        let g1 = make_view("graph_a", &[]);
728        let g2 = make_view("graph_b", &[]);
729        let uv = UnionView::new("u", vec![g1, g2], false);
730        let mut names = uv.graph_names();
731        names.sort();
732        assert_eq!(names, vec!["graph_a", "graph_b"]);
733    }
734
735    // ---- MergedView ----
736
737    #[test]
738    fn test_merged_view_empty() {
739        let default_g = GraphView::new("default", vec![]);
740        let mv = MergedView::new("m", default_g, vec![], false);
741        assert!(mv.is_empty());
742        assert_eq!(mv.len(), 0);
743    }
744
745    #[test]
746    fn test_merged_view_default_only() {
747        let default_g = make_view("default", &[("s", "p", "o")]);
748        let mv = MergedView::new("m", default_g, vec![], false);
749        assert_eq!(mv.len(), 1);
750    }
751
752    #[test]
753    fn test_merged_view_named_graphs() {
754        let default_g = make_view("default", &[("s1", "p", "o1")]);
755        let ng = make_view("named", &[("s2", "p", "o2")]);
756        let mv = MergedView::new("m", default_g, vec![ng], false);
757        assert_eq!(mv.len(), 2);
758    }
759
760    #[test]
761    fn test_merged_view_dedup() {
762        let default_g = make_view("default", &[("s", "p", "o")]);
763        let ng = make_view("named", &[("s", "p", "o"), ("s2", "p2", "o2")]);
764        let mv = MergedView::new("m", default_g, vec![ng], true);
765        assert_eq!(mv.len(), 2); // ("s","p","o") deduped; ("s2","p2","o2") unique
766    }
767
768    #[test]
769    fn test_merged_view_find() {
770        let default_g = make_view("default", &[("alice", "type", "Person")]);
771        let ng = make_view("named", &[("bob", "type", "Animal")]);
772        let mv = MergedView::new("m", default_g, vec![ng], false);
773        let found = mv.find(None, Some("type"), None);
774        assert_eq!(found.len(), 2);
775    }
776
777    #[test]
778    fn test_merged_view_materialize() {
779        let default_g = make_view("default", &[("s", "p", "o")]);
780        let mut mv = MergedView::new("m", default_g, vec![], false);
781        let mats = mv.materialize();
782        assert_eq!(mats.len(), 1);
783    }
784
785    #[test]
786    fn test_merged_view_invalidate() {
787        let default_g = make_view("default", &[("s", "p", "o")]);
788        let mut mv = MergedView::new("m", default_g, vec![], false);
789        mv.materialize();
790        mv.invalidate();
791        // Re-materialize should still work
792        let mats = mv.materialize();
793        assert_eq!(mats.len(), 1);
794    }
795
796    #[test]
797    fn test_merged_view_source_summary() {
798        let default_g = make_view("default", &[("s", "p", "o")]);
799        let ng = make_view("ng1", &[("s2", "p2", "o2"), ("s3", "p3", "o3")]);
800        let mv = MergedView::new("m", default_g, vec![ng], false);
801        let summary = mv.source_summary();
802        assert_eq!(*summary.get("default").expect("key should exist"), 1);
803        assert_eq!(*summary.get("ng1").expect("key should exist"), 2);
804    }
805
806    #[test]
807    fn test_merged_view_add_named_graph() {
808        let default_g = make_view("default", &[("s", "p", "o")]);
809        let mut mv = MergedView::new("m", default_g, vec![], false);
810        assert_eq!(mv.len(), 1);
811        mv.add_named_graph(make_view("extra", &[("s2", "p2", "o2")]));
812        assert_eq!(mv.len(), 2);
813    }
814
815    // ---- ViewMaterializer ----
816
817    #[test]
818    fn test_view_materializer_empty() {
819        let vm = ViewMaterializer::new();
820        assert!(vm.stale_views().is_empty());
821    }
822
823    #[test]
824    fn test_view_materializer_register_and_get() {
825        let mut vm = ViewMaterializer::new();
826        let view = make_view("v1", &[("s", "p", "o")]);
827        vm.register_graph_view(view, vec!["p".to_string()]);
828        assert!(vm.get_graph_view("v1").is_some());
829    }
830
831    #[test]
832    fn test_view_materializer_mark_stale() {
833        let mut vm = ViewMaterializer::new();
834        let view = make_view("v1", &[]);
835        vm.register_graph_view(view, vec!["http://p/age".to_string()]);
836        let stale = vm.mark_stale_for_predicates(&["http://p/age".to_string()]);
837        assert_eq!(stale.len(), 1);
838        assert!(vm.is_stale("v1"));
839    }
840
841    #[test]
842    fn test_view_materializer_no_stale_on_unrelated_predicate() {
843        let mut vm = ViewMaterializer::new();
844        let view = make_view("v1", &[]);
845        vm.register_graph_view(view, vec!["http://p/name".to_string()]);
846        let stale = vm.mark_stale_for_predicates(&["http://p/age".to_string()]);
847        assert!(stale.is_empty());
848        assert!(!vm.is_stale("v1"));
849    }
850
851    #[test]
852    fn test_view_materializer_refresh_clears_stale() {
853        let mut vm = ViewMaterializer::new();
854        let view = make_view("v1", &[]);
855        vm.register_graph_view(view, vec!["p".to_string()]);
856        vm.mark_stale_for_predicates(&["p".to_string()]);
857        assert!(vm.is_stale("v1"));
858        vm.refresh_view("v1", vec![make_triple("s", "p", "o")]);
859        assert!(!vm.is_stale("v1"));
860    }
861
862    #[test]
863    fn test_view_materializer_empty_deps_always_stale() {
864        let mut vm = ViewMaterializer::new();
865        let view = make_view("v_all", &[]);
866        vm.register_graph_view(view, vec![]); // no deps → any change stales it
867        let stale = vm.mark_stale_for_predicates(&["any_predicate".to_string()]);
868        assert!(stale.contains(&"v_all".to_string()));
869    }
870}