manifoldb_graph/traversal/
pattern.rs

1//! Path pattern matching.
2//!
3//! This module provides pattern-based path matching for complex graph queries.
4//! Patterns define sequences of edge types and optional node/edge filters
5//! that paths must match.
6
7// Allow expect - the invariant is guaranteed by the data structure
8#![allow(clippy::expect_used)]
9
10use std::collections::HashSet;
11
12use manifoldb_core::{Edge, EdgeId, EdgeType, EntityId};
13use manifoldb_storage::Transaction;
14
15use super::Direction;
16use crate::index::AdjacencyIndex;
17use crate::store::{EdgeStore, GraphResult};
18
19/// A filter for edges in a path step.
20#[derive(Debug, Clone)]
21pub enum StepFilter {
22    /// Match any edge.
23    Any,
24    /// Match edges of a specific type.
25    EdgeType(EdgeType),
26    /// Match edges of any of these types.
27    EdgeTypes(Vec<EdgeType>),
28    /// Custom filter with a predicate function.
29    /// Note: This variant stores a descriptive string for debugging.
30    Custom(String),
31}
32
33impl Default for StepFilter {
34    fn default() -> Self {
35        Self::Any
36    }
37}
38
39impl StepFilter {
40    /// Create a filter for a specific edge type.
41    pub fn edge_type(edge_type: impl Into<EdgeType>) -> Self {
42        Self::EdgeType(edge_type.into())
43    }
44
45    /// Create a filter for multiple edge types.
46    pub fn edge_types(types: impl IntoIterator<Item = EdgeType>) -> Self {
47        Self::EdgeTypes(types.into_iter().collect())
48    }
49
50    /// Check if an edge matches this filter.
51    pub fn matches(&self, edge: &Edge) -> bool {
52        match self {
53            Self::Any => true,
54            Self::EdgeType(et) => &edge.edge_type == et,
55            Self::EdgeTypes(types) => types.contains(&edge.edge_type),
56            Self::Custom(_) => true, // Custom filters need external predicate
57        }
58    }
59}
60
61impl From<&str> for StepFilter {
62    fn from(s: &str) -> Self {
63        Self::EdgeType(EdgeType::new(s))
64    }
65}
66
67impl From<EdgeType> for StepFilter {
68    fn from(et: EdgeType) -> Self {
69        Self::EdgeType(et)
70    }
71}
72
73/// A single step in a path pattern.
74///
75/// Each step specifies a direction, edge filter, and optional hop range.
76#[derive(Debug, Clone)]
77pub struct PathStep {
78    /// Direction to traverse for this step.
79    pub direction: Direction,
80    /// Filter for edges in this step.
81    pub filter: StepFilter,
82    /// Minimum number of hops (default: 1).
83    pub min_hops: usize,
84    /// Maximum number of hops (default: 1).
85    /// Use `None` for unlimited (variable length patterns).
86    pub max_hops: Option<usize>,
87}
88
89impl PathStep {
90    /// Create a new single-hop step.
91    pub fn new(direction: Direction, filter: impl Into<StepFilter>) -> Self {
92        Self { direction, filter: filter.into(), min_hops: 1, max_hops: Some(1) }
93    }
94
95    /// Create a step that matches any edge in the given direction.
96    pub fn any(direction: Direction) -> Self {
97        Self::new(direction, StepFilter::Any)
98    }
99
100    /// Create an outgoing step with the given edge type.
101    pub fn outgoing(edge_type: impl Into<EdgeType>) -> Self {
102        Self::new(Direction::Outgoing, StepFilter::edge_type(edge_type))
103    }
104
105    /// Create an incoming step with the given edge type.
106    pub fn incoming(edge_type: impl Into<EdgeType>) -> Self {
107        Self::new(Direction::Incoming, StepFilter::edge_type(edge_type))
108    }
109
110    /// Create a bidirectional step with the given edge type.
111    pub fn both(edge_type: impl Into<EdgeType>) -> Self {
112        Self::new(Direction::Both, StepFilter::edge_type(edge_type))
113    }
114
115    /// Set the hop range for this step.
116    ///
117    /// # Arguments
118    ///
119    /// * `min` - Minimum number of hops
120    /// * `max` - Maximum number of hops (None for unlimited)
121    pub const fn with_hops(mut self, min: usize, max: Option<usize>) -> Self {
122        self.min_hops = min;
123        self.max_hops = max;
124        self
125    }
126
127    /// Make this a variable-length step with the given range.
128    ///
129    /// Equivalent to Cypher's `*min..max` syntax.
130    pub const fn variable_length(mut self, min: usize, max: usize) -> Self {
131        self.min_hops = min;
132        self.max_hops = Some(max);
133        self
134    }
135
136    /// Make this step optional (0 or 1 hops).
137    pub const fn optional(mut self) -> Self {
138        self.min_hops = 0;
139        self.max_hops = Some(1);
140        self
141    }
142
143    /// Make this step repeat any number of times (0 or more).
144    pub const fn zero_or_more(mut self) -> Self {
145        self.min_hops = 0;
146        self.max_hops = None;
147        self
148    }
149
150    /// Make this step repeat one or more times.
151    pub const fn one_or_more(mut self) -> Self {
152        self.min_hops = 1;
153        self.max_hops = None;
154        self
155    }
156
157    /// Check if this step is variable length.
158    pub fn is_variable_length(&self) -> bool {
159        self.min_hops != 1 || self.max_hops != Some(1)
160    }
161}
162
163/// A match result from pattern matching.
164#[derive(Debug, Clone)]
165pub struct PatternMatch {
166    /// The nodes traversed, in order.
167    pub nodes: Vec<EntityId>,
168    /// The edges used for each step.
169    /// Each inner vector contains edges for one step (may have multiple for variable-length steps).
170    pub step_edges: Vec<Vec<EdgeId>>,
171}
172
173impl PatternMatch {
174    /// Get the starting node.
175    pub fn source(&self) -> EntityId {
176        self.nodes[0]
177    }
178
179    /// Get the ending node.
180    pub fn target(&self) -> EntityId {
181        *self.nodes.last().expect("pattern match has at least one node")
182    }
183
184    /// Get all edges as a flat list.
185    pub fn all_edges(&self) -> Vec<EdgeId> {
186        self.step_edges.iter().flatten().copied().collect()
187    }
188
189    /// Get the total path length (number of edges).
190    pub fn length(&self) -> usize {
191        self.step_edges.iter().map(std::vec::Vec::len).sum()
192    }
193}
194
195/// Context for pattern matching operations.
196///
197/// Bundles related parameters to reduce function argument counts
198/// and improve code readability.
199struct MatchContext<'a, T: Transaction> {
200    /// Transaction for storage operations.
201    tx: &'a T,
202    /// Current node being processed.
203    current: EntityId,
204    /// Current step index in the pattern.
205    step_idx: usize,
206    /// Accumulated path nodes.
207    path_nodes: Vec<EntityId>,
208    /// Accumulated path edges for each step.
209    path_edges: Vec<Vec<EdgeId>>,
210    /// Set of visited nodes (for cycle detection).
211    visited: HashSet<EntityId>,
212    /// Accumulated match results.
213    results: &'a mut Vec<PatternMatch>,
214}
215
216impl<'a, T: Transaction> MatchContext<'a, T> {
217    /// Create a new match context.
218    fn new(
219        tx: &'a T,
220        start: EntityId,
221        allow_cycles: bool,
222        results: &'a mut Vec<PatternMatch>,
223    ) -> Self {
224        let visited = if allow_cycles {
225            HashSet::new()
226        } else {
227            let mut set = HashSet::new();
228            set.insert(start);
229            set
230        };
231
232        Self {
233            tx,
234            current: start,
235            step_idx: 0,
236            path_nodes: vec![start],
237            path_edges: Vec::new(),
238            visited,
239            results,
240        }
241    }
242}
243
244/// Path pattern matcher.
245///
246/// Matches paths against a sequence of steps, supporting variable-length
247/// patterns and filters.
248///
249/// # Example
250///
251/// ```ignore
252/// // Pattern: (a)-[:KNOWS]->(b)-[:WORKS_AT]->(c)
253/// let pattern = PathPattern::new()
254///     .add_step(PathStep::outgoing("KNOWS"))
255///     .add_step(PathStep::outgoing("WORKS_AT"));
256///
257/// let matches = pattern.find_from(&tx, start_node)?;
258///
259/// // Variable-length: (a)-[:FRIEND*1..3]->(b)
260/// let pattern = PathPattern::new()
261///     .add_step(PathStep::outgoing("FRIEND").variable_length(1, 3));
262/// ```
263#[derive(Debug, Clone, Default)]
264pub struct PathPattern {
265    /// The steps in this pattern.
266    steps: Vec<PathStep>,
267    /// Maximum number of matches to return.
268    limit: Option<usize>,
269    /// Whether to allow cycles in matches.
270    allow_cycles: bool,
271}
272
273impl PathPattern {
274    /// Create a new empty pattern.
275    pub fn new() -> Self {
276        Self::default()
277    }
278
279    /// Add a step to the pattern.
280    pub fn add_step(mut self, step: PathStep) -> Self {
281        self.steps.push(step);
282        self
283    }
284
285    /// Add an outgoing step with the given edge type.
286    pub fn outgoing(self, edge_type: impl Into<EdgeType>) -> Self {
287        self.add_step(PathStep::outgoing(edge_type))
288    }
289
290    /// Add an incoming step with the given edge type.
291    pub fn incoming(self, edge_type: impl Into<EdgeType>) -> Self {
292        self.add_step(PathStep::incoming(edge_type))
293    }
294
295    /// Add a bidirectional step with the given edge type.
296    pub fn both(self, edge_type: impl Into<EdgeType>) -> Self {
297        self.add_step(PathStep::both(edge_type))
298    }
299
300    /// Set the maximum number of matches to return.
301    pub const fn with_limit(mut self, limit: usize) -> Self {
302        self.limit = Some(limit);
303        self
304    }
305
306    /// Allow cycles in matched paths.
307    ///
308    /// By default, nodes cannot be visited more than once in a path.
309    pub const fn allow_cycles(mut self) -> Self {
310        self.allow_cycles = true;
311        self
312    }
313
314    /// Get the steps in this pattern.
315    pub fn steps(&self) -> &[PathStep] {
316        &self.steps
317    }
318
319    /// Check if this pattern is empty.
320    pub fn is_empty(&self) -> bool {
321        self.steps.is_empty()
322    }
323
324    /// Find all paths matching this pattern from a starting node.
325    pub fn find_from<T: Transaction>(
326        &self,
327        tx: &T,
328        start: EntityId,
329    ) -> GraphResult<Vec<PatternMatch>> {
330        if self.steps.is_empty() {
331            // Empty pattern matches only the start node
332            return Ok(vec![PatternMatch { nodes: vec![start], step_edges: Vec::new() }]);
333        }
334
335        // Pre-allocate results based on expected pattern matches
336        // For typical patterns, we expect a moderate number of matches
337        const INITIAL_RESULTS_CAPACITY: usize = 64;
338
339        let mut results = Vec::with_capacity(INITIAL_RESULTS_CAPACITY);
340        let mut ctx = MatchContext::new(tx, start, self.allow_cycles, &mut results);
341
342        self.match_from_step(&mut ctx)?;
343
344        Ok(results)
345    }
346
347    /// Find paths matching this pattern that end at a specific node.
348    pub fn find_between<T: Transaction>(
349        &self,
350        tx: &T,
351        start: EntityId,
352        end: EntityId,
353    ) -> GraphResult<Vec<PatternMatch>> {
354        let all_matches = self.find_from(tx, start)?;
355        Ok(all_matches.into_iter().filter(|m| m.target() == end).collect())
356    }
357
358    /// Check if any path matches this pattern from the given start.
359    pub fn matches<T: Transaction>(&self, tx: &T, start: EntityId) -> GraphResult<bool> {
360        let pattern_with_limit =
361            Self { steps: self.steps.clone(), limit: Some(1), allow_cycles: self.allow_cycles };
362        let matches = pattern_with_limit.find_from(tx, start)?;
363        Ok(!matches.is_empty())
364    }
365
366    /// Recursive helper to match pattern steps.
367    fn match_from_step<T: Transaction>(&self, ctx: &mut MatchContext<'_, T>) -> GraphResult<()> {
368        // Check limit
369        if let Some(limit) = self.limit {
370            if ctx.results.len() >= limit {
371                return Ok(());
372            }
373        }
374
375        // If we've processed all steps, we have a match
376        if ctx.step_idx >= self.steps.len() {
377            ctx.results.push(PatternMatch {
378                nodes: ctx.path_nodes.clone(),
379                step_edges: ctx.path_edges.clone(),
380            });
381            return Ok(());
382        }
383
384        let step = &self.steps[ctx.step_idx];
385
386        // Handle variable-length steps
387        if step.is_variable_length() {
388            self.match_variable_step(ctx, step)?;
389        } else {
390            // Single-hop step
391            self.match_single_step(ctx, step)?;
392        }
393
394        Ok(())
395    }
396
397    fn match_single_step<T: Transaction>(
398        &self,
399        ctx: &mut MatchContext<'_, T>,
400        step: &PathStep,
401    ) -> GraphResult<()> {
402        let neighbors = self.get_filtered_neighbors(ctx.tx, ctx.current, step)?;
403
404        for (neighbor, edge_id) in neighbors {
405            if !self.allow_cycles && ctx.visited.contains(&neighbor) {
406                continue;
407            }
408
409            // Save current state for backtracking
410            let prev_current = ctx.current;
411            let prev_step_idx = ctx.step_idx;
412            let prev_nodes_len = ctx.path_nodes.len();
413            let prev_edges_len = ctx.path_edges.len();
414
415            // Build new path
416            ctx.path_nodes.push(neighbor);
417            ctx.path_edges.push(vec![edge_id]);
418            ctx.current = neighbor;
419            ctx.step_idx += 1;
420
421            // Track visited
422            let was_new = if self.allow_cycles { false } else { ctx.visited.insert(neighbor) };
423
424            // Continue to next step
425            self.match_from_step(ctx)?;
426
427            // Restore state for backtracking
428            ctx.path_nodes.truncate(prev_nodes_len);
429            ctx.path_edges.truncate(prev_edges_len);
430            ctx.current = prev_current;
431            ctx.step_idx = prev_step_idx;
432
433            if was_new {
434                ctx.visited.remove(&neighbor);
435            }
436        }
437
438        Ok(())
439    }
440
441    fn match_variable_step<T: Transaction>(
442        &self,
443        ctx: &mut MatchContext<'_, T>,
444        step: &PathStep,
445    ) -> GraphResult<()> {
446        // Use DFS with backtracking to avoid O(n) cloning per BFS level.
447        // This approach uses push/pop on shared vectors rather than cloning
448        // entire collections for each queue entry.
449        self.dfs_variable_step(ctx, step, ctx.current, 0)
450    }
451
452    /// DFS helper for variable-length step matching with backtracking.
453    /// Uses push/pop on shared vectors to avoid cloning.
454    fn dfs_variable_step<T: Transaction>(
455        &self,
456        ctx: &mut MatchContext<'_, T>,
457        step: &PathStep,
458        current_node: EntityId,
459        hop_count: usize,
460    ) -> GraphResult<()> {
461        // Check limit
462        if let Some(limit) = self.limit {
463            if ctx.results.len() >= limit {
464                return Ok(());
465            }
466        }
467
468        // If within valid hop range, try continuing with next step
469        if hop_count >= step.min_hops {
470            // Save current state for backtracking
471            let prev_current = ctx.current;
472            let prev_step_idx = ctx.step_idx;
473            let prev_nodes_len = ctx.path_nodes.len();
474            let prev_edges_len = ctx.path_edges.len();
475
476            // Update context for next step
477            ctx.current = current_node;
478            ctx.step_idx += 1;
479
480            // The path_nodes already contain the nodes for this variable step
481            // (added during DFS descent). We need to collect edges from the last
482            // `hop_count` positions that were added during descent.
483            // We use a marker approach: path_edges.len() tracks how many complete steps
484            // we have recorded. For variable steps, we record all edges at once when
485            // we commit to continuing to the next pattern step.
486
487            // Since we're using DFS and path_nodes already has the nodes from descent,
488            // we just need to collect the current step's edges.
489            // For now, push an empty vec that we'll populate via match context tracking.
490
491            // Actually, we need to track edges during descent too. Let's use a separate
492            // vector in a revised approach.
493
494            self.match_from_step(ctx)?;
495
496            // Restore state
497            ctx.path_nodes.truncate(prev_nodes_len);
498            ctx.path_edges.truncate(prev_edges_len);
499            ctx.current = prev_current;
500            ctx.step_idx = prev_step_idx;
501        }
502
503        // Check if we can expand further
504        let can_expand = step.max_hops.map_or(true, |max| hop_count < max);
505        if !can_expand {
506            return Ok(());
507        }
508
509        // Check limit again before expansion
510        if let Some(limit) = self.limit {
511            if ctx.results.len() >= limit {
512                return Ok(());
513            }
514        }
515
516        // Expand to neighbors using DFS with backtracking
517        let neighbors = self.get_filtered_neighbors(ctx.tx, current_node, step)?;
518
519        for (neighbor, edge_id) in neighbors {
520            if !self.allow_cycles && ctx.visited.contains(&neighbor) {
521                continue;
522            }
523
524            // Push state for this neighbor
525            ctx.path_nodes.push(neighbor);
526
527            // Track edge for this step - we need to manage step edges carefully
528            // For the first hop, we push a new vec; for subsequent hops, we extend
529            if hop_count == 0 {
530                ctx.path_edges.push(vec![edge_id]);
531            } else {
532                // Extend the current step's edges
533                if let Some(last_edges) = ctx.path_edges.last_mut() {
534                    last_edges.push(edge_id);
535                }
536            }
537
538            let was_new = if self.allow_cycles { false } else { ctx.visited.insert(neighbor) };
539
540            // Recurse
541            self.dfs_variable_step(ctx, step, neighbor, hop_count + 1)?;
542
543            // Pop state (backtrack)
544            ctx.path_nodes.pop();
545
546            if hop_count == 0 {
547                ctx.path_edges.pop();
548            } else if let Some(last_edges) = ctx.path_edges.last_mut() {
549                last_edges.pop();
550            }
551
552            if was_new {
553                ctx.visited.remove(&neighbor);
554            }
555        }
556
557        Ok(())
558    }
559
560    fn get_filtered_neighbors<T: Transaction>(
561        &self,
562        tx: &T,
563        node: EntityId,
564        step: &PathStep,
565    ) -> GraphResult<Vec<(EntityId, EdgeId)>> {
566        // Pre-allocate for typical node degree (most nodes have < 32 neighbors)
567        const INITIAL_NEIGHBORS_CAPACITY: usize = 16;
568
569        let mut neighbors = Vec::with_capacity(INITIAL_NEIGHBORS_CAPACITY);
570
571        if step.direction.includes_outgoing() {
572            self.add_filtered_outgoing(tx, node, step, &mut neighbors)?;
573        }
574
575        if step.direction.includes_incoming() {
576            self.add_filtered_incoming(tx, node, step, &mut neighbors)?;
577        }
578
579        Ok(neighbors)
580    }
581
582    fn add_filtered_outgoing<T: Transaction>(
583        &self,
584        tx: &T,
585        node: EntityId,
586        step: &PathStep,
587        neighbors: &mut Vec<(EntityId, EdgeId)>,
588    ) -> GraphResult<()> {
589        match &step.filter {
590            StepFilter::EdgeType(et) => {
591                AdjacencyIndex::for_each_outgoing_by_type(tx, node, et, |edge_id| {
592                    if let Some(edge) = EdgeStore::get(tx, edge_id)? {
593                        neighbors.push((edge.target, edge_id));
594                    }
595                    Ok(true)
596                })?;
597            }
598            StepFilter::EdgeTypes(types) => {
599                for et in types {
600                    AdjacencyIndex::for_each_outgoing_by_type(tx, node, et, |edge_id| {
601                        if let Some(edge) = EdgeStore::get(tx, edge_id)? {
602                            neighbors.push((edge.target, edge_id));
603                        }
604                        Ok(true)
605                    })?;
606                }
607            }
608            StepFilter::Any | StepFilter::Custom(_) => {
609                AdjacencyIndex::for_each_outgoing(tx, node, |edge_id| {
610                    if let Some(edge) = EdgeStore::get(tx, edge_id)? {
611                        if step.filter.matches(&edge) {
612                            neighbors.push((edge.target, edge_id));
613                        }
614                    }
615                    Ok(true)
616                })?;
617            }
618        }
619        Ok(())
620    }
621
622    fn add_filtered_incoming<T: Transaction>(
623        &self,
624        tx: &T,
625        node: EntityId,
626        step: &PathStep,
627        neighbors: &mut Vec<(EntityId, EdgeId)>,
628    ) -> GraphResult<()> {
629        match &step.filter {
630            StepFilter::EdgeType(et) => {
631                AdjacencyIndex::for_each_incoming_by_type(tx, node, et, |edge_id| {
632                    if let Some(edge) = EdgeStore::get(tx, edge_id)? {
633                        neighbors.push((edge.source, edge_id));
634                    }
635                    Ok(true)
636                })?;
637            }
638            StepFilter::EdgeTypes(types) => {
639                for et in types {
640                    AdjacencyIndex::for_each_incoming_by_type(tx, node, et, |edge_id| {
641                        if let Some(edge) = EdgeStore::get(tx, edge_id)? {
642                            neighbors.push((edge.source, edge_id));
643                        }
644                        Ok(true)
645                    })?;
646                }
647            }
648            StepFilter::Any | StepFilter::Custom(_) => {
649                AdjacencyIndex::for_each_incoming(tx, node, |edge_id| {
650                    if let Some(edge) = EdgeStore::get(tx, edge_id)? {
651                        if step.filter.matches(&edge) {
652                            neighbors.push((edge.source, edge_id));
653                        }
654                    }
655                    Ok(true)
656                })?;
657            }
658        }
659        Ok(())
660    }
661}
662
663/// Builder for creating path patterns with a fluent API.
664pub struct PatternBuilder {
665    pattern: PathPattern,
666}
667
668impl PatternBuilder {
669    /// Start building a new pattern.
670    pub fn new() -> Self {
671        Self { pattern: PathPattern::new() }
672    }
673
674    /// Add an outgoing edge of the specified type.
675    pub fn out(mut self, edge_type: impl Into<EdgeType>) -> Self {
676        self.pattern = self.pattern.outgoing(edge_type);
677        self
678    }
679
680    /// Add an incoming edge of the specified type.
681    pub fn inc(mut self, edge_type: impl Into<EdgeType>) -> Self {
682        self.pattern = self.pattern.incoming(edge_type);
683        self
684    }
685
686    /// Add a bidirectional edge of the specified type.
687    pub fn rel(mut self, edge_type: impl Into<EdgeType>) -> Self {
688        self.pattern = self.pattern.both(edge_type);
689        self
690    }
691
692    /// Add any outgoing edge.
693    pub fn out_any(mut self) -> Self {
694        self.pattern = self.pattern.add_step(PathStep::any(Direction::Outgoing));
695        self
696    }
697
698    /// Add any incoming edge.
699    pub fn in_any(mut self) -> Self {
700        self.pattern = self.pattern.add_step(PathStep::any(Direction::Incoming));
701        self
702    }
703
704    /// Add any edge in either direction.
705    pub fn any(mut self) -> Self {
706        self.pattern = self.pattern.add_step(PathStep::any(Direction::Both));
707        self
708    }
709
710    /// Add a variable-length outgoing path.
711    pub fn out_var(mut self, edge_type: impl Into<EdgeType>, min: usize, max: usize) -> Self {
712        self.pattern =
713            self.pattern.add_step(PathStep::outgoing(edge_type).variable_length(min, max));
714        self
715    }
716
717    /// Add a variable-length incoming path.
718    pub fn in_var(mut self, edge_type: impl Into<EdgeType>, min: usize, max: usize) -> Self {
719        self.pattern =
720            self.pattern.add_step(PathStep::incoming(edge_type).variable_length(min, max));
721        self
722    }
723
724    /// Set result limit.
725    pub fn limit(mut self, limit: usize) -> Self {
726        self.pattern = self.pattern.with_limit(limit);
727        self
728    }
729
730    /// Allow cycles in matches.
731    pub fn with_cycles(mut self) -> Self {
732        self.pattern = self.pattern.allow_cycles();
733        self
734    }
735
736    /// Build the pattern.
737    pub fn build(self) -> PathPattern {
738        self.pattern
739    }
740}
741
742impl Default for PatternBuilder {
743    fn default() -> Self {
744        Self::new()
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    #[test]
753    fn step_filter_matches_any() {
754        let filter = StepFilter::Any;
755        let edge = Edge::new(EdgeId::new(1), EntityId::new(1), EntityId::new(2), "TEST");
756        assert!(filter.matches(&edge));
757    }
758
759    #[test]
760    fn step_filter_matches_edge_type() {
761        let filter = StepFilter::edge_type("FRIEND");
762        let friend_edge = Edge::new(EdgeId::new(1), EntityId::new(1), EntityId::new(2), "FRIEND");
763        let work_edge = Edge::new(EdgeId::new(2), EntityId::new(1), EntityId::new(2), "WORKS_AT");
764
765        assert!(filter.matches(&friend_edge));
766        assert!(!filter.matches(&work_edge));
767    }
768
769    #[test]
770    fn step_filter_matches_multiple_types() {
771        let filter = StepFilter::edge_types([EdgeType::new("FRIEND"), EdgeType::new("FOLLOWS")]);
772        let friend = Edge::new(EdgeId::new(1), EntityId::new(1), EntityId::new(2), "FRIEND");
773        let follows = Edge::new(EdgeId::new(2), EntityId::new(1), EntityId::new(2), "FOLLOWS");
774        let blocks = Edge::new(EdgeId::new(3), EntityId::new(1), EntityId::new(2), "BLOCKS");
775
776        assert!(filter.matches(&friend));
777        assert!(filter.matches(&follows));
778        assert!(!filter.matches(&blocks));
779    }
780
781    #[test]
782    fn path_step_variable_length() {
783        let step = PathStep::outgoing("FRIEND").variable_length(1, 3);
784        assert!(step.is_variable_length());
785        assert_eq!(step.min_hops, 1);
786        assert_eq!(step.max_hops, Some(3));
787    }
788
789    #[test]
790    fn path_step_optional() {
791        let step = PathStep::outgoing("FRIEND").optional();
792        assert!(step.is_variable_length());
793        assert_eq!(step.min_hops, 0);
794        assert_eq!(step.max_hops, Some(1));
795    }
796
797    #[test]
798    fn path_step_zero_or_more() {
799        let step = PathStep::outgoing("FRIEND").zero_or_more();
800        assert!(step.is_variable_length());
801        assert_eq!(step.min_hops, 0);
802        assert_eq!(step.max_hops, None);
803    }
804
805    #[test]
806    fn path_pattern_builder() {
807        let pattern = PatternBuilder::new().out("KNOWS").out("WORKS_AT").limit(10).build();
808
809        assert_eq!(pattern.steps().len(), 2);
810        assert_eq!(pattern.limit, Some(10));
811    }
812
813    #[test]
814    fn pattern_match_helpers() {
815        let pm = PatternMatch {
816            nodes: vec![EntityId::new(1), EntityId::new(2), EntityId::new(3)],
817            step_edges: vec![vec![EdgeId::new(10)], vec![EdgeId::new(20)]],
818        };
819
820        assert_eq!(pm.source(), EntityId::new(1));
821        assert_eq!(pm.target(), EntityId::new(3));
822        assert_eq!(pm.length(), 2);
823        assert_eq!(pm.all_edges(), vec![EdgeId::new(10), EdgeId::new(20)]);
824    }
825
826    #[test]
827    fn empty_pattern() {
828        let pattern = PathPattern::new();
829        assert!(pattern.is_empty());
830    }
831}