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