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, GraphStoreSearch};
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 GraphStoreSearch>,
81    spec: ProjectionSpec,
82}
83
84impl GraphProjection {
85    /// Creates a new projection over the given store.
86    pub fn new(inner: Arc<dyn GraphStoreSearch>, 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
501impl GraphStoreSearch for GraphProjection {}
502
503#[cfg(test)]
504#[cfg(feature = "lpg")]
505mod tests {
506    use super::*;
507    use crate::graph::lpg::LpgStore;
508
509    fn setup_social_graph() -> Arc<LpgStore> {
510        let store = Arc::new(LpgStore::new().unwrap());
511        let alix = store.create_node(&["Person"]);
512        let gus = store.create_node(&["Person"]);
513        let amsterdam = store.create_node(&["City"]);
514        let grafeo = store.create_node(&["Software"]);
515
516        store.set_node_property(alix, "name", Value::from("Alix"));
517        store.set_node_property(gus, "name", Value::from("Gus"));
518        store.set_node_property(amsterdam, "name", Value::from("Amsterdam"));
519        store.set_node_property(grafeo, "name", Value::from("Grafeo"));
520
521        store.create_edge(alix, gus, "KNOWS");
522        store.create_edge(alix, amsterdam, "LIVES_IN");
523        store.create_edge(gus, amsterdam, "LIVES_IN");
524        store.create_edge(alix, grafeo, "CONTRIBUTES_TO");
525
526        store
527    }
528
529    #[test]
530    fn unfiltered_projection_sees_everything() {
531        let store = setup_social_graph();
532        let proj = GraphProjection::new(store.clone(), ProjectionSpec::new());
533        assert_eq!(proj.node_count(), store.node_count());
534        assert_eq!(proj.edge_count(), store.edge_count());
535    }
536
537    #[test]
538    fn filter_by_label() {
539        let store = setup_social_graph();
540        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
541        let proj = GraphProjection::new(store, spec);
542
543        assert_eq!(proj.node_count(), 2);
544        assert_eq!(proj.nodes_by_label("Person").len(), 2);
545        assert!(proj.nodes_by_label("City").is_empty());
546        assert!(proj.nodes_by_label("Software").is_empty());
547    }
548
549    #[test]
550    fn filter_by_edge_type() {
551        let store = setup_social_graph();
552        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
553        let proj = GraphProjection::new(store, spec);
554
555        // All nodes visible (no label filter), but only KNOWS edges
556        assert_eq!(proj.node_count(), 4);
557        assert_eq!(proj.edge_count(), 1);
558    }
559
560    #[test]
561    fn combined_label_and_edge_filter() {
562        let store = setup_social_graph();
563        let spec = ProjectionSpec::new()
564            .with_node_labels(["Person", "City"])
565            .with_edge_types(["LIVES_IN"]);
566        let proj = GraphProjection::new(store, spec);
567
568        assert_eq!(proj.node_count(), 3); // 2 Person + 1 City
569        assert_eq!(proj.edge_count(), 2); // 2 LIVES_IN edges
570    }
571
572    #[test]
573    fn edge_excluded_when_endpoint_excluded() {
574        let store = setup_social_graph();
575        // Only Person nodes, but LIVES_IN edge type
576        // LIVES_IN goes Person -> City, but City is excluded
577        let spec = ProjectionSpec::new()
578            .with_node_labels(["Person"])
579            .with_edge_types(["LIVES_IN"]);
580        let proj = GraphProjection::new(store, spec);
581
582        assert_eq!(proj.node_count(), 2);
583        // LIVES_IN edges should be excluded because City endpoints are filtered out
584        assert_eq!(proj.edge_count(), 0);
585    }
586
587    #[test]
588    fn get_node_filtered() {
589        let store = setup_social_graph();
590        let all_ids = store.node_ids();
591        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
592        let proj = GraphProjection::new(store.clone(), spec);
593
594        // Person nodes visible
595        assert!(proj.get_node(all_ids[0]).is_some()); // Alix (Person)
596        assert!(proj.get_node(all_ids[1]).is_some()); // Gus (Person)
597        // City and Software nodes hidden
598        assert!(proj.get_node(all_ids[2]).is_none()); // Amsterdam (City)
599        assert!(proj.get_node(all_ids[3]).is_none()); // Grafeo (Software)
600    }
601
602    #[test]
603    fn neighbors_filtered() {
604        let store = setup_social_graph();
605        let alix_id = store.node_ids()[0];
606
607        // Without projection: Alix has 3 outgoing neighbors (Gus, Amsterdam, Grafeo)
608        let all_neighbors: Vec<_> = store.neighbors(alix_id, Direction::Outgoing).collect();
609        assert_eq!(all_neighbors.len(), 3);
610
611        // With Person-only projection: Alix -> Gus only
612        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
613        let proj = GraphProjection::new(store, spec);
614        let neighbors = proj.neighbors(alix_id, Direction::Outgoing);
615        assert_eq!(neighbors.len(), 1);
616    }
617
618    #[test]
619    fn neighbors_filtered_by_edge_type() {
620        let store = setup_social_graph();
621        let alix_id = store.node_ids()[0];
622
623        // With edge-type filter: only KNOWS edges visible
624        // Alix KNOWS Gus, but LIVES_IN Amsterdam and CONTRIBUTES_TO Grafeo are excluded
625        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
626        let proj = GraphProjection::new(store, spec);
627        let neighbors = proj.neighbors(alix_id, Direction::Outgoing);
628        assert_eq!(neighbors.len(), 1);
629    }
630
631    #[test]
632    fn property_access_respects_filter() {
633        let store = setup_social_graph();
634        let city_id = store.node_ids()[2]; // Amsterdam
635        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
636        let proj = GraphProjection::new(store, spec);
637
638        // City node properties are inaccessible
639        assert!(
640            proj.get_node_property(city_id, &PropertyKey::from("name"))
641                .is_none()
642        );
643    }
644
645    #[test]
646    fn cardinality_estimation_respects_filter() {
647        let store = setup_social_graph();
648        let spec = ProjectionSpec::new()
649            .with_node_labels(["Person"])
650            .with_edge_types(["KNOWS"]);
651        let proj = GraphProjection::new(store, spec);
652
653        assert!(proj.estimate_label_cardinality("City") == 0.0);
654        assert!(proj.estimate_avg_degree("LIVES_IN", true) == 0.0);
655    }
656
657    #[test]
658    fn schema_introspection_reflects_filter() {
659        let store = setup_social_graph();
660        let spec = ProjectionSpec::new()
661            .with_node_labels(["Person"])
662            .with_edge_types(["KNOWS"]);
663        let proj = GraphProjection::new(store, spec);
664
665        let labels = proj.all_labels();
666        assert_eq!(labels.len(), 1);
667        assert!(labels.contains(&"Person".to_string()));
668
669        let edge_types = proj.all_edge_types();
670        assert_eq!(edge_types.len(), 1);
671        assert!(edge_types.contains(&"KNOWS".to_string()));
672    }
673
674    /// Helper: returns (node_ids, edge_ids) from the social graph.
675    fn setup_social_graph_with_ids() -> (Arc<LpgStore>, Vec<NodeId>, Vec<EdgeId>) {
676        let store = Arc::new(LpgStore::new().unwrap());
677        let alix = store.create_node(&["Person"]);
678        let gus = store.create_node(&["Person"]);
679        let amsterdam = store.create_node(&["City"]);
680        let grafeo = store.create_node(&["Software"]);
681
682        store.set_node_property(alix, "name", Value::from("Alix"));
683        store.set_node_property(gus, "name", Value::from("Gus"));
684        store.set_node_property(amsterdam, "name", Value::from("Amsterdam"));
685        store.set_node_property(grafeo, "name", Value::from("Grafeo"));
686        store.set_node_property(alix, "age", Value::from(30));
687        store.set_node_property(gus, "age", Value::from(25));
688
689        let e_knows = store.create_edge(alix, gus, "KNOWS");
690        let e_alix_lives = store.create_edge(alix, amsterdam, "LIVES_IN");
691        let e_gus_lives = store.create_edge(gus, amsterdam, "LIVES_IN");
692        let e_contrib = store.create_edge(alix, grafeo, "CONTRIBUTES_TO");
693
694        store.set_edge_property(e_knows, "since", Value::from(2020));
695        store.set_edge_property(e_alix_lives, "since", Value::from(2018));
696
697        let nodes = vec![alix, gus, amsterdam, grafeo];
698        let edges = vec![e_knows, e_alix_lives, e_gus_lives, e_contrib];
699        (store, nodes, edges)
700    }
701
702    // 1. get_edge with edge that passes/fails type filter
703
704    #[test]
705    fn get_edge_passes_type_filter() {
706        let (store, _, edges) = setup_social_graph_with_ids();
707        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
708        let proj = GraphProjection::new(store, spec);
709
710        // KNOWS edge passes filter
711        assert!(proj.get_edge(edges[0]).is_some());
712        // LIVES_IN edge does not pass filter
713        assert!(proj.get_edge(edges[1]).is_none());
714        // CONTRIBUTES_TO edge does not pass filter
715        assert!(proj.get_edge(edges[3]).is_none());
716    }
717
718    #[test]
719    fn get_edge_excluded_by_endpoint_label_filter() {
720        let (store, _, edges) = setup_social_graph_with_ids();
721        // Only Person nodes, LIVES_IN goes Person->City, so excluded
722        let spec = ProjectionSpec::new()
723            .with_node_labels(["Person"])
724            .with_edge_types(["LIVES_IN"]);
725        let proj = GraphProjection::new(store, spec);
726
727        assert!(proj.get_edge(edges[1]).is_none()); // Alix->Amsterdam
728        assert!(proj.get_edge(edges[2]).is_none()); // Gus->Amsterdam
729    }
730
731    // 2. get_node_versioned and get_edge_versioned
732
733    #[test]
734    fn get_node_versioned_respects_filter() {
735        let (store, nodes, _) = setup_social_graph_with_ids();
736        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
737        let proj = GraphProjection::new(store, spec);
738
739        let epoch = EpochId(0);
740        let txn = TransactionId(0);
741
742        // Person node visible
743        assert!(proj.get_node_versioned(nodes[0], epoch, txn).is_some());
744        // City node filtered out
745        assert!(proj.get_node_versioned(nodes[2], epoch, txn).is_none());
746    }
747
748    #[test]
749    fn get_edge_versioned_respects_filter() {
750        let (store, _, edges) = setup_social_graph_with_ids();
751        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
752        let proj = GraphProjection::new(store, spec);
753
754        let epoch = EpochId(0);
755        let txn = TransactionId(0);
756
757        // KNOWS edge visible
758        assert!(proj.get_edge_versioned(edges[0], epoch, txn).is_some());
759        // LIVES_IN edge filtered out
760        assert!(proj.get_edge_versioned(edges[1], epoch, txn).is_none());
761    }
762
763    // 3. get_node_at_epoch and get_edge_at_epoch
764
765    #[test]
766    fn get_node_at_epoch_respects_filter() {
767        let (store, nodes, _) = setup_social_graph_with_ids();
768        let spec = ProjectionSpec::new().with_node_labels(["City"]);
769        let proj = GraphProjection::new(store, spec);
770
771        let epoch = EpochId(0);
772
773        // Amsterdam (City) visible
774        assert!(proj.get_node_at_epoch(nodes[2], epoch).is_some());
775        // Alix (Person) filtered out
776        assert!(proj.get_node_at_epoch(nodes[0], epoch).is_none());
777    }
778
779    #[test]
780    fn get_edge_at_epoch_respects_filter() {
781        let (store, _, edges) = setup_social_graph_with_ids();
782        let spec = ProjectionSpec::new().with_edge_types(["LIVES_IN"]);
783        let proj = GraphProjection::new(store, spec);
784
785        let epoch = EpochId(0);
786
787        // LIVES_IN edge visible
788        assert!(proj.get_edge_at_epoch(edges[1], epoch).is_some());
789        // KNOWS edge filtered out
790        assert!(proj.get_edge_at_epoch(edges[0], epoch).is_none());
791    }
792
793    // 4. get_edge_property for edges in/out of projection
794
795    #[test]
796    fn get_edge_property_in_projection() {
797        let (store, _, edges) = setup_social_graph_with_ids();
798        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
799        let proj = GraphProjection::new(store, spec);
800
801        let key = PropertyKey::from("since");
802        // KNOWS edge has "since" property and passes filter
803        assert_eq!(
804            proj.get_edge_property(edges[0], &key),
805            Some(Value::from(2020))
806        );
807    }
808
809    #[test]
810    fn get_edge_property_outside_projection() {
811        let (store, _, edges) = setup_social_graph_with_ids();
812        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
813        let proj = GraphProjection::new(store, spec);
814
815        let key = PropertyKey::from("since");
816        // LIVES_IN edge has "since" but is filtered out
817        assert!(proj.get_edge_property(edges[1], &key).is_none());
818    }
819
820    // 5. get_node_property_batch for mixed in/out of projection nodes
821
822    #[test]
823    fn get_node_property_batch_mixed() {
824        let (store, nodes, _) = setup_social_graph_with_ids();
825        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
826        let proj = GraphProjection::new(store, spec);
827
828        let key = PropertyKey::from("name");
829        // Alix (Person), Amsterdam (City), Gus (Person)
830        let ids = vec![nodes[0], nodes[2], nodes[1]];
831        let results = proj.get_node_property_batch(&ids, &key);
832
833        assert_eq!(results.len(), 3);
834        assert_eq!(results[0], Some(Value::from("Alix"))); // Person: visible
835        assert_eq!(results[1], None); // City: filtered out
836        assert_eq!(results[2], Some(Value::from("Gus"))); // Person: visible
837    }
838
839    // 6. get_nodes_properties_batch and selective batch
840
841    #[test]
842    fn get_nodes_properties_batch_filters() {
843        let (store, nodes, _) = setup_social_graph_with_ids();
844        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
845        let proj = GraphProjection::new(store, spec);
846
847        let ids = vec![nodes[0], nodes[2]]; // Alix (Person), Amsterdam (City)
848        let results = proj.get_nodes_properties_batch(&ids);
849
850        assert_eq!(results.len(), 2);
851        // Alix has properties
852        assert!(results[0].contains_key(&PropertyKey::from("name")));
853        // Amsterdam filtered out, empty map
854        assert!(results[1].is_empty());
855    }
856
857    #[test]
858    fn get_nodes_properties_selective_batch_filters() {
859        let (store, nodes, _) = setup_social_graph_with_ids();
860        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
861        let proj = GraphProjection::new(store, spec);
862
863        let ids = vec![nodes[0], nodes[2]]; // Alix (Person), Amsterdam (City)
864        let keys = vec![PropertyKey::from("name")];
865        let results = proj.get_nodes_properties_selective_batch(&ids, &keys);
866
867        assert_eq!(results.len(), 2);
868        assert_eq!(
869            results[0].get(&PropertyKey::from("name")),
870            Some(&Value::from("Alix"))
871        );
872        assert!(results[1].is_empty());
873    }
874
875    // 7. get_edges_properties_selective_batch
876
877    #[test]
878    fn get_edges_properties_selective_batch_filters() {
879        let (store, _, edges) = setup_social_graph_with_ids();
880        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
881        let proj = GraphProjection::new(store, spec);
882
883        let ids = vec![edges[0], edges[1]]; // KNOWS, LIVES_IN
884        let keys = vec![PropertyKey::from("since")];
885        let results = proj.get_edges_properties_selective_batch(&ids, &keys);
886
887        assert_eq!(results.len(), 2);
888        // KNOWS edge has "since" and passes filter
889        assert_eq!(
890            results[0].get(&PropertyKey::from("since")),
891            Some(&Value::from(2020))
892        );
893        // LIVES_IN edge filtered out
894        assert!(results[1].is_empty());
895    }
896
897    // 8. edges_from with edge type filter
898
899    #[test]
900    fn edges_from_with_edge_type_filter() {
901        let (store, nodes, _) = setup_social_graph_with_ids();
902        let spec = ProjectionSpec::new().with_edge_types(["LIVES_IN"]);
903        let proj = GraphProjection::new(store, spec);
904
905        // Alix has outgoing KNOWS, LIVES_IN, CONTRIBUTES_TO
906        // Only LIVES_IN should be visible
907        let alix_edges = proj.edges_from(nodes[0], Direction::Outgoing);
908        assert_eq!(alix_edges.len(), 1);
909        assert_eq!(alix_edges[0].0, nodes[2]); // target is Amsterdam
910    }
911
912    #[test]
913    fn edges_from_filtered_node_returns_empty() {
914        let (store, nodes, _) = setup_social_graph_with_ids();
915        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
916        let proj = GraphProjection::new(store, spec);
917
918        // Amsterdam (City) is filtered out, so edges_from returns empty
919        let amsterdam_edges = proj.edges_from(nodes[2], Direction::Outgoing);
920        assert!(amsterdam_edges.is_empty());
921    }
922
923    // 9. out_degree and in_degree with filtered projection
924
925    #[test]
926    fn out_degree_with_filter() {
927        let (store, nodes, _) = setup_social_graph_with_ids();
928        let spec = ProjectionSpec::new()
929            .with_node_labels(["Person", "City"])
930            .with_edge_types(["LIVES_IN"]);
931        let proj = GraphProjection::new(store, spec);
932
933        // Alix has 1 outgoing LIVES_IN to Amsterdam
934        assert_eq!(proj.out_degree(nodes[0]), 1);
935        // Gus has 1 outgoing LIVES_IN to Amsterdam
936        assert_eq!(proj.out_degree(nodes[1]), 1);
937        // Amsterdam has no outgoing LIVES_IN
938        assert_eq!(proj.out_degree(nodes[2]), 0);
939    }
940
941    #[test]
942    fn in_degree_with_filter() {
943        let (store, nodes, _) = setup_social_graph_with_ids();
944        let spec = ProjectionSpec::new()
945            .with_node_labels(["Person", "City"])
946            .with_edge_types(["LIVES_IN"]);
947        let proj = GraphProjection::new(store, spec);
948
949        // Amsterdam has 2 incoming LIVES_IN edges
950        assert_eq!(proj.in_degree(nodes[2]), 2);
951        // Alix has 0 incoming LIVES_IN
952        assert_eq!(proj.in_degree(nodes[0]), 0);
953    }
954
955    // 10. all_node_ids
956
957    #[test]
958    fn all_node_ids_with_label_filter() {
959        let (store, nodes, _) = setup_social_graph_with_ids();
960        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
961        let proj = GraphProjection::new(store, spec);
962
963        let ids = proj.all_node_ids();
964        assert_eq!(ids.len(), 2);
965        assert!(ids.contains(&nodes[0])); // Alix
966        assert!(ids.contains(&nodes[1])); // Gus
967        assert!(!ids.contains(&nodes[2])); // Amsterdam excluded
968    }
969
970    #[test]
971    fn all_node_ids_unfiltered() {
972        let (store, _, _) = setup_social_graph_with_ids();
973        let spec = ProjectionSpec::new();
974        let proj = GraphProjection::new(store.clone(), spec);
975
976        assert_eq!(proj.all_node_ids().len(), store.all_node_ids().len());
977    }
978
979    // 11. node_count and edge_count with various filters
980
981    #[test]
982    fn node_count_with_city_filter() {
983        let (store, _, _) = setup_social_graph_with_ids();
984        let spec = ProjectionSpec::new().with_node_labels(["City"]);
985        let proj = GraphProjection::new(store, spec);
986
987        assert_eq!(proj.node_count(), 1);
988    }
989
990    #[test]
991    fn edge_count_with_combined_filter() {
992        let (store, _, _) = setup_social_graph_with_ids();
993        let spec = ProjectionSpec::new()
994            .with_node_labels(["Person"])
995            .with_edge_types(["KNOWS"]);
996        let proj = GraphProjection::new(store, spec);
997
998        // Only KNOWS between Person nodes: Alix->Gus
999        assert_eq!(proj.edge_count(), 1);
1000    }
1001
1002    #[test]
1003    fn edge_count_unfiltered_delegates() {
1004        let (store, _, _) = setup_social_graph_with_ids();
1005        let spec = ProjectionSpec::new();
1006        let proj = GraphProjection::new(store.clone(), spec);
1007
1008        assert_eq!(proj.edge_count(), store.edge_count());
1009    }
1010
1011    // 12. find_nodes_by_property with label filter
1012
1013    #[test]
1014    fn find_nodes_by_property_with_label_filter() {
1015        let (store, nodes, _) = setup_social_graph_with_ids();
1016        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
1017        let proj = GraphProjection::new(store, spec);
1018
1019        // "name" = "Alix" exists on a Person node
1020        let found = proj.find_nodes_by_property("name", &Value::from("Alix"));
1021        assert_eq!(found.len(), 1);
1022        assert_eq!(found[0], nodes[0]);
1023
1024        // "name" = "Amsterdam" exists but on a City node, which is filtered
1025        let found = proj.find_nodes_by_property("name", &Value::from("Amsterdam"));
1026        assert!(found.is_empty());
1027    }
1028
1029    // 13. find_nodes_by_properties with label filter
1030
1031    #[test]
1032    fn find_nodes_by_properties_with_label_filter() {
1033        let (store, nodes, _) = setup_social_graph_with_ids();
1034        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
1035        let proj = GraphProjection::new(store, spec);
1036
1037        let conditions = vec![("name", Value::from("Gus"))];
1038        let found = proj.find_nodes_by_properties(&conditions);
1039        assert_eq!(found.len(), 1);
1040        assert_eq!(found[0], nodes[1]);
1041
1042        // Search for city name, filtered out
1043        let conditions = vec![("name", Value::from("Amsterdam"))];
1044        let found = proj.find_nodes_by_properties(&conditions);
1045        assert!(found.is_empty());
1046    }
1047
1048    // 14. find_nodes_in_range with label filter
1049
1050    #[test]
1051    fn find_nodes_in_range_with_label_filter() {
1052        let (store, nodes, _) = setup_social_graph_with_ids();
1053        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
1054        let proj = GraphProjection::new(store, spec);
1055
1056        // Age range 20..=30 should find both Alix (30) and Gus (25)
1057        let min = Value::from(20);
1058        let max = Value::from(30);
1059        let found = proj.find_nodes_in_range("age", Some(&min), Some(&max), true, true);
1060        assert_eq!(found.len(), 2);
1061        assert!(found.contains(&nodes[0])); // Alix
1062        assert!(found.contains(&nodes[1])); // Gus
1063    }
1064
1065    #[test]
1066    fn find_nodes_in_range_excludes_filtered_labels() {
1067        let (store, _, _) = setup_social_graph_with_ids();
1068        let spec = ProjectionSpec::new().with_node_labels(["City"]);
1069        let proj = GraphProjection::new(store, spec);
1070
1071        // City nodes don't have "age" property
1072        let min = Value::from(20);
1073        let max = Value::from(30);
1074        let found = proj.find_nodes_in_range("age", Some(&min), Some(&max), true, true);
1075        assert!(found.is_empty());
1076    }
1077
1078    // 15. node_property_might_match and edge_property_might_match
1079
1080    #[test]
1081    fn node_property_might_match_delegates() {
1082        let (store, _, _) = setup_social_graph_with_ids();
1083        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
1084        let proj = GraphProjection::new(store.clone(), spec);
1085
1086        let key = PropertyKey::from("name");
1087        let val = Value::from("Alix");
1088        // Delegates to inner store, so result should match
1089        let inner_result = store.node_property_might_match(&key, CompareOp::Eq, &val);
1090        assert_eq!(
1091            proj.node_property_might_match(&key, CompareOp::Eq, &val),
1092            inner_result
1093        );
1094    }
1095
1096    #[test]
1097    fn edge_property_might_match_delegates() {
1098        let (store, _, _) = setup_social_graph_with_ids();
1099        let spec = ProjectionSpec::new().with_edge_types(["KNOWS"]);
1100        let proj = GraphProjection::new(store.clone(), spec);
1101
1102        let key = PropertyKey::from("since");
1103        let val = Value::from(2020);
1104        let inner_result = store.edge_property_might_match(&key, CompareOp::Eq, &val);
1105        assert_eq!(
1106            proj.edge_property_might_match(&key, CompareOp::Eq, &val),
1107            inner_result
1108        );
1109    }
1110
1111    // 16. current_epoch
1112
1113    #[test]
1114    fn current_epoch_delegates() {
1115        let (store, _, _) = setup_social_graph_with_ids();
1116        let spec = ProjectionSpec::new();
1117        let proj = GraphProjection::new(store.clone(), spec);
1118
1119        assert_eq!(proj.current_epoch(), store.current_epoch());
1120    }
1121
1122    // 17. all_property_keys
1123
1124    #[test]
1125    fn all_property_keys_delegates() {
1126        let (store, _, _) = setup_social_graph_with_ids();
1127        let spec = ProjectionSpec::new().with_node_labels(["Person"]);
1128        let proj = GraphProjection::new(store.clone(), spec);
1129
1130        let proj_keys = proj.all_property_keys();
1131        let store_keys = store.all_property_keys();
1132        // Delegates to inner, so same result
1133        assert_eq!(proj_keys.len(), store_keys.len());
1134    }
1135
1136    // 18. statistics returns non-null
1137
1138    #[test]
1139    fn statistics_returns_value() {
1140        let (store, _, _) = setup_social_graph_with_ids();
1141        let spec = ProjectionSpec::new();
1142        let proj = GraphProjection::new(store, spec);
1143
1144        let stats = proj.statistics();
1145        // Just verify it returns without panicking and is non-null (Arc)
1146        let _ = stats;
1147    }
1148
1149    // 19. has_backward_adjacency delegates to inner
1150
1151    #[test]
1152    fn has_backward_adjacency_delegates() {
1153        let (store, _, _) = setup_social_graph_with_ids();
1154        let spec = ProjectionSpec::new();
1155        let proj = GraphProjection::new(store.clone(), spec);
1156
1157        assert_eq!(
1158            proj.has_backward_adjacency(),
1159            store.has_backward_adjacency()
1160        );
1161    }
1162
1163    // 20. has_property_index delegates to inner
1164
1165    #[test]
1166    fn has_property_index_delegates() {
1167        let (store, _, _) = setup_social_graph_with_ids();
1168        let spec = ProjectionSpec::new();
1169        let proj = GraphProjection::new(store.clone(), spec);
1170
1171        // LpgStore default has no property indexes
1172        assert_eq!(
1173            proj.has_property_index("name"),
1174            store.has_property_index("name")
1175        );
1176    }
1177}