Skip to main content

grafeo_core/graph/
traits.rs

1//! Storage traits for the graph engine.
2//!
3//! These traits capture the minimal surface that query operators need from
4//! the graph store. The split is intentional:
5//!
6//! - [`GraphStore`]: Read-only operations (scans, lookups, traversal, statistics)
7//! - [`GraphStoreMut`]: Write operations (create, delete, mutate)
8//!
9//! Admin operations (index management, MVCC internals, schema introspection,
10//! statistics recomputation, WAL recovery) stay on the concrete [`LpgStore`]
11//! and are not part of these traits.
12//!
13//! ## Design rationale
14//!
15//! The traits work with typed graph objects (`Node`, `Edge`, `Value`) rather
16//! than raw bytes. This preserves zero-overhead access for in-memory storage
17//! while allowing future backends (SpilloverStore, disk-backed) to implement
18//! the same interface with transparent serialization where needed.
19//!
20//! [`LpgStore`]: crate::graph::lpg::LpgStore
21
22use crate::graph::Direction;
23use crate::graph::lpg::CompareOp;
24use crate::graph::lpg::{Edge, Node};
25#[cfg(feature = "vector-index")]
26use crate::index::vector::DistanceMetric;
27use crate::statistics::Statistics;
28use arcstr::ArcStr;
29use grafeo_common::types::{EdgeId, EpochId, NodeId, PropertyKey, TransactionId, Value};
30use grafeo_common::utils::hash::FxHashMap;
31use std::sync::Arc;
32
33/// Read-only graph operations used by the query engine.
34///
35/// This trait captures the minimal surface that scan, expand, filter,
36/// project, and shortest-path operators need. Implementations may serve
37/// data from memory, disk, or a hybrid of both.
38///
39/// # Object safety
40///
41/// This trait is object-safe: you can use `Arc<dyn GraphStoreSearch>` for dynamic
42/// dispatch. Traversal methods return `Vec` instead of `impl Iterator` to
43/// enable this.
44pub trait GraphStore: Send + Sync {
45    // --- Point lookups ---
46
47    /// Returns a node by ID (latest visible version at current epoch).
48    fn get_node(&self, id: NodeId) -> Option<Node>;
49
50    /// Returns an edge by ID (latest visible version at current epoch).
51    fn get_edge(&self, id: EdgeId) -> Option<Edge>;
52
53    /// Returns a node visible to a specific transaction.
54    fn get_node_versioned(
55        &self,
56        id: NodeId,
57        epoch: EpochId,
58        transaction_id: TransactionId,
59    ) -> Option<Node>;
60
61    /// Returns an edge visible to a specific transaction.
62    fn get_edge_versioned(
63        &self,
64        id: EdgeId,
65        epoch: EpochId,
66        transaction_id: TransactionId,
67    ) -> Option<Edge>;
68
69    /// Returns a node using pure epoch-based visibility (no transaction context).
70    ///
71    /// The node is visible if `created_epoch <= epoch` and not deleted at or
72    /// before `epoch`. Used for time-travel queries where transaction ownership
73    /// must not bypass the epoch check.
74    fn get_node_at_epoch(&self, id: NodeId, epoch: EpochId) -> Option<Node>;
75
76    /// Returns an edge using pure epoch-based visibility (no transaction context).
77    fn get_edge_at_epoch(&self, id: EdgeId, epoch: EpochId) -> Option<Edge>;
78
79    // --- Property access (fast path, avoids loading full entity) ---
80
81    /// Gets a single property from a node without loading all properties.
82    fn get_node_property(&self, id: NodeId, key: &PropertyKey) -> Option<Value>;
83
84    /// Gets a single property from an edge without loading all properties.
85    fn get_edge_property(&self, id: EdgeId, key: &PropertyKey) -> Option<Value>;
86
87    /// Gets a property for multiple nodes in a single batch operation.
88    fn get_node_property_batch(&self, ids: &[NodeId], key: &PropertyKey) -> Vec<Option<Value>>;
89
90    /// Gets all properties for multiple nodes in a single batch operation.
91    fn get_nodes_properties_batch(&self, ids: &[NodeId]) -> Vec<FxHashMap<PropertyKey, Value>>;
92
93    /// Gets selected properties for multiple nodes (projection pushdown).
94    fn get_nodes_properties_selective_batch(
95        &self,
96        ids: &[NodeId],
97        keys: &[PropertyKey],
98    ) -> Vec<FxHashMap<PropertyKey, Value>>;
99
100    /// Gets selected properties for multiple edges (projection pushdown).
101    fn get_edges_properties_selective_batch(
102        &self,
103        ids: &[EdgeId],
104        keys: &[PropertyKey],
105    ) -> Vec<FxHashMap<PropertyKey, Value>>;
106
107    // --- Traversal ---
108
109    /// Returns neighbor node IDs in the specified direction.
110    ///
111    /// Returns `Vec` instead of an iterator for object safety. The underlying
112    /// `ChunkedAdjacency` already produces a `Vec` internally.
113    fn neighbors(&self, node: NodeId, direction: Direction) -> Vec<NodeId>;
114
115    /// Returns (target_node, edge_id) pairs for edges from a node.
116    fn edges_from(&self, node: NodeId, direction: Direction) -> Vec<(NodeId, EdgeId)>;
117
118    /// Returns the out-degree of a node (number of outgoing edges).
119    fn out_degree(&self, node: NodeId) -> usize;
120
121    /// Returns the in-degree of a node (number of incoming edges).
122    fn in_degree(&self, node: NodeId) -> usize;
123
124    /// Whether backward adjacency is available for incoming edge queries.
125    fn has_backward_adjacency(&self) -> bool;
126
127    // --- Scans ---
128
129    /// Returns all non-deleted node IDs, sorted by ID.
130    fn node_ids(&self) -> Vec<NodeId>;
131
132    /// Returns all node IDs including uncommitted/PENDING versions.
133    ///
134    /// Unlike `node_ids()` which pre-filters by current epoch, this method
135    /// returns every node that has a version chain entry. Used by scan operators
136    /// that perform their own MVCC visibility filtering (e.g. with transaction context).
137    fn all_node_ids(&self) -> Vec<NodeId> {
138        // Default: fall back to node_ids() for stores without MVCC
139        self.node_ids()
140    }
141
142    /// Returns node IDs with a specific label.
143    fn nodes_by_label(&self, label: &str) -> Vec<NodeId>;
144
145    /// Returns the total number of non-deleted nodes.
146    fn node_count(&self) -> usize;
147
148    /// Returns the total number of non-deleted edges.
149    fn edge_count(&self) -> usize;
150
151    // --- Entity metadata ---
152
153    /// Returns the type string of an edge.
154    fn edge_type(&self, id: EdgeId) -> Option<ArcStr>;
155
156    /// Returns the type string of an edge visible to a specific transaction.
157    ///
158    /// Falls back to epoch-based `edge_type` if not overridden.
159    fn edge_type_versioned(
160        &self,
161        id: EdgeId,
162        epoch: EpochId,
163        transaction_id: TransactionId,
164    ) -> Option<ArcStr> {
165        let _ = (epoch, transaction_id);
166        self.edge_type(id)
167    }
168
169    // --- Index introspection ---
170
171    /// Returns `true` if a property index exists for the given property.
172    ///
173    /// The default returns `false`, which is correct for stores without indexes.
174    fn has_property_index(&self, _property: &str) -> bool {
175        false
176    }
177
178    // --- Filtered search ---
179
180    /// Finds all nodes with a specific property value. Uses indexes when available.
181    fn find_nodes_by_property(&self, property: &str, value: &Value) -> Vec<NodeId>;
182
183    /// Finds nodes matching multiple property equality conditions.
184    fn find_nodes_by_properties(&self, conditions: &[(&str, Value)]) -> Vec<NodeId>;
185
186    /// Finds nodes whose property value falls within a range.
187    fn find_nodes_in_range(
188        &self,
189        property: &str,
190        min: Option<&Value>,
191        max: Option<&Value>,
192        min_inclusive: bool,
193        max_inclusive: bool,
194    ) -> Vec<NodeId>;
195
196    // --- Zone maps (skip pruning) ---
197
198    /// Returns `true` if a node property predicate might match any nodes.
199    /// Uses zone maps for early filtering.
200    fn node_property_might_match(
201        &self,
202        property: &PropertyKey,
203        op: CompareOp,
204        value: &Value,
205    ) -> bool;
206
207    /// Returns `true` if an edge property predicate might match any edges.
208    fn edge_property_might_match(
209        &self,
210        property: &PropertyKey,
211        op: CompareOp,
212        value: &Value,
213    ) -> bool;
214
215    // --- Statistics (for cost-based optimizer) ---
216
217    /// Returns the current statistics snapshot (cheap Arc clone).
218    fn statistics(&self) -> Arc<Statistics>;
219
220    /// Estimates cardinality for a label scan.
221    fn estimate_label_cardinality(&self, label: &str) -> f64;
222
223    /// Estimates average degree for an edge type.
224    fn estimate_avg_degree(&self, edge_type: &str, outgoing: bool) -> f64;
225
226    // --- Epoch ---
227
228    /// Returns the current MVCC epoch.
229    fn current_epoch(&self) -> EpochId;
230
231    // --- Schema introspection ---
232
233    /// Returns all label names in the database.
234    fn all_labels(&self) -> Vec<String> {
235        Vec::new()
236    }
237
238    /// Returns all edge type names in the database.
239    fn all_edge_types(&self) -> Vec<String> {
240        Vec::new()
241    }
242
243    /// Returns all property key names used in the database.
244    fn all_property_keys(&self) -> Vec<String> {
245        Vec::new()
246    }
247
248    // --- Visibility checks (fast path, avoids building full entities) ---
249
250    /// Checks if a node is visible at the given epoch without building the full Node.
251    ///
252    /// More efficient than `get_node_at_epoch(...).is_some()` because it skips
253    /// label and property loading. Override in concrete stores for optimal
254    /// performance.
255    fn is_node_visible_at_epoch(&self, id: NodeId, epoch: EpochId) -> bool {
256        self.get_node_at_epoch(id, epoch).is_some()
257    }
258
259    /// Checks if a node is visible to a specific transaction without building
260    /// the full Node.
261    fn is_node_visible_versioned(
262        &self,
263        id: NodeId,
264        epoch: EpochId,
265        transaction_id: TransactionId,
266    ) -> bool {
267        self.get_node_versioned(id, epoch, transaction_id).is_some()
268    }
269
270    /// Checks if an edge is visible at the given epoch without building the full Edge.
271    ///
272    /// More efficient than `get_edge_at_epoch(...).is_some()` because it skips
273    /// type name resolution and property loading. Override in concrete stores
274    /// for optimal performance.
275    fn is_edge_visible_at_epoch(&self, id: EdgeId, epoch: EpochId) -> bool {
276        self.get_edge_at_epoch(id, epoch).is_some()
277    }
278
279    /// Checks if an edge is visible to a specific transaction without building
280    /// the full Edge.
281    fn is_edge_visible_versioned(
282        &self,
283        id: EdgeId,
284        epoch: EpochId,
285        transaction_id: TransactionId,
286    ) -> bool {
287        self.get_edge_versioned(id, epoch, transaction_id).is_some()
288    }
289
290    /// Filters node IDs to only those visible at the given epoch (batch).
291    ///
292    /// More efficient than per-node calls because implementations can hold
293    /// a single lock for the entire batch.
294    fn filter_visible_node_ids(&self, ids: &[NodeId], epoch: EpochId) -> Vec<NodeId> {
295        ids.iter()
296            .copied()
297            .filter(|id| self.is_node_visible_at_epoch(*id, epoch))
298            .collect()
299    }
300
301    /// Filters node IDs to only those visible to a transaction (batch).
302    fn filter_visible_node_ids_versioned(
303        &self,
304        ids: &[NodeId],
305        epoch: EpochId,
306        transaction_id: TransactionId,
307    ) -> Vec<NodeId> {
308        ids.iter()
309            .copied()
310            .filter(|id| self.is_node_visible_versioned(*id, epoch, transaction_id))
311            .collect()
312    }
313
314    // --- History ---
315
316    /// Returns all versions of a node with their creation/deletion epochs, newest first.
317    ///
318    /// Each entry is `(created_epoch, deleted_epoch, Node)`. Properties and labels
319    /// reflect the current state (they are not versioned per-epoch).
320    ///
321    /// Default returns empty (not all backends track version history).
322    fn get_node_history(&self, _id: NodeId) -> Vec<(EpochId, Option<EpochId>, Node)> {
323        Vec::new()
324    }
325
326    /// Returns all versions of an edge with their creation/deletion epochs, newest first.
327    ///
328    /// Each entry is `(created_epoch, deleted_epoch, Edge)`. Properties reflect
329    /// the current state (they are not versioned per-epoch).
330    ///
331    /// Default returns empty (not all backends track version history).
332    fn get_edge_history(&self, _id: EdgeId) -> Vec<(EpochId, Option<EpochId>, Edge)> {
333        Vec::new()
334    }
335}
336
337/// Index-backed search capabilities that an LPG store may optionally provide.
338///
339/// Keeps the base [`GraphStore`] scoped to graph-structure operations. Stores
340/// that back text or vector indexes implement these methods with real search
341/// logic; stores that don't (columnar bases, projections, RDF adapters) accept
342/// the no-op defaults and the executor falls through to per-row evaluation.
343///
344/// # Symmetric text and vector APIs
345///
346/// `text_search` and `vector_search` are peer operations returning owned
347/// `Vec<(NodeId, f64)>`. The planner decides strategy based on `has_*_index`
348/// and `vector_index_metric` introspection; the store executes the chosen
349/// plan, falling back to brute-force internally when the request is valid
350/// but no matching index exists.
351pub trait GraphStoreSearch: GraphStore {
352    // --- Text search (BM25) ---
353
354    /// Returns true if a BM25 text index exists for the given label and property.
355    #[cfg(feature = "text-index")]
356    #[must_use]
357    fn has_text_index(&self, _label: &str, _property: &str) -> bool {
358        false
359    }
360
361    /// Scores a single document against a text query for per-row filter evaluation.
362    ///
363    /// Returns `None` when no text index exists for the (label, property) pair.
364    /// The planner calls this when pushdown is unavailable, for example when
365    /// the text predicate follows a traversal instead of a bare label scan.
366    #[cfg(feature = "text-index")]
367    fn score_text(
368        &self,
369        _node_id: NodeId,
370        _label: &str,
371        _property: &str,
372        _query: &str,
373    ) -> Option<f64> {
374        None
375    }
376
377    /// Returns the top-`k` documents by BM25 score for a text query.
378    ///
379    /// Results are sorted by score descending. Returns an empty vec when no
380    /// text index exists, so the caller can fall back to a slower path.
381    #[cfg(feature = "text-index")]
382    fn text_search(
383        &self,
384        _label: &str,
385        _property: &str,
386        _query: &str,
387        _k: usize,
388    ) -> Vec<(NodeId, f64)> {
389        Vec::new()
390    }
391
392    /// Returns every document whose BM25 score meets or exceeds a threshold.
393    #[cfg(feature = "text-index")]
394    fn text_search_with_threshold(
395        &self,
396        _label: &str,
397        _property: &str,
398        _query: &str,
399        _threshold: f64,
400    ) -> Vec<(NodeId, f64)> {
401        Vec::new()
402    }
403
404    // --- Vector search (HNSW or brute force) ---
405
406    /// Returns true if a vector index exists for the given label and property.
407    #[cfg(feature = "vector-index")]
408    #[must_use]
409    fn has_vector_index(&self, _label: &str, _property: &str) -> bool {
410        false
411    }
412
413    /// Returns the distance metric of the vector index at (label, property), if any.
414    ///
415    /// The planner uses this to decide between an HNSW-accelerated plan and a
416    /// brute-force fallback: an index whose metric does not match the query's
417    /// requested metric cannot serve the query directly, so the planner either
418    /// routes to brute force or skips pushdown entirely.
419    #[cfg(feature = "vector-index")]
420    fn vector_index_metric(&self, _label: &str, _property: &str) -> Option<DistanceMetric> {
421        None
422    }
423
424    /// Returns the top-`k` nearest neighbors for a vector similarity search.
425    ///
426    /// `label` is optional: `None` searches every node that has the named
427    /// property. The store uses HNSW when an index exists for (label, property)
428    /// whose metric matches `metric`; otherwise it falls back to brute force.
429    ///
430    /// Results are sorted by distance ascending (nearest first). Returns an
431    /// empty vec when neither an index nor any indexable property is found.
432    #[cfg(feature = "vector-index")]
433    fn vector_search(
434        &self,
435        _label: Option<&str>,
436        _property: &str,
437        _query: &[f32],
438        _k: usize,
439        _metric: DistanceMetric,
440    ) -> Vec<(NodeId, f64)> {
441        Vec::new()
442    }
443
444    /// Returns every node whose distance to the query vector is at or below a threshold.
445    #[cfg(feature = "vector-index")]
446    fn vector_search_with_threshold(
447        &self,
448        _label: Option<&str>,
449        _property: &str,
450        _query: &[f32],
451        _threshold: f64,
452        _metric: DistanceMetric,
453    ) -> Vec<(NodeId, f64)> {
454        Vec::new()
455    }
456}
457
458/// Write operations for graph mutation.
459///
460/// Separated from [`GraphStore`] so read-only wrappers (snapshots, read
461/// replicas) can implement only `GraphStore`. Any mutable store is also
462/// readable via the supertrait bound.
463pub trait GraphStoreMut: GraphStoreSearch {
464    // --- Node creation ---
465
466    /// Creates a new node with the given labels.
467    fn create_node(&self, labels: &[&str]) -> NodeId;
468
469    /// Creates a new node within a transaction context.
470    fn create_node_versioned(
471        &self,
472        labels: &[&str],
473        epoch: EpochId,
474        transaction_id: TransactionId,
475    ) -> NodeId;
476
477    // --- Edge creation ---
478
479    /// Creates a new edge between two nodes.
480    fn create_edge(&self, src: NodeId, dst: NodeId, edge_type: &str) -> EdgeId;
481
482    /// Creates a new edge within a transaction context.
483    fn create_edge_versioned(
484        &self,
485        src: NodeId,
486        dst: NodeId,
487        edge_type: &str,
488        epoch: EpochId,
489        transaction_id: TransactionId,
490    ) -> EdgeId;
491
492    /// Creates multiple edges in batch (single lock acquisition).
493    fn batch_create_edges(&self, edges: &[(NodeId, NodeId, &str)]) -> Vec<EdgeId>;
494
495    // --- Deletion ---
496
497    /// Deletes a node. Returns `true` if the node existed.
498    fn delete_node(&self, id: NodeId) -> bool;
499
500    /// Deletes a node within a transaction context. Returns `true` if the node existed.
501    fn delete_node_versioned(
502        &self,
503        id: NodeId,
504        epoch: EpochId,
505        transaction_id: TransactionId,
506    ) -> bool;
507
508    /// Deletes all edges connected to a node (DETACH DELETE).
509    fn delete_node_edges(&self, node_id: NodeId);
510
511    /// Deletes an edge. Returns `true` if the edge existed.
512    fn delete_edge(&self, id: EdgeId) -> bool;
513
514    /// Deletes an edge within a transaction context. Returns `true` if the edge existed.
515    fn delete_edge_versioned(
516        &self,
517        id: EdgeId,
518        epoch: EpochId,
519        transaction_id: TransactionId,
520    ) -> bool;
521
522    // --- Property mutation ---
523
524    /// Sets a property on a node.
525    fn set_node_property(&self, id: NodeId, key: &str, value: Value);
526
527    /// Sets a property on an edge.
528    fn set_edge_property(&self, id: EdgeId, key: &str, value: Value);
529
530    /// Sets a node property within a transaction, recording the previous value
531    /// so it can be restored on rollback.
532    ///
533    /// Default delegates to [`set_node_property`](Self::set_node_property).
534    fn set_node_property_versioned(
535        &self,
536        id: NodeId,
537        key: &str,
538        value: Value,
539        _transaction_id: TransactionId,
540    ) {
541        self.set_node_property(id, key, value);
542    }
543
544    /// Sets an edge property within a transaction, recording the previous value
545    /// so it can be restored on rollback.
546    ///
547    /// Default delegates to [`set_edge_property`](Self::set_edge_property).
548    fn set_edge_property_versioned(
549        &self,
550        id: EdgeId,
551        key: &str,
552        value: Value,
553        _transaction_id: TransactionId,
554    ) {
555        self.set_edge_property(id, key, value);
556    }
557
558    /// Removes a property from a node. Returns the previous value if it existed.
559    fn remove_node_property(&self, id: NodeId, key: &str) -> Option<Value>;
560
561    /// Removes a property from an edge. Returns the previous value if it existed.
562    fn remove_edge_property(&self, id: EdgeId, key: &str) -> Option<Value>;
563
564    /// Removes a node property within a transaction, recording the previous value
565    /// so it can be restored on rollback.
566    ///
567    /// Default delegates to [`remove_node_property`](Self::remove_node_property).
568    fn remove_node_property_versioned(
569        &self,
570        id: NodeId,
571        key: &str,
572        _transaction_id: TransactionId,
573    ) -> Option<Value> {
574        self.remove_node_property(id, key)
575    }
576
577    /// Removes an edge property within a transaction, recording the previous value
578    /// so it can be restored on rollback.
579    ///
580    /// Default delegates to [`remove_edge_property`](Self::remove_edge_property).
581    fn remove_edge_property_versioned(
582        &self,
583        id: EdgeId,
584        key: &str,
585        _transaction_id: TransactionId,
586    ) -> Option<Value> {
587        self.remove_edge_property(id, key)
588    }
589
590    // --- Label mutation ---
591
592    /// Adds a label to a node. Returns `true` if the label was new.
593    fn add_label(&self, node_id: NodeId, label: &str) -> bool;
594
595    /// Removes a label from a node. Returns `true` if the label existed.
596    fn remove_label(&self, node_id: NodeId, label: &str) -> bool;
597
598    /// Adds a label within a transaction, recording the change for rollback.
599    ///
600    /// Default delegates to [`add_label`](Self::add_label).
601    fn add_label_versioned(
602        &self,
603        node_id: NodeId,
604        label: &str,
605        _transaction_id: TransactionId,
606    ) -> bool {
607        self.add_label(node_id, label)
608    }
609
610    /// Removes a label within a transaction, recording the change for rollback.
611    ///
612    /// Default delegates to [`remove_label`](Self::remove_label).
613    fn remove_label_versioned(
614        &self,
615        node_id: NodeId,
616        label: &str,
617        _transaction_id: TransactionId,
618    ) -> bool {
619        self.remove_label(node_id, label)
620    }
621
622    // --- Convenience (with default implementations) ---
623
624    /// Creates a new node with labels and properties in one call.
625    ///
626    /// The default implementation calls [`create_node`](Self::create_node)
627    /// followed by [`set_node_property`](Self::set_node_property) for each
628    /// property. Implementations may override for atomicity or performance.
629    fn create_node_with_props(
630        &self,
631        labels: &[&str],
632        properties: &[(PropertyKey, Value)],
633    ) -> NodeId {
634        let id = self.create_node(labels);
635        for (key, value) in properties {
636            self.set_node_property(id, key.as_str(), value.clone());
637        }
638        id
639    }
640
641    /// Creates a new edge with properties in one call.
642    ///
643    /// The default implementation calls [`create_edge`](Self::create_edge)
644    /// followed by [`set_edge_property`](Self::set_edge_property) for each
645    /// property. Implementations may override for atomicity or performance.
646    fn create_edge_with_props(
647        &self,
648        src: NodeId,
649        dst: NodeId,
650        edge_type: &str,
651        properties: &[(PropertyKey, Value)],
652    ) -> EdgeId {
653        let id = self.create_edge(src, dst, edge_type);
654        for (key, value) in properties {
655            self.set_edge_property(id, key.as_str(), value.clone());
656        }
657        id
658    }
659}
660
661/// A no-op [`GraphStore`] that returns empty results for all queries.
662///
663/// Used by the RDF planner to satisfy the expression evaluator's store
664/// requirement. SPARQL expression functions (STR, LANG, DATATYPE, etc.)
665/// operate on already-materialized values in DataChunk columns and never
666/// call store methods.
667pub struct NullGraphStore;
668
669impl GraphStore for NullGraphStore {
670    fn get_node(&self, _: NodeId) -> Option<Node> {
671        None
672    }
673    fn get_edge(&self, _: EdgeId) -> Option<Edge> {
674        None
675    }
676    fn get_node_versioned(&self, _: NodeId, _: EpochId, _: TransactionId) -> Option<Node> {
677        None
678    }
679    fn get_edge_versioned(&self, _: EdgeId, _: EpochId, _: TransactionId) -> Option<Edge> {
680        None
681    }
682    fn get_node_at_epoch(&self, _: NodeId, _: EpochId) -> Option<Node> {
683        None
684    }
685    fn get_edge_at_epoch(&self, _: EdgeId, _: EpochId) -> Option<Edge> {
686        None
687    }
688    fn get_node_property(&self, _: NodeId, _: &PropertyKey) -> Option<Value> {
689        None
690    }
691    fn get_edge_property(&self, _: EdgeId, _: &PropertyKey) -> Option<Value> {
692        None
693    }
694    fn get_node_property_batch(&self, ids: &[NodeId], _: &PropertyKey) -> Vec<Option<Value>> {
695        vec![None; ids.len()]
696    }
697    fn get_nodes_properties_batch(&self, ids: &[NodeId]) -> Vec<FxHashMap<PropertyKey, Value>> {
698        vec![FxHashMap::default(); ids.len()]
699    }
700    fn get_nodes_properties_selective_batch(
701        &self,
702        ids: &[NodeId],
703        _: &[PropertyKey],
704    ) -> Vec<FxHashMap<PropertyKey, Value>> {
705        vec![FxHashMap::default(); ids.len()]
706    }
707    fn get_edges_properties_selective_batch(
708        &self,
709        ids: &[EdgeId],
710        _: &[PropertyKey],
711    ) -> Vec<FxHashMap<PropertyKey, Value>> {
712        vec![FxHashMap::default(); ids.len()]
713    }
714    fn neighbors(&self, _: NodeId, _: Direction) -> Vec<NodeId> {
715        Vec::new()
716    }
717    fn edges_from(&self, _: NodeId, _: Direction) -> Vec<(NodeId, EdgeId)> {
718        Vec::new()
719    }
720    fn out_degree(&self, _: NodeId) -> usize {
721        0
722    }
723    fn in_degree(&self, _: NodeId) -> usize {
724        0
725    }
726    fn has_backward_adjacency(&self) -> bool {
727        false
728    }
729    fn node_ids(&self) -> Vec<NodeId> {
730        Vec::new()
731    }
732    fn nodes_by_label(&self, _: &str) -> Vec<NodeId> {
733        Vec::new()
734    }
735    fn node_count(&self) -> usize {
736        0
737    }
738    fn edge_count(&self) -> usize {
739        0
740    }
741    fn edge_type(&self, _: EdgeId) -> Option<ArcStr> {
742        None
743    }
744    fn find_nodes_by_property(&self, _: &str, _: &Value) -> Vec<NodeId> {
745        Vec::new()
746    }
747    fn find_nodes_by_properties(&self, _: &[(&str, Value)]) -> Vec<NodeId> {
748        Vec::new()
749    }
750    fn find_nodes_in_range(
751        &self,
752        _: &str,
753        _: Option<&Value>,
754        _: Option<&Value>,
755        _: bool,
756        _: bool,
757    ) -> Vec<NodeId> {
758        Vec::new()
759    }
760    fn node_property_might_match(&self, _: &PropertyKey, _: CompareOp, _: &Value) -> bool {
761        false
762    }
763    fn edge_property_might_match(&self, _: &PropertyKey, _: CompareOp, _: &Value) -> bool {
764        false
765    }
766    fn statistics(&self) -> Arc<Statistics> {
767        Arc::new(Statistics::default())
768    }
769    fn estimate_label_cardinality(&self, _: &str) -> f64 {
770        0.0
771    }
772    fn estimate_avg_degree(&self, _: &str, _: bool) -> f64 {
773        0.0
774    }
775    fn current_epoch(&self) -> EpochId {
776        EpochId(0)
777    }
778}
779
780impl GraphStoreSearch for NullGraphStore {}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use std::sync::Mutex;
786
787    #[test]
788    fn null_graph_store_point_lookups() {
789        let store = NullGraphStore;
790        let nid = NodeId(1);
791        let eid = EdgeId(1);
792        let epoch = EpochId(0);
793        let txn = TransactionId(1);
794
795        assert!(store.get_node(nid).is_none());
796        assert!(store.get_edge(eid).is_none());
797        assert!(store.get_node_versioned(nid, epoch, txn).is_none());
798        assert!(store.get_edge_versioned(eid, epoch, txn).is_none());
799        assert!(store.get_node_at_epoch(nid, epoch).is_none());
800        assert!(store.get_edge_at_epoch(eid, epoch).is_none());
801    }
802
803    #[test]
804    fn null_graph_store_property_access() {
805        let store = NullGraphStore;
806        let nid = NodeId(1);
807        let eid = EdgeId(1);
808        let key = PropertyKey::from("name");
809
810        assert!(store.get_node_property(nid, &key).is_none());
811        assert!(store.get_edge_property(eid, &key).is_none());
812        assert_eq!(
813            store.get_node_property_batch(&[nid, NodeId(2)], &key),
814            vec![None, None]
815        );
816
817        let node_props = store.get_nodes_properties_batch(&[nid]);
818        assert_eq!(node_props.len(), 1);
819        assert!(node_props[0].is_empty());
820
821        let selective =
822            store.get_nodes_properties_selective_batch(&[nid], std::slice::from_ref(&key));
823        assert_eq!(selective.len(), 1);
824        assert!(selective[0].is_empty());
825
826        let edge_selective = store.get_edges_properties_selective_batch(&[eid], &[key]);
827        assert_eq!(edge_selective.len(), 1);
828        assert!(edge_selective[0].is_empty());
829    }
830
831    #[test]
832    fn null_graph_store_traversal() {
833        let store = NullGraphStore;
834        let nid = NodeId(1);
835
836        assert!(store.neighbors(nid, Direction::Outgoing).is_empty());
837        assert!(store.edges_from(nid, Direction::Incoming).is_empty());
838        assert_eq!(store.out_degree(nid), 0);
839        assert_eq!(store.in_degree(nid), 0);
840        assert!(!store.has_backward_adjacency());
841    }
842
843    #[test]
844    fn null_graph_store_scans_and_counts() {
845        let store = NullGraphStore;
846
847        assert!(store.node_ids().is_empty());
848        assert!(store.all_node_ids().is_empty());
849        assert!(store.nodes_by_label("Person").is_empty());
850        assert_eq!(store.node_count(), 0);
851        assert_eq!(store.edge_count(), 0);
852    }
853
854    #[test]
855    fn null_graph_store_metadata_and_schema() {
856        let store = NullGraphStore;
857        let eid = EdgeId(1);
858        let epoch = EpochId(0);
859        let txn = TransactionId(1);
860
861        assert!(store.edge_type(eid).is_none());
862        assert!(store.edge_type_versioned(eid, epoch, txn).is_none());
863        assert!(!store.has_property_index("name"));
864        assert!(store.all_labels().is_empty());
865        assert!(store.all_edge_types().is_empty());
866        assert!(store.all_property_keys().is_empty());
867    }
868
869    #[test]
870    fn null_graph_store_search() {
871        let store = NullGraphStore;
872        let key = PropertyKey::from("age");
873        let val = Value::Int64(30);
874
875        assert!(store.find_nodes_by_property("age", &val).is_empty());
876        assert!(
877            store
878                .find_nodes_by_properties(&[("age", val.clone())])
879                .is_empty()
880        );
881        assert!(
882            store
883                .find_nodes_in_range("age", Some(&val), None, true, false)
884                .is_empty()
885        );
886        assert!(!store.node_property_might_match(&key, CompareOp::Eq, &val));
887        assert!(!store.edge_property_might_match(&key, CompareOp::Eq, &val));
888    }
889
890    #[test]
891    fn null_graph_store_statistics() {
892        let store = NullGraphStore;
893
894        let _stats = store.statistics();
895        assert_eq!(store.estimate_label_cardinality("Person"), 0.0);
896        assert_eq!(store.estimate_avg_degree("KNOWS", true), 0.0);
897        assert_eq!(store.current_epoch(), EpochId(0));
898    }
899
900    #[test]
901    fn null_graph_store_visibility() {
902        let store = NullGraphStore;
903        let nid = NodeId(1);
904        let eid = EdgeId(1);
905        let epoch = EpochId(0);
906        let txn = TransactionId(1);
907
908        assert!(!store.is_node_visible_at_epoch(nid, epoch));
909        assert!(!store.is_node_visible_versioned(nid, epoch, txn));
910        assert!(!store.is_edge_visible_at_epoch(eid, epoch));
911        assert!(!store.is_edge_visible_versioned(eid, epoch, txn));
912
913        assert!(
914            store
915                .filter_visible_node_ids(&[nid, NodeId(2)], epoch)
916                .is_empty()
917        );
918        assert!(
919            store
920                .filter_visible_node_ids_versioned(&[nid], epoch, txn)
921                .is_empty()
922        );
923    }
924
925    #[test]
926    fn null_graph_store_history() {
927        let store = NullGraphStore;
928
929        assert!(store.get_node_history(NodeId(1)).is_empty());
930        assert!(store.get_edge_history(EdgeId(1)).is_empty());
931    }
932
933    /// Minimal in-memory store used to exercise the default method bodies on
934    /// `GraphStoreMut`. Concrete production stores override every default, so
935    /// without this harness those default bodies would stay uncovered.
936    #[derive(Default)]
937    struct TestMutStore {
938        inner: Mutex<TestMutInner>,
939    }
940
941    #[derive(Default)]
942    struct TestMutInner {
943        next_node: u64,
944        next_edge: u64,
945        nodes: Vec<Node>,
946        edges: Vec<Edge>,
947    }
948
949    impl TestMutStore {
950        fn new() -> Self {
951            Self::default()
952        }
953
954        fn find_node(&self, id: NodeId) -> Option<Node> {
955            self.inner
956                .lock()
957                .unwrap()
958                .nodes
959                .iter()
960                .find(|n| n.id == id)
961                .cloned()
962        }
963
964        fn find_edge(&self, id: EdgeId) -> Option<Edge> {
965            self.inner
966                .lock()
967                .unwrap()
968                .edges
969                .iter()
970                .find(|e| e.id == id)
971                .cloned()
972        }
973    }
974
975    impl GraphStore for TestMutStore {
976        fn get_node(&self, id: NodeId) -> Option<Node> {
977            self.find_node(id)
978        }
979        fn get_edge(&self, id: EdgeId) -> Option<Edge> {
980            self.find_edge(id)
981        }
982        fn get_node_versioned(&self, id: NodeId, _: EpochId, _: TransactionId) -> Option<Node> {
983            self.find_node(id)
984        }
985        fn get_edge_versioned(&self, id: EdgeId, _: EpochId, _: TransactionId) -> Option<Edge> {
986            self.find_edge(id)
987        }
988        fn get_node_at_epoch(&self, id: NodeId, _: EpochId) -> Option<Node> {
989            self.find_node(id)
990        }
991        fn get_edge_at_epoch(&self, id: EdgeId, _: EpochId) -> Option<Edge> {
992            self.find_edge(id)
993        }
994        fn get_node_property(&self, id: NodeId, key: &PropertyKey) -> Option<Value> {
995            self.find_node(id)
996                .and_then(|n| n.properties.get(key).cloned())
997        }
998        fn get_edge_property(&self, id: EdgeId, key: &PropertyKey) -> Option<Value> {
999            self.find_edge(id)
1000                .and_then(|e| e.properties.get(key).cloned())
1001        }
1002        fn get_node_property_batch(&self, ids: &[NodeId], key: &PropertyKey) -> Vec<Option<Value>> {
1003            ids.iter()
1004                .map(|id| self.get_node_property(*id, key))
1005                .collect()
1006        }
1007        fn get_nodes_properties_batch(&self, ids: &[NodeId]) -> Vec<FxHashMap<PropertyKey, Value>> {
1008            ids.iter()
1009                .map(|id| {
1010                    let mut map = FxHashMap::default();
1011                    if let Some(n) = self.find_node(*id) {
1012                        for (k, v) in n.properties.iter() {
1013                            map.insert(k.clone(), v.clone());
1014                        }
1015                    }
1016                    map
1017                })
1018                .collect()
1019        }
1020        fn get_nodes_properties_selective_batch(
1021            &self,
1022            ids: &[NodeId],
1023            _: &[PropertyKey],
1024        ) -> Vec<FxHashMap<PropertyKey, Value>> {
1025            vec![FxHashMap::default(); ids.len()]
1026        }
1027        fn get_edges_properties_selective_batch(
1028            &self,
1029            ids: &[EdgeId],
1030            _: &[PropertyKey],
1031        ) -> Vec<FxHashMap<PropertyKey, Value>> {
1032            vec![FxHashMap::default(); ids.len()]
1033        }
1034        fn neighbors(&self, _: NodeId, _: Direction) -> Vec<NodeId> {
1035            Vec::new()
1036        }
1037        fn edges_from(&self, _: NodeId, _: Direction) -> Vec<(NodeId, EdgeId)> {
1038            Vec::new()
1039        }
1040        fn out_degree(&self, _: NodeId) -> usize {
1041            0
1042        }
1043        fn in_degree(&self, _: NodeId) -> usize {
1044            0
1045        }
1046        fn has_backward_adjacency(&self) -> bool {
1047            false
1048        }
1049        fn node_ids(&self) -> Vec<NodeId> {
1050            self.inner
1051                .lock()
1052                .unwrap()
1053                .nodes
1054                .iter()
1055                .map(|n| n.id)
1056                .collect()
1057        }
1058        fn nodes_by_label(&self, _: &str) -> Vec<NodeId> {
1059            Vec::new()
1060        }
1061        fn node_count(&self) -> usize {
1062            self.inner.lock().unwrap().nodes.len()
1063        }
1064        fn edge_count(&self) -> usize {
1065            self.inner.lock().unwrap().edges.len()
1066        }
1067        fn edge_type(&self, id: EdgeId) -> Option<ArcStr> {
1068            self.find_edge(id).map(|e| e.edge_type)
1069        }
1070        fn find_nodes_by_property(&self, _: &str, _: &Value) -> Vec<NodeId> {
1071            Vec::new()
1072        }
1073        fn find_nodes_by_properties(&self, _: &[(&str, Value)]) -> Vec<NodeId> {
1074            Vec::new()
1075        }
1076        fn find_nodes_in_range(
1077            &self,
1078            _: &str,
1079            _: Option<&Value>,
1080            _: Option<&Value>,
1081            _: bool,
1082            _: bool,
1083        ) -> Vec<NodeId> {
1084            Vec::new()
1085        }
1086        fn node_property_might_match(&self, _: &PropertyKey, _: CompareOp, _: &Value) -> bool {
1087            true
1088        }
1089        fn edge_property_might_match(&self, _: &PropertyKey, _: CompareOp, _: &Value) -> bool {
1090            true
1091        }
1092        fn statistics(&self) -> Arc<Statistics> {
1093            Arc::new(Statistics::default())
1094        }
1095        fn estimate_label_cardinality(&self, _: &str) -> f64 {
1096            0.0
1097        }
1098        fn estimate_avg_degree(&self, _: &str, _: bool) -> f64 {
1099            0.0
1100        }
1101        fn current_epoch(&self) -> EpochId {
1102            EpochId(0)
1103        }
1104    }
1105
1106    impl GraphStoreSearch for TestMutStore {}
1107
1108    impl GraphStoreMut for TestMutStore {
1109        fn create_node(&self, labels: &[&str]) -> NodeId {
1110            let mut inner = self.inner.lock().unwrap();
1111            inner.next_node += 1;
1112            let id = NodeId(inner.next_node);
1113            let mut node = Node::new(id);
1114            for label in labels {
1115                node.add_label(*label);
1116            }
1117            inner.nodes.push(node);
1118            id
1119        }
1120        fn create_node_versioned(&self, labels: &[&str], _: EpochId, _: TransactionId) -> NodeId {
1121            self.create_node(labels)
1122        }
1123        fn create_edge(&self, src: NodeId, dst: NodeId, edge_type: &str) -> EdgeId {
1124            let mut inner = self.inner.lock().unwrap();
1125            inner.next_edge += 1;
1126            let id = EdgeId(inner.next_edge);
1127            inner.edges.push(Edge::new(id, src, dst, edge_type));
1128            id
1129        }
1130        fn create_edge_versioned(
1131            &self,
1132            src: NodeId,
1133            dst: NodeId,
1134            edge_type: &str,
1135            _: EpochId,
1136            _: TransactionId,
1137        ) -> EdgeId {
1138            self.create_edge(src, dst, edge_type)
1139        }
1140        fn batch_create_edges(&self, edges: &[(NodeId, NodeId, &str)]) -> Vec<EdgeId> {
1141            edges
1142                .iter()
1143                .map(|(s, d, t)| self.create_edge(*s, *d, t))
1144                .collect()
1145        }
1146        fn delete_node(&self, id: NodeId) -> bool {
1147            let mut inner = self.inner.lock().unwrap();
1148            if let Some(pos) = inner.nodes.iter().position(|n| n.id == id) {
1149                inner.nodes.remove(pos);
1150                true
1151            } else {
1152                false
1153            }
1154        }
1155        fn delete_node_versioned(&self, id: NodeId, _: EpochId, _: TransactionId) -> bool {
1156            self.delete_node(id)
1157        }
1158        fn delete_node_edges(&self, node_id: NodeId) {
1159            let mut inner = self.inner.lock().unwrap();
1160            inner.edges.retain(|e| e.src != node_id && e.dst != node_id);
1161        }
1162        fn delete_edge(&self, id: EdgeId) -> bool {
1163            let mut inner = self.inner.lock().unwrap();
1164            if let Some(pos) = inner.edges.iter().position(|e| e.id == id) {
1165                inner.edges.remove(pos);
1166                true
1167            } else {
1168                false
1169            }
1170        }
1171        fn delete_edge_versioned(&self, id: EdgeId, _: EpochId, _: TransactionId) -> bool {
1172            self.delete_edge(id)
1173        }
1174        fn set_node_property(&self, id: NodeId, key: &str, value: Value) {
1175            let mut inner = self.inner.lock().unwrap();
1176            if let Some(node) = inner.nodes.iter_mut().find(|n| n.id == id) {
1177                node.set_property(key, value);
1178            }
1179        }
1180        fn set_edge_property(&self, id: EdgeId, key: &str, value: Value) {
1181            let mut inner = self.inner.lock().unwrap();
1182            if let Some(edge) = inner.edges.iter_mut().find(|e| e.id == id) {
1183                edge.set_property(key, value);
1184            }
1185        }
1186        fn remove_node_property(&self, id: NodeId, key: &str) -> Option<Value> {
1187            let mut inner = self.inner.lock().unwrap();
1188            inner
1189                .nodes
1190                .iter_mut()
1191                .find(|n| n.id == id)
1192                .and_then(|n| n.remove_property(key))
1193        }
1194        fn remove_edge_property(&self, id: EdgeId, key: &str) -> Option<Value> {
1195            let mut inner = self.inner.lock().unwrap();
1196            inner
1197                .edges
1198                .iter_mut()
1199                .find(|e| e.id == id)
1200                .and_then(|e| e.remove_property(key))
1201        }
1202        fn add_label(&self, node_id: NodeId, label: &str) -> bool {
1203            let mut inner = self.inner.lock().unwrap();
1204            if let Some(node) = inner.nodes.iter_mut().find(|n| n.id == node_id) {
1205                if node.has_label(label) {
1206                    false
1207                } else {
1208                    node.add_label(label);
1209                    true
1210                }
1211            } else {
1212                false
1213            }
1214        }
1215        fn remove_label(&self, node_id: NodeId, label: &str) -> bool {
1216            let mut inner = self.inner.lock().unwrap();
1217            inner
1218                .nodes
1219                .iter_mut()
1220                .find(|n| n.id == node_id)
1221                .is_some_and(|n| n.remove_label(label))
1222        }
1223    }
1224
1225    #[test]
1226    fn test_mut_store_default_set_versioned_property_delegates() {
1227        let store = TestMutStore::new();
1228        let id = store.create_node(&["Person"]);
1229        let key = PropertyKey::from("name");
1230        let txn = TransactionId(7);
1231
1232        // Default impl of set_node_property_versioned calls set_node_property.
1233        store.set_node_property_versioned(id, "name", Value::from("Vincent"), txn);
1234        assert_eq!(
1235            store.get_node_property(id, &key),
1236            Some(Value::from("Vincent"))
1237        );
1238
1239        let edge_id = {
1240            let src = store.create_node(&["Person"]);
1241            let dst = store.create_node(&["City"]);
1242            store.create_edge(src, dst, "LIVES_IN")
1243        };
1244        let since = PropertyKey::from("since");
1245        store.set_edge_property_versioned(edge_id, "since", Value::Int64(1994), txn);
1246        assert_eq!(
1247            store.get_edge_property(edge_id, &since),
1248            Some(Value::Int64(1994))
1249        );
1250    }
1251
1252    #[test]
1253    fn test_mut_store_default_remove_versioned_property_delegates() {
1254        let store = TestMutStore::new();
1255        let txn = TransactionId(11);
1256
1257        let node_id = store.create_node(&["Person"]);
1258        store.set_node_property(node_id, "city", Value::from("Amsterdam"));
1259        let removed = store.remove_node_property_versioned(node_id, "city", txn);
1260        assert_eq!(removed, Some(Value::from("Amsterdam")));
1261        assert!(
1262            store
1263                .get_node_property(node_id, &PropertyKey::from("city"))
1264                .is_none()
1265        );
1266
1267        let missing = store.remove_node_property_versioned(node_id, "absent", txn);
1268        assert!(missing.is_none());
1269
1270        let src = store.create_node(&["Person"]);
1271        let dst = store.create_node(&["Person"]);
1272        let edge_id = store.create_edge(src, dst, "KNOWS");
1273        store.set_edge_property(edge_id, "weight", Value::Int64(42));
1274        let removed_edge = store.remove_edge_property_versioned(edge_id, "weight", txn);
1275        assert_eq!(removed_edge, Some(Value::Int64(42)));
1276        let removed_again = store.remove_edge_property_versioned(edge_id, "weight", txn);
1277        assert!(removed_again.is_none());
1278    }
1279
1280    #[test]
1281    fn test_mut_store_default_label_versioned_delegates() {
1282        let store = TestMutStore::new();
1283        let txn = TransactionId(3);
1284        let id = store.create_node(&["Person"]);
1285
1286        // Adding a new label returns true, re-adding the same returns false.
1287        assert!(store.add_label_versioned(id, "Director", txn));
1288        assert!(!store.add_label_versioned(id, "Director", txn));
1289
1290        // Removing an existing label returns true, removing absent returns false.
1291        assert!(store.remove_label_versioned(id, "Director", txn));
1292        assert!(!store.remove_label_versioned(id, "Director", txn));
1293
1294        // Unknown node id yields false on both add and remove paths.
1295        let unknown = NodeId(9999);
1296        assert!(!store.add_label_versioned(unknown, "Ghost", txn));
1297        assert!(!store.remove_label_versioned(unknown, "Ghost", txn));
1298    }
1299
1300    #[test]
1301    fn test_mut_store_default_create_node_with_props() {
1302        let store = TestMutStore::new();
1303        let props = vec![
1304            (PropertyKey::from("name"), Value::from("Jules")),
1305            (PropertyKey::from("city"), Value::from("Paris")),
1306        ];
1307
1308        let id = store.create_node_with_props(&["Person"], &props);
1309        let node = store.get_node(id).expect("node should exist");
1310        assert!(node.has_label("Person"));
1311        assert_eq!(
1312            node.properties.get(&PropertyKey::from("name")),
1313            Some(&Value::from("Jules"))
1314        );
1315        assert_eq!(
1316            node.properties.get(&PropertyKey::from("city")),
1317            Some(&Value::from("Paris"))
1318        );
1319
1320        // Empty properties slice still produces a valid node.
1321        let bare = store.create_node_with_props(&["Person"], &[]);
1322        let bare_node = store.get_node(bare).expect("bare node should exist");
1323        assert!(bare_node.properties.is_empty());
1324    }
1325
1326    #[test]
1327    fn test_mut_store_default_create_edge_with_props() {
1328        let store = TestMutStore::new();
1329        let src = store.create_node_with_props(
1330            &["Person"],
1331            &[(PropertyKey::from("name"), Value::from("Mia"))],
1332        );
1333        let dst = store.create_node_with_props(
1334            &["City"],
1335            &[(PropertyKey::from("name"), Value::from("Berlin"))],
1336        );
1337        let props = vec![
1338            (PropertyKey::from("since"), Value::Int64(2021)),
1339            (PropertyKey::from("role"), Value::from("resident")),
1340        ];
1341
1342        let edge_id = store.create_edge_with_props(src, dst, "LIVES_IN", &props);
1343        let edge = store.get_edge(edge_id).expect("edge should exist");
1344        assert_eq!(edge.src, src);
1345        assert_eq!(edge.dst, dst);
1346        assert_eq!(edge.edge_type.as_str(), "LIVES_IN");
1347        assert_eq!(
1348            edge.properties.get(&PropertyKey::from("since")),
1349            Some(&Value::Int64(2021))
1350        );
1351        assert_eq!(
1352            edge.properties.get(&PropertyKey::from("role")),
1353            Some(&Value::from("resident"))
1354        );
1355
1356        // Confirm the edge type is also reachable through the read trait.
1357        assert_eq!(
1358            store
1359                .edge_type(edge_id)
1360                .as_ref()
1361                .map(arcstr::ArcStr::as_str),
1362            Some("LIVES_IN")
1363        );
1364
1365        // With no properties, default still produces an edge.
1366        let bare = store.create_edge_with_props(src, dst, "VISITED", &[]);
1367        let bare_edge = store.get_edge(bare).expect("bare edge should exist");
1368        assert!(bare_edge.properties.is_empty());
1369    }
1370
1371    #[test]
1372    fn test_mut_store_object_safe_dyn_dispatch() {
1373        // Exercise the object-safe contract: GraphStore methods through `dyn`.
1374        let store: Arc<dyn GraphStoreSearch> = Arc::new(TestMutStore::new());
1375        assert_eq!(store.node_count(), 0);
1376        assert_eq!(store.edge_count(), 0);
1377        assert!(store.node_ids().is_empty());
1378        assert!(store.get_node(NodeId(1)).is_none());
1379        assert_eq!(store.current_epoch(), EpochId(0));
1380    }
1381}