Skip to main content

fraiseql_core/schema/dependency_graph/
analysis.rs

1//! Analysis methods: cycle detection, unused types, impact analysis, transitive queries.
2
3use std::collections::HashSet;
4
5use super::{
6    graph::SchemaDependencyGraph,
7    types::{ChangeImpact, CyclePath},
8};
9
10impl SchemaDependencyGraph {
11    /// Find all circular dependencies in the schema.
12    ///
13    /// Returns a list of cycle paths. Each cycle is reported once,
14    /// starting from the lexicographically smallest type name.
15    #[must_use]
16    pub fn find_cycles(&self) -> Vec<CyclePath> {
17        let mut visited = HashSet::new();
18        let mut rec_stack = HashSet::new();
19        let mut cycles = Vec::new();
20
21        for type_name in &self.all_types {
22            if !visited.contains(type_name) {
23                self.dfs_find_cycles(
24                    type_name,
25                    &mut visited,
26                    &mut rec_stack,
27                    &mut Vec::new(),
28                    &mut cycles,
29                );
30            }
31        }
32
33        // Normalize and deduplicate cycles
34        Self::normalize_cycles(cycles)
35    }
36
37    /// DFS helper for cycle detection.
38    fn dfs_find_cycles(
39        &self,
40        node: &str,
41        visited: &mut HashSet<String>,
42        rec_stack: &mut HashSet<String>,
43        path: &mut Vec<String>,
44        cycles: &mut Vec<CyclePath>,
45    ) {
46        visited.insert(node.to_string());
47        rec_stack.insert(node.to_string());
48        path.push(node.to_string());
49
50        if let Some(deps) = self.outgoing.get(node) {
51            for dep in deps {
52                if !visited.contains(dep) {
53                    self.dfs_find_cycles(dep, visited, rec_stack, path, cycles);
54                } else if rec_stack.contains(dep) {
55                    // Found a cycle - extract it from the path
56                    if let Some(start_idx) = path.iter().position(|x| x == dep) {
57                        let cycle_nodes: Vec<String> = path[start_idx..].to_vec();
58                        cycles.push(CyclePath::new(cycle_nodes));
59                    }
60                }
61            }
62        }
63
64        rec_stack.remove(node);
65        path.pop();
66    }
67
68    /// Normalize cycles so each cycle starts from its lexicographically smallest node,
69    /// and deduplicate equivalent cycles.
70    fn normalize_cycles(mut cycles: Vec<CyclePath>) -> Vec<CyclePath> {
71        // Normalize each cycle to start from the smallest element
72        for cycle in &mut cycles {
73            if cycle.nodes.is_empty() {
74                continue;
75            }
76            // Find the position of the minimum element
77            let min_pos = cycle
78                .nodes
79                .iter()
80                .enumerate()
81                .min_by_key(|(_, name)| *name)
82                .map_or(0, |(i, _)| i);
83
84            // Rotate so minimum is first
85            cycle.nodes.rotate_left(min_pos);
86        }
87
88        // Sort by nodes for consistent ordering
89        cycles.sort_by(|a, b| a.nodes.cmp(&b.nodes));
90
91        // Deduplicate
92        cycles.dedup();
93
94        cycles
95    }
96
97    /// Find all types that have no incoming references (orphaned types).
98    ///
99    /// Root types (Query, Mutation, Subscription) are excluded from this list
100    /// as they are always considered used.
101    #[must_use]
102    pub fn find_unused(&self) -> Vec<String> {
103        let mut unused = Vec::new();
104
105        for type_name in &self.all_types {
106            // Skip root types - they're always used
107            if self.root_types.contains(type_name) {
108                continue;
109            }
110
111            // Check if any type references this one
112            let has_references = self.incoming.get(type_name).is_some_and(|refs| !refs.is_empty());
113
114            if !has_references {
115                unused.push(type_name.clone());
116            }
117        }
118
119        unused.sort();
120        unused
121    }
122
123    /// Analyze the impact of deleting a type from the schema.
124    ///
125    /// Returns all types that would be affected (transitively) by removing
126    /// the specified type.
127    #[must_use]
128    pub fn impact_of_deletion(&self, type_name: &str) -> ChangeImpact {
129        let mut affected = HashSet::new();
130        let mut breaking_changes = Vec::new();
131
132        // Find all types that depend on this type (directly or transitively)
133        let mut to_visit = vec![type_name.to_string()];
134        let mut visited = HashSet::new();
135
136        while let Some(current) = to_visit.pop() {
137            if !visited.insert(current.clone()) {
138                continue;
139            }
140
141            // Get types that depend on current
142            if let Some(dependents) = self.incoming.get(&current) {
143                for dependent in dependents {
144                    if !visited.contains(dependent) {
145                        affected.insert(dependent.clone());
146                        to_visit.push(dependent.clone());
147                        breaking_changes.push(format!(
148                            "Type '{}' references '{}' which would be deleted",
149                            dependent, type_name
150                        ));
151                    }
152                }
153            }
154        }
155
156        // Remove the type itself from affected (it's the one being deleted)
157        affected.remove(type_name);
158
159        ChangeImpact::new(affected, breaking_changes)
160    }
161
162    /// Get transitive dependencies of a type (all types it depends on, recursively).
163    #[must_use]
164    pub fn transitive_dependencies(&self, type_name: &str) -> HashSet<String> {
165        let mut visited = HashSet::new();
166        let mut to_visit = vec![type_name.to_string()];
167
168        while let Some(current) = to_visit.pop() {
169            if !visited.insert(current.clone()) {
170                continue;
171            }
172
173            if let Some(deps) = self.outgoing.get(&current) {
174                for dep in deps {
175                    if !visited.contains(dep) {
176                        to_visit.push(dep.clone());
177                    }
178                }
179            }
180        }
181
182        // Remove the starting type
183        visited.remove(type_name);
184        visited
185    }
186
187    /// Get transitive dependents of a type (all types that depend on it, recursively).
188    #[must_use]
189    pub fn transitive_dependents(&self, type_name: &str) -> HashSet<String> {
190        let mut visited = HashSet::new();
191        let mut to_visit = vec![type_name.to_string()];
192
193        while let Some(current) = to_visit.pop() {
194            if !visited.insert(current.clone()) {
195                continue;
196            }
197
198            if let Some(refs) = self.incoming.get(&current) {
199                for ref_type in refs {
200                    if !visited.contains(ref_type) {
201                        to_visit.push(ref_type.clone());
202                    }
203                }
204            }
205        }
206
207        // Remove the starting type
208        visited.remove(type_name);
209        visited
210    }
211}