Skip to main content

fraiseql_core/graphql/
fragments.rs

1//! Fragment cycle detection and validation.
2//!
3//! This module implements GraphQL fragment cycle detection using DFS-based
4//! cycle detection with backtracking to identify circular fragment dependencies.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::graphql::types::{FragmentDefinition, ParsedQuery};
9
10/// Fragment dependency graph and cycle detection
11#[derive(Debug)]
12pub struct FragmentGraph {
13    /// Map of fragment name to set of fragment names it depends on
14    dependencies: HashMap<String, HashSet<String>>,
15}
16
17impl FragmentGraph {
18    /// Create a new fragment graph from parsed query
19    #[must_use]
20    pub fn new(query: &ParsedQuery) -> Self {
21        let mut dependencies = HashMap::new();
22
23        // Build dependency graph from fragment definitions
24        for fragment in &query.fragments {
25            let deps = Self::extract_fragment_dependencies(fragment, &query.fragments);
26            dependencies.insert(fragment.name.clone(), deps);
27        }
28
29        Self { dependencies }
30    }
31
32    /// Extract fragment dependencies for a single fragment
33    fn extract_fragment_dependencies(
34        fragment: &FragmentDefinition,
35        all_fragments: &[FragmentDefinition],
36    ) -> HashSet<String> {
37        let mut deps = HashSet::new();
38
39        // Direct fragment spreads
40        deps.extend(fragment.fragment_spreads.iter().cloned());
41
42        // Recursive dependencies through nested selections
43        for selection in &fragment.selections {
44            Self::extract_selection_dependencies(selection, all_fragments, &mut deps);
45        }
46
47        deps
48    }
49
50    /// Extract dependencies from field selections (recursive helper)
51    #[allow(clippy::only_used_in_recursion)] // Reason: &self receiver required for method dispatch on FragmentValidator
52    fn extract_selection_dependencies(
53        selection: &crate::graphql::types::FieldSelection,
54        all_fragments: &[FragmentDefinition],
55        deps: &mut HashSet<String>,
56    ) {
57        // Fragment spreads are already collected during parsing in
58        // FragmentDefinition.fragment_spreads No additional processing needed here for
59        // selection-level dependencies
60
61        // Recursively check nested selections
62        for nested in &selection.nested_fields {
63            Self::extract_selection_dependencies(nested, all_fragments, deps);
64        }
65    }
66
67    /// Detect cycles in fragment dependencies using DFS
68    ///
69    /// Returns Ok(()) if no cycles found, `Err(cycle_path)` if cycle detected
70    ///
71    /// # Errors
72    ///
73    /// Returns an error with the cycle path if:
74    /// - A circular fragment dependency is detected
75    /// - A fragment references itself directly or indirectly
76    pub fn detect_cycles(&self) -> Result<(), Vec<String>> {
77        let mut visited = HashSet::new();
78        let mut recursion_stack = HashSet::new();
79        let mut cycle_path = Vec::new();
80
81        for fragment_name in self.dependencies.keys() {
82            if !visited.contains(fragment_name) {
83                if let Some(cycle) = self.dfs_cycle_detect(
84                    fragment_name,
85                    &mut visited,
86                    &mut recursion_stack,
87                    &mut cycle_path,
88                ) {
89                    return Err(cycle);
90                }
91            }
92        }
93
94        Ok(())
95    }
96
97    /// DFS cycle detection helper
98    fn dfs_cycle_detect(
99        &self,
100        fragment_name: &str,
101        visited: &mut HashSet<String>,
102        recursion_stack: &mut HashSet<String>,
103        cycle_path: &mut Vec<String>,
104    ) -> Option<Vec<String>> {
105        visited.insert(fragment_name.to_string());
106        recursion_stack.insert(fragment_name.to_string());
107        cycle_path.push(fragment_name.to_string());
108
109        if let Some(deps) = self.dependencies.get(fragment_name) {
110            for dep in deps {
111                if let Some(cycle) =
112                    self.check_dependency_cycle(dep, visited, recursion_stack, cycle_path)
113                {
114                    return Some(cycle);
115                }
116            }
117        }
118
119        recursion_stack.remove(fragment_name);
120        cycle_path.pop();
121        None
122    }
123
124    /// Check if a dependency creates a cycle (helper to reduce nesting)
125    fn check_dependency_cycle(
126        &self,
127        dep: &str,
128        visited: &mut HashSet<String>,
129        recursion_stack: &mut HashSet<String>,
130        cycle_path: &mut Vec<String>,
131    ) -> Option<Vec<String>> {
132        if !visited.contains(dep) {
133            // Not visited yet - recurse
134            return self.dfs_cycle_detect(dep, visited, recursion_stack, cycle_path);
135        }
136
137        if recursion_stack.contains(dep) {
138            // Cycle found - extract cycle path
139            #[allow(clippy::expect_used)]
140            // Reason: dep is guaranteed to be in cycle_path when found in recursion_stack
141            let cycle_start = cycle_path
142                .iter()
143                .position(|f| f == dep)
144                .expect("dep must be in cycle_path when in recursion_stack");
145            let cycle = cycle_path[cycle_start..].to_vec();
146            return Some(cycle);
147        }
148
149        None
150    }
151
152    /// Validate all fragments in the query
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if:
157    /// - Fragment cycle is detected (with formatted cycle path in error message)
158    pub fn validate_fragments(&self) -> Result<(), String> {
159        self.detect_cycles()
160            .map_err(|cycle| format!("Fragment cycle detected: {}", cycle.join(" -> ")))
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
167
168    use super::*;
169
170    #[test]
171    fn test_no_cycles() {
172        let graph = FragmentGraph {
173            dependencies: HashMap::from([
174                ("FragA".to_string(), HashSet::from(["FragB".to_string()])),
175                ("FragB".to_string(), HashSet::from(["FragC".to_string()])),
176                ("FragC".to_string(), HashSet::new()),
177            ]),
178        };
179        graph
180            .detect_cycles()
181            .unwrap_or_else(|c| panic!("expected no cycles, got: {c:?}"));
182    }
183
184    #[test]
185    fn test_simple_cycle() {
186        let graph = FragmentGraph {
187            dependencies: HashMap::from([
188                ("FragA".to_string(), HashSet::from(["FragB".to_string()])),
189                ("FragB".to_string(), HashSet::from(["FragA".to_string()])),
190            ]),
191        };
192        let cycle = graph.detect_cycles().expect_err("expected cycle to be detected");
193        // Cycle can start from either FragA or FragB depending on iteration order
194        assert!(cycle.len() >= 2, "cycle must contain at least 2 fragments, got: {cycle:?}");
195    }
196
197    #[test]
198    fn test_complex_cycle() {
199        let graph = FragmentGraph {
200            dependencies: HashMap::from([
201                ("FragA".to_string(), HashSet::from(["FragB".to_string()])),
202                ("FragB".to_string(), HashSet::from(["FragC".to_string()])),
203                ("FragC".to_string(), HashSet::from(["FragA".to_string()])),
204                ("FragD".to_string(), HashSet::from(["FragE".to_string()])),
205                ("FragE".to_string(), HashSet::new()),
206            ]),
207        };
208        let result = graph.detect_cycles();
209        assert!(result.is_err(), "expected A→B→C→A cycle to be detected, got: {result:?}");
210    }
211
212    #[test]
213    fn test_multiple_cycles() {
214        let graph = FragmentGraph {
215            dependencies: HashMap::from([
216                ("FragA".to_string(), HashSet::from(["FragB".to_string()])),
217                ("FragB".to_string(), HashSet::from(["FragA".to_string()])),
218                ("FragC".to_string(), HashSet::from(["FragD".to_string()])),
219                ("FragD".to_string(), HashSet::from(["FragC".to_string()])),
220            ]),
221        };
222        let cycle = graph.detect_cycles().expect_err("expected at least one cycle to be detected");
223        // Should detect one of the cycles (DFS order dependent)
224        assert!(
225            cycle.len() >= 2,
226            "cycle must contain at least 2 fragments (A→B or C→D), got: {cycle:?}"
227        );
228    }
229
230    #[test]
231    fn test_self_reference_cycle() {
232        let graph = FragmentGraph {
233            dependencies: HashMap::from([(
234                "FragA".to_string(),
235                HashSet::from(["FragA".to_string()]),
236            )]),
237        };
238        let result = graph.detect_cycles();
239        assert!(
240            result.is_err(),
241            "expected self-reference FragA→FragA to be detected as cycle, got: {result:?}"
242        );
243    }
244}