Skip to main content

grafeo_core/graph/
projection.rs

1//! Graph projections: read-only, filtered views of a graph store.
2//!
3//! A [`GraphProjection`] wraps an existing [`GraphStore`] and presents a
4//! subgraph defined by a [`ProjectionSpec`]. Only nodes with matching labels
5//! and edges with matching types (whose endpoints are both in the projection)
6//! are visible. Everything else is filtered out transparently.
7//!
8//! Projections are read-only: they implement [`GraphStore`] but not
9//! [`super::GraphStoreMut`].
10//!
11//! # Example
12//!
13//! ```ignore
14//! let spec = ProjectionSpec::new()
15//!     .with_node_labels(["Person", "City"])
16//!     .with_edge_types(["LIVES_IN"]);
17//! let projected = GraphProjection::new(store, spec);
18//! // Only Person/City nodes and LIVES_IN edges are visible
19//! ```
20
21use std::collections::HashSet;
22use std::sync::Arc;
23
24use arcstr::ArcStr;
25use grafeo_common::types::{EdgeId, EpochId, NodeId, PropertyKey, TransactionId, Value};
26use grafeo_common::utils::hash::FxHashMap;
27
28use super::Direction;
29use super::lpg::{CompareOp, Edge, Node};
30use super::traits::GraphStore;
31use crate::statistics::Statistics;
32
33/// Defines which nodes and edges are included in a projection.
34#[derive(Debug, Clone, Default)]
35pub struct ProjectionSpec {
36    /// Node labels to include. Empty means all nodes.
37    node_labels: HashSet<String>,
38    /// Edge types to include. Empty means all edges.
39    edge_types: HashSet<String>,
40}
41
42impl ProjectionSpec {
43    /// Creates an empty spec (all nodes, all edges).
44    #[must_use]
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Restricts the projection to nodes with any of these labels.
50    #[must_use]
51    pub fn with_node_labels(mut self, labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
52        self.node_labels = labels.into_iter().map(Into::into).collect();
53        self
54    }
55
56    /// Restricts the projection to edges with any of these types.
57    #[must_use]
58    pub fn with_edge_types(mut self, types: impl IntoIterator<Item = impl Into<String>>) -> Self {
59        self.edge_types = types.into_iter().map(Into::into).collect();
60        self
61    }
62
63    /// Returns true if node labels are filtered.
64    fn filters_labels(&self) -> bool {
65        !self.node_labels.is_empty()
66    }
67
68    /// Returns true if edge types are filtered.
69    fn filters_edge_types(&self) -> bool {
70        !self.edge_types.is_empty()
71    }
72}
73
74/// A read-only, filtered view of a graph store.
75///
76/// Delegates all reads to the inner store, filtering results by the
77/// [`ProjectionSpec`]. Nodes without matching labels and edges without
78/// matching types are invisible.
79pub struct GraphProjection {
80    inner: Arc<dyn GraphStore>,
81    spec: ProjectionSpec,
82}
83
84impl GraphProjection {
85    /// Creates a new projection over the given store.
86    pub fn new(inner: Arc<dyn GraphStore>, spec: ProjectionSpec) -> Self {
87        Self { inner, spec }
88    }
89
90    /// Returns true if a node passes the label filter.
91    fn node_matches(&self, node: &Node) -> bool {
92        if !self.spec.filters_labels() {
93            return true;
94        }
95        node.labels
96            .iter()
97            .any(|l| self.spec.node_labels.contains(l.as_str()))
98    }
99
100    /// Returns true if a node ID passes the label filter.
101    fn node_id_matches(&self, id: NodeId) -> bool {
102        if !self.spec.filters_labels() {
103            return true;
104        }
105        self.inner
106            .get_node(id)
107            .is_some_and(|n| self.node_matches(&n))
108    }
109
110    /// Returns true if an edge type passes the type filter.
111    fn edge_type_matches(&self, edge_type: &str) -> bool {
112        if !self.spec.filters_edge_types() {
113            return true;
114        }
115        self.spec.edge_types.contains(edge_type)
116    }
117
118    /// Returns true if an edge passes both endpoint and type filters.
119    fn edge_matches(&self, edge: &Edge) -> bool {
120        if !self.edge_type_matches(&edge.edge_type) {
121            return false;
122        }
123        self.node_id_matches(edge.src) && self.node_id_matches(edge.dst)
124    }
125}
126
127impl GraphStore for GraphProjection {
128    // --- Point lookups ---
129
130    fn get_node(&self, id: NodeId) -> Option<Node> {
131        self.inner.get_node(id).filter(|n| self.node_matches(n))
132    }
133
134    fn get_edge(&self, id: EdgeId) -> Option<Edge> {
135        self.inner.get_edge(id).filter(|e| self.edge_matches(e))
136    }
137
138    fn get_node_versioned(
139        &self,
140        id: NodeId,
141        epoch: EpochId,
142        transaction_id: TransactionId,
143    ) -> Option<Node> {
144        self.inner
145            .get_node_versioned(id, epoch, transaction_id)
146            .filter(|n| self.node_matches(n))
147    }
148
149    /// Returns a versioned edge if it passes projection filters.
150    ///
151    /// **Limitation**: `edge_matches` checks endpoint visibility via `get_node`
152    /// (current snapshot), not `get_node_versioned`, because `GraphProjection`
153    /// does not store epoch/transaction context. This means endpoint filtering
154    /// may reflect the current state rather than the requested version.
155    fn get_edge_versioned(
156        &self,
157        id: EdgeId,
158        epoch: EpochId,
159        transaction_id: TransactionId,
160    ) -> Option<Edge> {
161        self.inner
162            .get_edge_versioned(id, epoch, transaction_id)
163            .filter(|e| self.edge_matches(e))
164    }
165
166    fn get_node_at_epoch(&self, id: NodeId, epoch: EpochId) -> Option<Node> {
167        self.inner
168            .get_node_at_epoch(id, epoch)
169            .filter(|n| self.node_matches(n))
170    }
171
172    fn get_edge_at_epoch(&self, id: EdgeId, epoch: EpochId) -> Option<Edge> {
173        self.inner
174            .get_edge_at_epoch(id, epoch)
175            .filter(|e| self.edge_matches(e))
176    }
177
178    // --- Property access ---
179
180    fn get_node_property(&self, id: NodeId, key: &PropertyKey) -> Option<Value> {
181        if !self.node_id_matches(id) {
182            return None;
183        }
184        self.inner.get_node_property(id, key)
185    }
186
187    fn get_edge_property(&self, id: EdgeId, key: &PropertyKey) -> Option<Value> {
188        self.inner
189            .get_edge(id)
190            .filter(|e| self.edge_matches(e))
191            .and_then(|_| self.inner.get_edge_property(id, key))
192    }
193
194    fn get_node_property_batch(&self, ids: &[NodeId], key: &PropertyKey) -> Vec<Option<Value>> {
195        let filtered: Vec<_> = ids
196            .iter()
197            .map(|&id| {
198                if self.node_id_matches(id) {
199                    self.inner.get_node_property(id, key)
200                } else {
201                    None
202                }
203            })
204            .collect();
205        filtered
206    }
207
208    fn get_nodes_properties_batch(&self, ids: &[NodeId]) -> Vec<FxHashMap<PropertyKey, Value>> {
209        ids.iter()
210            .map(|&id| {
211                if self.node_id_matches(id) {
212                    self.inner
213                        .get_nodes_properties_batch(std::slice::from_ref(&id))
214                        .into_iter()
215                        .next()
216                        .unwrap_or_default()
217                } else {
218                    FxHashMap::default()
219                }
220            })
221            .collect()
222    }
223
224    fn get_nodes_properties_selective_batch(
225        &self,
226        ids: &[NodeId],
227        keys: &[PropertyKey],
228    ) -> Vec<FxHashMap<PropertyKey, Value>> {
229        ids.iter()
230            .map(|&id| {
231                if self.node_id_matches(id) {
232                    self.inner
233                        .get_nodes_properties_selective_batch(std::slice::from_ref(&id), keys)
234                        .into_iter()
235                        .next()
236                        .unwrap_or_default()
237                } else {
238                    FxHashMap::default()
239                }
240            })
241            .collect()
242    }
243
244    fn get_edges_properties_selective_batch(
245        &self,
246        ids: &[EdgeId],
247        keys: &[PropertyKey],
248    ) -> Vec<FxHashMap<PropertyKey, Value>> {
249        ids.iter()
250            .map(|&id| {
251                if self.get_edge(id).is_some() {
252                    self.inner
253                        .get_edges_properties_selective_batch(std::slice::from_ref(&id), keys)
254                        .into_iter()
255                        .next()
256                        .unwrap_or_default()
257                } else {
258                    FxHashMap::default()
259                }
260            })
261            .collect()
262    }
263
264    // --- Traversal ---
265
266    fn neighbors(&self, node: NodeId, direction: Direction) -> Vec<NodeId> {
267        if !self.node_id_matches(node) {
268            return Vec::new();
269        }
270        // Use edges_from (which filters by edge type and endpoint visibility)
271        // and extract the target node IDs, so neighbors connected only via
272        // excluded edge types are not returned.
273        self.edges_from(node, direction)
274            .into_iter()
275            .map(|(target, _)| target)
276            .collect()
277    }
278
279    fn edges_from(&self, node: NodeId, direction: Direction) -> Vec<(NodeId, EdgeId)> {
280        if !self.node_id_matches(node) {
281            return Vec::new();
282        }
283        self.inner
284            .edges_from(node, direction)
285            .into_iter()
286            .filter(|&(target, edge_id)| {
287                self.node_id_matches(target)
288                    && self
289                        .inner
290                        .edge_type(edge_id)
291                        .is_some_and(|t| self.edge_type_matches(&t))
292            })
293            .collect()
294    }
295
296    fn out_degree(&self, node: NodeId) -> usize {
297        self.edges_from(node, Direction::Outgoing).len()
298    }
299
300    fn in_degree(&self, node: NodeId) -> usize {
301        self.edges_from(node, Direction::Incoming).len()
302    }
303
304    fn has_backward_adjacency(&self) -> bool {
305        self.inner.has_backward_adjacency()
306    }
307
308    // --- Scans ---
309
310    fn node_ids(&self) -> Vec<NodeId> {
311        if !self.spec.filters_labels() {
312            return self.inner.node_ids();
313        }
314        self.inner
315            .node_ids()
316            .into_iter()
317            .filter(|&id| self.node_id_matches(id))
318            .collect()
319    }
320
321    fn all_node_ids(&self) -> Vec<NodeId> {
322        if !self.spec.filters_labels() {
323            return self.inner.all_node_ids();
324        }
325        self.inner
326            .all_node_ids()
327            .into_iter()
328            .filter(|&id| self.node_id_matches(id))
329            .collect()
330    }
331
332    fn nodes_by_label(&self, label: &str) -> Vec<NodeId> {
333        if self.spec.filters_labels() && !self.spec.node_labels.contains(label) {
334            return Vec::new();
335        }
336        self.inner.nodes_by_label(label)
337    }
338
339    fn node_count(&self) -> usize {
340        self.node_ids().len()
341    }
342
343    fn edge_count(&self) -> usize {
344        // Approximate: count edges whose type is in the spec
345        if !self.spec.filters_edge_types() && !self.spec.filters_labels() {
346            return self.inner.edge_count();
347        }
348        // Fallback: scan all nodes and count projected edges
349        self.node_ids().iter().map(|&id| self.out_degree(id)).sum()
350    }
351
352    // --- Entity metadata ---
353
354    fn edge_type(&self, id: EdgeId) -> Option<ArcStr> {
355        // Check type filter first (cheap: no property loading)
356        let et = self.inner.edge_type(id)?;
357        if !self.edge_type_matches(&et) {
358            return None;
359        }
360        // Check endpoint visibility only if labels are filtered
361        if self.spec.filters_labels() {
362            let edge = self.inner.get_edge(id)?;
363            if !self.node_id_matches(edge.src) || !self.node_id_matches(edge.dst) {
364                return None;
365            }
366        }
367        Some(et)
368    }
369
370    /// Returns the type of a versioned edge if it passes projection filters.
371    ///
372    /// **Limitation**: endpoint visibility is checked via `get_node` (current
373    /// snapshot), not `get_node_versioned`. See `get_edge_versioned` for details.
374    fn edge_type_versioned(
375        &self,
376        id: EdgeId,
377        epoch: EpochId,
378        transaction_id: TransactionId,
379    ) -> Option<ArcStr> {
380        let et = self.inner.edge_type_versioned(id, epoch, transaction_id)?;
381        if !self.edge_type_matches(&et) {
382            return None;
383        }
384        if self.spec.filters_labels() {
385            let edge = self.inner.get_edge_versioned(id, epoch, transaction_id)?;
386            if !self.node_id_matches(edge.src) || !self.node_id_matches(edge.dst) {
387                return None;
388            }
389        }
390        Some(et)
391    }
392
393    // --- Index introspection ---
394
395    fn has_property_index(&self, property: &str) -> bool {
396        self.inner.has_property_index(property)
397    }
398
399    // --- Filtered search ---
400
401    fn find_nodes_by_property(&self, property: &str, value: &Value) -> Vec<NodeId> {
402        self.inner
403            .find_nodes_by_property(property, value)
404            .into_iter()
405            .filter(|&id| self.node_id_matches(id))
406            .collect()
407    }
408
409    fn find_nodes_by_properties(&self, conditions: &[(&str, Value)]) -> Vec<NodeId> {
410        self.inner
411            .find_nodes_by_properties(conditions)
412            .into_iter()
413            .filter(|&id| self.node_id_matches(id))
414            .collect()
415    }
416
417    fn find_nodes_in_range(
418        &self,
419        property: &str,
420        min: Option<&Value>,
421        max: Option<&Value>,
422        min_inclusive: bool,
423        max_inclusive: bool,
424    ) -> Vec<NodeId> {
425        self.inner
426            .find_nodes_in_range(property, min, max, min_inclusive, max_inclusive)
427            .into_iter()
428            .filter(|&id| self.node_id_matches(id))
429            .collect()
430    }
431
432    // --- Zone maps ---
433
434    fn node_property_might_match(
435        &self,
436        property: &PropertyKey,
437        op: CompareOp,
438        value: &Value,
439    ) -> bool {
440        self.inner.node_property_might_match(property, op, value)
441    }
442
443    fn edge_property_might_match(
444        &self,
445        property: &PropertyKey,
446        op: CompareOp,
447        value: &Value,
448    ) -> bool {
449        self.inner.edge_property_might_match(property, op, value)
450    }
451
452    // --- Statistics ---
453
454    fn statistics(&self) -> Arc<Statistics> {
455        self.inner.statistics()
456    }
457
458    fn estimate_label_cardinality(&self, label: &str) -> f64 {
459        if self.spec.filters_labels() && !self.spec.node_labels.contains(label) {
460            return 0.0;
461        }
462        self.inner.estimate_label_cardinality(label)
463    }
464
465    fn estimate_avg_degree(&self, edge_type: &str, outgoing: bool) -> f64 {
466        if self.spec.filters_edge_types() && !self.spec.edge_types.contains(edge_type) {
467            return 0.0;
468        }
469        self.inner.estimate_avg_degree(edge_type, outgoing)
470    }
471
472    // --- Epoch ---
473
474    fn current_epoch(&self) -> EpochId {
475        self.inner.current_epoch()
476    }
477
478    // --- Schema introspection ---
479
480    fn all_labels(&self) -> Vec<String> {
481        if self.spec.filters_labels() {
482            self.spec.node_labels.iter().cloned().collect()
483        } else {
484            self.inner.all_labels()
485        }
486    }
487
488    fn all_edge_types(&self) -> Vec<String> {
489        if self.spec.filters_edge_types() {
490            self.spec.edge_types.iter().cloned().collect()
491        } else {
492            self.inner.all_edge_types()
493        }
494    }
495
496    fn all_property_keys(&self) -> Vec<String> {
497        self.inner.all_property_keys()
498    }
499}
500
501#[cfg(test)]
502#[cfg(feature = "lpg")]
503mod tests {
504    use super::*;
505    use crate::graph::lpg::LpgStore;
506
507    fn setup_social_graph() -> Arc<LpgStore> {
508        let store = Arc::new(LpgStore::new().unwrap());
509        let alix = store.create_node(&["Person"]);
510        let gus = store.create_node(&["Person"]);
511        let amsterdam = store.create_node(&["City"]);
512        let grafeo = store.create_node(&["Software"]);
513
514        store.set_node_property(alix, "name", Value::from("Alix"));
515        store.set_node_property(gus, "name", Value::from("Gus"));
516        store.set_node_property(amsterdam, "name", Value::from("Amsterdam"));
517        store.set_node_property(grafeo, "name", Value::from("Grafeo"));
518
519        store.create_edge(alix, gus, "KNOWS");
520        store.create_edge(alix, amsterdam, "LIVES_IN");
521        store.create_edge(gus, amsterdam, "LIVES_IN");
522        store.create_edge(alix, grafeo, "CONTRIBUTES_TO");
523
524        store
525    }
526
527    #[test]
528    fn unfiltered_projection_sees_everything() {
529        let store = setup_social_graph();
530        let proj = GraphProjection::new(store.clone(), ProjectionSpec::new());
531        assert_eq!(proj.node_count(), store.node_count());
532        assert_eq!(proj.edge_count(), store.edge_count());
533    }
534
535    #[test]
536    fn filter_by_label() {
537        let store = setup_social_graph();
538        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
539        let proj = GraphProjection::new(store, spec);
540
541        assert_eq!(proj.node_count(), 2);
542        assert_eq!(proj.nodes_by_label("Person").len(), 2);
543        assert!(proj.nodes_by_label("City").is_empty());
544        assert!(proj.nodes_by_label("Software").is_empty());
545    }
546
547    #[test]
548    fn filter_by_edge_type() {
549        let store = setup_social_graph();
550        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
551        let proj = GraphProjection::new(store, spec);
552
553        // All nodes visible (no label filter), but only KNOWS edges
554        assert_eq!(proj.node_count(), 4);
555        assert_eq!(proj.edge_count(), 1);
556    }
557
558    #[test]
559    fn combined_label_and_edge_filter() {
560        let store = setup_social_graph();
561        let spec = ProjectionSpec::new()
562            .with_node_labels(["Person", "City"])
563            .with_edge_types(["LIVES_IN"]);
564        let proj = GraphProjection::new(store, spec);
565
566        assert_eq!(proj.node_count(), 3); // 2 Person + 1 City
567        assert_eq!(proj.edge_count(), 2); // 2 LIVES_IN edges
568    }
569
570    #[test]
571    fn edge_excluded_when_endpoint_excluded() {
572        let store = setup_social_graph();
573        // Only Person nodes, but LIVES_IN edge type
574        // LIVES_IN goes Person -> City, but City is excluded
575        let spec = ProjectionSpec::new()
576            .with_node_labels(["Person"])
577            .with_edge_types(["LIVES_IN"]);
578        let proj = GraphProjection::new(store, spec);
579
580        assert_eq!(proj.node_count(), 2);
581        // LIVES_IN edges should be excluded because City endpoints are filtered out
582        assert_eq!(proj.edge_count(), 0);
583    }
584
585    #[test]
586    fn get_node_filtered() {
587        let store = setup_social_graph();
588        let all_ids = store.node_ids();
589        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
590        let proj = GraphProjection::new(store.clone(), spec);
591
592        // Person nodes visible
593        assert!(proj.get_node(all_ids[0]).is_some()); // Alix (Person)
594        assert!(proj.get_node(all_ids[1]).is_some()); // Gus (Person)
595        // City and Software nodes hidden
596        assert!(proj.get_node(all_ids[2]).is_none()); // Amsterdam (City)
597        assert!(proj.get_node(all_ids[3]).is_none()); // Grafeo (Software)
598    }
599
600    #[test]
601    fn neighbors_filtered() {
602        let store = setup_social_graph();
603        let alix_id = store.node_ids()[0];
604
605        // Without projection: Alix has 3 outgoing neighbors (Gus, Amsterdam, Grafeo)
606        let all_neighbors: Vec<_> = store.neighbors(alix_id, Direction::Outgoing).collect();
607        assert_eq!(all_neighbors.len(), 3);
608
609        // With Person-only projection: Alix -> Gus only
610        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
611        let proj = GraphProjection::new(store, spec);
612        let neighbors = proj.neighbors(alix_id, Direction::Outgoing);
613        assert_eq!(neighbors.len(), 1);
614    }
615
616    #[test]
617    fn neighbors_filtered_by_edge_type() {
618        let store = setup_social_graph();
619        let alix_id = store.node_ids()[0];
620
621        // With edge-type filter: only KNOWS edges visible
622        // Alix KNOWS Gus, but LIVES_IN Amsterdam and CONTRIBUTES_TO Grafeo are excluded
623        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
624        let proj = GraphProjection::new(store, spec);
625        let neighbors = proj.neighbors(alix_id, Direction::Outgoing);
626        assert_eq!(neighbors.len(), 1);
627    }
628
629    #[test]
630    fn property_access_respects_filter() {
631        let store = setup_social_graph();
632        let city_id = store.node_ids()[2]; // Amsterdam
633        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
634        let proj = GraphProjection::new(store, spec);
635
636        // City node properties are inaccessible
637        assert!(
638            proj.get_node_property(city_id, &PropertyKey::from("name"))
639                .is_none()
640        );
641    }
642
643    #[test]
644    fn cardinality_estimation_respects_filter() {
645        let store = setup_social_graph();
646        let spec = ProjectionSpec::new()
647            .with_node_labels(["Person"])
648            .with_edge_types(["KNOWS"]);
649        let proj = GraphProjection::new(store, spec);
650
651        assert!(proj.estimate_label_cardinality("City") == 0.0);
652        assert!(proj.estimate_avg_degree("LIVES_IN", true) == 0.0);
653    }
654
655    #[test]
656    fn schema_introspection_reflects_filter() {
657        let store = setup_social_graph();
658        let spec = ProjectionSpec::new()
659            .with_node_labels(["Person"])
660            .with_edge_types(["KNOWS"]);
661        let proj = GraphProjection::new(store, spec);
662
663        let labels = proj.all_labels();
664        assert_eq!(labels.len(), 1);
665        assert!(labels.contains(&"Person".to_string()));
666
667        let edge_types = proj.all_edge_types();
668        assert_eq!(edge_types.len(), 1);
669        assert!(edge_types.contains(&"KNOWS".to_string()));
670    }
671
672    /// Helper: returns (node_ids, edge_ids) from the social graph.
673    fn setup_social_graph_with_ids() -> (Arc<LpgStore>, Vec<NodeId>, Vec<EdgeId>) {
674        let store = Arc::new(LpgStore::new().unwrap());
675        let alix = store.create_node(&["Person"]);
676        let gus = store.create_node(&["Person"]);
677        let amsterdam = store.create_node(&["City"]);
678        let grafeo = store.create_node(&["Software"]);
679
680        store.set_node_property(alix, "name", Value::from("Alix"));
681        store.set_node_property(gus, "name", Value::from("Gus"));
682        store.set_node_property(amsterdam, "name", Value::from("Amsterdam"));
683        store.set_node_property(grafeo, "name", Value::from("Grafeo"));
684        store.set_node_property(alix, "age", Value::from(30));
685        store.set_node_property(gus, "age", Value::from(25));
686
687        let e_knows = store.create_edge(alix, gus, "KNOWS");
688        let e_alix_lives = store.create_edge(alix, amsterdam, "LIVES_IN");
689        let e_gus_lives = store.create_edge(gus, amsterdam, "LIVES_IN");
690        let e_contrib = store.create_edge(alix, grafeo, "CONTRIBUTES_TO");
691
692        store.set_edge_property(e_knows, "since", Value::from(2020));
693        store.set_edge_property(e_alix_lives, "since", Value::from(2018));
694
695        let nodes = vec![alix, gus, amsterdam, grafeo];
696        let edges = vec![e_knows, e_alix_lives, e_gus_lives, e_contrib];
697        (store, nodes, edges)
698    }
699
700    // 1. get_edge with edge that passes/fails type filter
701
702    #[test]
703    fn get_edge_passes_type_filter() {
704        let (store, _, edges) = setup_social_graph_with_ids();
705        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
706        let proj = GraphProjection::new(store, spec);
707
708        // KNOWS edge passes filter
709        assert!(proj.get_edge(edges[0]).is_some());
710        // LIVES_IN edge does not pass filter
711        assert!(proj.get_edge(edges[1]).is_none());
712        // CONTRIBUTES_TO edge does not pass filter
713        assert!(proj.get_edge(edges[3]).is_none());
714    }
715
716    #[test]
717    fn get_edge_excluded_by_endpoint_label_filter() {
718        let (store, _, edges) = setup_social_graph_with_ids();
719        // Only Person nodes, LIVES_IN goes Person->City, so excluded
720        let spec = ProjectionSpec::new()
721            .with_node_labels(["Person"])
722            .with_edge_types(["LIVES_IN"]);
723        let proj = GraphProjection::new(store, spec);
724
725        assert!(proj.get_edge(edges[1]).is_none()); // Alix->Amsterdam
726        assert!(proj.get_edge(edges[2]).is_none()); // Gus->Amsterdam
727    }
728
729    // 2. get_node_versioned and get_edge_versioned
730
731    #[test]
732    fn get_node_versioned_respects_filter() {
733        let (store, nodes, _) = setup_social_graph_with_ids();
734        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
735        let proj = GraphProjection::new(store, spec);
736
737        let epoch = EpochId(0);
738        let txn = TransactionId(0);
739
740        // Person node visible
741        assert!(proj.get_node_versioned(nodes[0], epoch, txn).is_some());
742        // City node filtered out
743        assert!(proj.get_node_versioned(nodes[2], epoch, txn).is_none());
744    }
745
746    #[test]
747    fn get_edge_versioned_respects_filter() {
748        let (store, _, edges) = setup_social_graph_with_ids();
749        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
750        let proj = GraphProjection::new(store, spec);
751
752        let epoch = EpochId(0);
753        let txn = TransactionId(0);
754
755        // KNOWS edge visible
756        assert!(proj.get_edge_versioned(edges[0], epoch, txn).is_some());
757        // LIVES_IN edge filtered out
758        assert!(proj.get_edge_versioned(edges[1], epoch, txn).is_none());
759    }
760
761    // 3. get_node_at_epoch and get_edge_at_epoch
762
763    #[test]
764    fn get_node_at_epoch_respects_filter() {
765        let (store, nodes, _) = setup_social_graph_with_ids();
766        let spec = ProjectionSpec::new().with_node_labels(["City"]);
767        let proj = GraphProjection::new(store, spec);
768
769        let epoch = EpochId(0);
770
771        // Amsterdam (City) visible
772        assert!(proj.get_node_at_epoch(nodes[2], epoch).is_some());
773        // Alix (Person) filtered out
774        assert!(proj.get_node_at_epoch(nodes[0], epoch).is_none());
775    }
776
777    #[test]
778    fn get_edge_at_epoch_respects_filter() {
779        let (store, _, edges) = setup_social_graph_with_ids();
780        let spec = ProjectionSpec::new().with_edge_types(["LIVES_IN"]);
781        let proj = GraphProjection::new(store, spec);
782
783        let epoch = EpochId(0);
784
785        // LIVES_IN edge visible
786        assert!(proj.get_edge_at_epoch(edges[1], epoch).is_some());
787        // KNOWS edge filtered out
788        assert!(proj.get_edge_at_epoch(edges[0], epoch).is_none());
789    }
790
791    // 4. get_edge_property for edges in/out of projection
792
793    #[test]
794    fn get_edge_property_in_projection() {
795        let (store, _, edges) = setup_social_graph_with_ids();
796        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
797        let proj = GraphProjection::new(store, spec);
798
799        let key = PropertyKey::from("since");
800        // KNOWS edge has "since" property and passes filter
801        assert_eq!(
802            proj.get_edge_property(edges[0], &key),
803            Some(Value::from(2020))
804        );
805    }
806
807    #[test]
808    fn get_edge_property_outside_projection() {
809        let (store, _, edges) = setup_social_graph_with_ids();
810        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
811        let proj = GraphProjection::new(store, spec);
812
813        let key = PropertyKey::from("since");
814        // LIVES_IN edge has "since" but is filtered out
815        assert!(proj.get_edge_property(edges[1], &key).is_none());
816    }
817
818    // 5. get_node_property_batch for mixed in/out of projection nodes
819
820    #[test]
821    fn get_node_property_batch_mixed() {
822        let (store, nodes, _) = setup_social_graph_with_ids();
823        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
824        let proj = GraphProjection::new(store, spec);
825
826        let key = PropertyKey::from("name");
827        // Alix (Person), Amsterdam (City), Gus (Person)
828        let ids = vec![nodes[0], nodes[2], nodes[1]];
829        let results = proj.get_node_property_batch(&ids, &key);
830
831        assert_eq!(results.len(), 3);
832        assert_eq!(results[0], Some(Value::from("Alix"))); // Person: visible
833        assert_eq!(results[1], None); // City: filtered out
834        assert_eq!(results[2], Some(Value::from("Gus"))); // Person: visible
835    }
836
837    // 6. get_nodes_properties_batch and selective batch
838
839    #[test]
840    fn get_nodes_properties_batch_filters() {
841        let (store, nodes, _) = setup_social_graph_with_ids();
842        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
843        let proj = GraphProjection::new(store, spec);
844
845        let ids = vec![nodes[0], nodes[2]]; // Alix (Person), Amsterdam (City)
846        let results = proj.get_nodes_properties_batch(&ids);
847
848        assert_eq!(results.len(), 2);
849        // Alix has properties
850        assert!(results[0].contains_key(&PropertyKey::from("name")));
851        // Amsterdam filtered out, empty map
852        assert!(results[1].is_empty());
853    }
854
855    #[test]
856    fn get_nodes_properties_selective_batch_filters() {
857        let (store, nodes, _) = setup_social_graph_with_ids();
858        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
859        let proj = GraphProjection::new(store, spec);
860
861        let ids = vec![nodes[0], nodes[2]]; // Alix (Person), Amsterdam (City)
862        let keys = vec![PropertyKey::from("name")];
863        let results = proj.get_nodes_properties_selective_batch(&ids, &keys);
864
865        assert_eq!(results.len(), 2);
866        assert_eq!(
867            results[0].get(&PropertyKey::from("name")),
868            Some(&Value::from("Alix"))
869        );
870        assert!(results[1].is_empty());
871    }
872
873    // 7. get_edges_properties_selective_batch
874
875    #[test]
876    fn get_edges_properties_selective_batch_filters() {
877        let (store, _, edges) = setup_social_graph_with_ids();
878        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
879        let proj = GraphProjection::new(store, spec);
880
881        let ids = vec![edges[0], edges[1]]; // KNOWS, LIVES_IN
882        let keys = vec![PropertyKey::from("since")];
883        let results = proj.get_edges_properties_selective_batch(&ids, &keys);
884
885        assert_eq!(results.len(), 2);
886        // KNOWS edge has "since" and passes filter
887        assert_eq!(
888            results[0].get(&PropertyKey::from("since")),
889            Some(&Value::from(2020))
890        );
891        // LIVES_IN edge filtered out
892        assert!(results[1].is_empty());
893    }
894
895    // 8. edges_from with edge type filter
896
897    #[test]
898    fn edges_from_with_edge_type_filter() {
899        let (store, nodes, _) = setup_social_graph_with_ids();
900        let spec = ProjectionSpec::new().with_edge_types(["LIVES_IN"]);
901        let proj = GraphProjection::new(store, spec);
902
903        // Alix has outgoing KNOWS, LIVES_IN, CONTRIBUTES_TO
904        // Only LIVES_IN should be visible
905        let alix_edges = proj.edges_from(nodes[0], Direction::Outgoing);
906        assert_eq!(alix_edges.len(), 1);
907        assert_eq!(alix_edges[0].0, nodes[2]); // target is Amsterdam
908    }
909
910    #[test]
911    fn edges_from_filtered_node_returns_empty() {
912        let (store, nodes, _) = setup_social_graph_with_ids();
913        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
914        let proj = GraphProjection::new(store, spec);
915
916        // Amsterdam (City) is filtered out, so edges_from returns empty
917        let amsterdam_edges = proj.edges_from(nodes[2], Direction::Outgoing);
918        assert!(amsterdam_edges.is_empty());
919    }
920
921    // 9. out_degree and in_degree with filtered projection
922
923    #[test]
924    fn out_degree_with_filter() {
925        let (store, nodes, _) = setup_social_graph_with_ids();
926        let spec = ProjectionSpec::new()
927            .with_node_labels(["Person", "City"])
928            .with_edge_types(["LIVES_IN"]);
929        let proj = GraphProjection::new(store, spec);
930
931        // Alix has 1 outgoing LIVES_IN to Amsterdam
932        assert_eq!(proj.out_degree(nodes[0]), 1);
933        // Gus has 1 outgoing LIVES_IN to Amsterdam
934        assert_eq!(proj.out_degree(nodes[1]), 1);
935        // Amsterdam has no outgoing LIVES_IN
936        assert_eq!(proj.out_degree(nodes[2]), 0);
937    }
938
939    #[test]
940    fn in_degree_with_filter() {
941        let (store, nodes, _) = setup_social_graph_with_ids();
942        let spec = ProjectionSpec::new()
943            .with_node_labels(["Person", "City"])
944            .with_edge_types(["LIVES_IN"]);
945        let proj = GraphProjection::new(store, spec);
946
947        // Amsterdam has 2 incoming LIVES_IN edges
948        assert_eq!(proj.in_degree(nodes[2]), 2);
949        // Alix has 0 incoming LIVES_IN
950        assert_eq!(proj.in_degree(nodes[0]), 0);
951    }
952
953    // 10. all_node_ids
954
955    #[test]
956    fn all_node_ids_with_label_filter() {
957        let (store, nodes, _) = setup_social_graph_with_ids();
958        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
959        let proj = GraphProjection::new(store, spec);
960
961        let ids = proj.all_node_ids();
962        assert_eq!(ids.len(), 2);
963        assert!(ids.contains(&nodes[0])); // Alix
964        assert!(ids.contains(&nodes[1])); // Gus
965        assert!(!ids.contains(&nodes[2])); // Amsterdam excluded
966    }
967
968    #[test]
969    fn all_node_ids_unfiltered() {
970        let (store, _, _) = setup_social_graph_with_ids();
971        let spec = ProjectionSpec::new();
972        let proj = GraphProjection::new(store.clone(), spec);
973
974        assert_eq!(proj.all_node_ids().len(), store.all_node_ids().len());
975    }
976
977    // 11. node_count and edge_count with various filters
978
979    #[test]
980    fn node_count_with_city_filter() {
981        let (store, _, _) = setup_social_graph_with_ids();
982        let spec = ProjectionSpec::new().with_node_labels(["City"]);
983        let proj = GraphProjection::new(store, spec);
984
985        assert_eq!(proj.node_count(), 1);
986    }
987
988    #[test]
989    fn edge_count_with_combined_filter() {
990        let (store, _, _) = setup_social_graph_with_ids();
991        let spec = ProjectionSpec::new()
992            .with_node_labels(["Person"])
993            .with_edge_types(["KNOWS"]);
994        let proj = GraphProjection::new(store, spec);
995
996        // Only KNOWS between Person nodes: Alix->Gus
997        assert_eq!(proj.edge_count(), 1);
998    }
999
1000    #[test]
1001    fn edge_count_unfiltered_delegates() {
1002        let (store, _, _) = setup_social_graph_with_ids();
1003        let spec = ProjectionSpec::new();
1004        let proj = GraphProjection::new(store.clone(), spec);
1005
1006        assert_eq!(proj.edge_count(), store.edge_count());
1007    }
1008
1009    // 12. find_nodes_by_property with label filter
1010
1011    #[test]
1012    fn find_nodes_by_property_with_label_filter() {
1013        let (store, nodes, _) = setup_social_graph_with_ids();
1014        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
1015        let proj = GraphProjection::new(store, spec);
1016
1017        // "name" = "Alix" exists on a Person node
1018        let found = proj.find_nodes_by_property("name", &Value::from("Alix"));
1019        assert_eq!(found.len(), 1);
1020        assert_eq!(found[0], nodes[0]);
1021
1022        // "name" = "Amsterdam" exists but on a City node, which is filtered
1023        let found = proj.find_nodes_by_property("name", &Value::from("Amsterdam"));
1024        assert!(found.is_empty());
1025    }
1026
1027    // 13. find_nodes_by_properties with label filter
1028
1029    #[test]
1030    fn find_nodes_by_properties_with_label_filter() {
1031        let (store, nodes, _) = setup_social_graph_with_ids();
1032        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
1033        let proj = GraphProjection::new(store, spec);
1034
1035        let conditions = vec![("name", Value::from("Gus"))];
1036        let found = proj.find_nodes_by_properties(&conditions);
1037        assert_eq!(found.len(), 1);
1038        assert_eq!(found[0], nodes[1]);
1039
1040        // Search for city name, filtered out
1041        let conditions = vec![("name", Value::from("Amsterdam"))];
1042        let found = proj.find_nodes_by_properties(&conditions);
1043        assert!(found.is_empty());
1044    }
1045
1046    // 14. find_nodes_in_range with label filter
1047
1048    #[test]
1049    fn find_nodes_in_range_with_label_filter() {
1050        let (store, nodes, _) = setup_social_graph_with_ids();
1051        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
1052        let proj = GraphProjection::new(store, spec);
1053
1054        // Age range 20..=30 should find both Alix (30) and Gus (25)
1055        let min = Value::from(20);
1056        let max = Value::from(30);
1057        let found = proj.find_nodes_in_range("age", Some(&min), Some(&max), true, true);
1058        assert_eq!(found.len(), 2);
1059        assert!(found.contains(&nodes[0])); // Alix
1060        assert!(found.contains(&nodes[1])); // Gus
1061    }
1062
1063    #[test]
1064    fn find_nodes_in_range_excludes_filtered_labels() {
1065        let (store, _, _) = setup_social_graph_with_ids();
1066        let spec = ProjectionSpec::new().with_node_labels(["City"]);
1067        let proj = GraphProjection::new(store, spec);
1068
1069        // City nodes don't have "age" property
1070        let min = Value::from(20);
1071        let max = Value::from(30);
1072        let found = proj.find_nodes_in_range("age", Some(&min), Some(&max), true, true);
1073        assert!(found.is_empty());
1074    }
1075
1076    // 15. node_property_might_match and edge_property_might_match
1077
1078    #[test]
1079    fn node_property_might_match_delegates() {
1080        let (store, _, _) = setup_social_graph_with_ids();
1081        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
1082        let proj = GraphProjection::new(store.clone(), spec);
1083
1084        let key = PropertyKey::from("name");
1085        let val = Value::from("Alix");
1086        // Delegates to inner store, so result should match
1087        let inner_result = store.node_property_might_match(&key, CompareOp::Eq, &val);
1088        assert_eq!(
1089            proj.node_property_might_match(&key, CompareOp::Eq, &val),
1090            inner_result
1091        );
1092    }
1093
1094    #[test]
1095    fn edge_property_might_match_delegates() {
1096        let (store, _, _) = setup_social_graph_with_ids();
1097        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
1098        let proj = GraphProjection::new(store.clone(), spec);
1099
1100        let key = PropertyKey::from("since");
1101        let val = Value::from(2020);
1102        let inner_result = store.edge_property_might_match(&key, CompareOp::Eq, &val);
1103        assert_eq!(
1104            proj.edge_property_might_match(&key, CompareOp::Eq, &val),
1105            inner_result
1106        );
1107    }
1108
1109    // 16. current_epoch
1110
1111    #[test]
1112    fn current_epoch_delegates() {
1113        let (store, _, _) = setup_social_graph_with_ids();
1114        let spec = ProjectionSpec::new();
1115        let proj = GraphProjection::new(store.clone(), spec);
1116
1117        assert_eq!(proj.current_epoch(), store.current_epoch());
1118    }
1119
1120    // 17. all_property_keys
1121
1122    #[test]
1123    fn all_property_keys_delegates() {
1124        let (store, _, _) = setup_social_graph_with_ids();
1125        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
1126        let proj = GraphProjection::new(store.clone(), spec);
1127
1128        let proj_keys = proj.all_property_keys();
1129        let store_keys = store.all_property_keys();
1130        // Delegates to inner, so same result
1131        assert_eq!(proj_keys.len(), store_keys.len());
1132    }
1133
1134    // 18. statistics returns non-null
1135
1136    #[test]
1137    fn statistics_returns_value() {
1138        let (store, _, _) = setup_social_graph_with_ids();
1139        let spec = ProjectionSpec::new();
1140        let proj = GraphProjection::new(store, spec);
1141
1142        let stats = proj.statistics();
1143        // Just verify it returns without panicking and is non-null (Arc)
1144        let _ = stats;
1145    }
1146
1147    // 19. has_backward_adjacency delegates to inner
1148
1149    #[test]
1150    fn has_backward_adjacency_delegates() {
1151        let (store, _, _) = setup_social_graph_with_ids();
1152        let spec = ProjectionSpec::new();
1153        let proj = GraphProjection::new(store.clone(), spec);
1154
1155        assert_eq!(
1156            proj.has_backward_adjacency(),
1157            store.has_backward_adjacency()
1158        );
1159    }
1160
1161    // 20. has_property_index delegates to inner
1162
1163    #[test]
1164    fn has_property_index_delegates() {
1165        let (store, _, _) = setup_social_graph_with_ids();
1166        let spec = ProjectionSpec::new();
1167        let proj = GraphProjection::new(store.clone(), spec);
1168
1169        // LpgStore default has no property indexes
1170        assert_eq!(
1171            proj.has_property_index("name"),
1172            store.has_property_index("name")
1173        );
1174    }
1175}