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