Skip to main content

fraiseql_core/schema/
dependency_graph.rs

1//! Schema dependency graph analysis.
2//!
3//! This module provides tools for analyzing type dependencies in a compiled schema,
4//! including cycle detection, unused type detection, and impact analysis.
5//!
6//! # Example
7//!
8//! ```
9//! use fraiseql_core::schema::{CompiledSchema, SchemaDependencyGraph};
10//!
11//! let schema = CompiledSchema::default();
12//! let graph = SchemaDependencyGraph::build(&schema);
13//!
14//! // Check for circular dependencies
15//! let cycles = graph.find_cycles();
16//! if !cycles.is_empty() {
17//!     for cycle in &cycles {
18//!         println!("Cycle detected: {}", cycle.path_string());
19//!     }
20//! }
21//!
22//! // Find unused types
23//! let unused = graph.find_unused();
24//! for type_name in &unused {
25//!     println!("Unused type: {}", type_name);
26//! }
27//! ```
28
29use std::collections::{HashMap, HashSet};
30
31use super::{CompiledSchema, FieldType};
32
33/// A path representing a circular dependency in the schema.
34///
35/// Contains the sequence of type names that form the cycle.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CyclePath {
38    /// Type names in the cycle, in dependency order.
39    /// For a cycle A → B → C → A, this would be `["A", "B", "C"]`.
40    pub nodes: Vec<String>,
41}
42
43impl CyclePath {
44    /// Create a new cycle path from a list of nodes.
45    #[must_use]
46    pub fn new(nodes: Vec<String>) -> Self {
47        Self { nodes }
48    }
49
50    /// Format the cycle as a readable path string.
51    ///
52    /// # Example
53    ///
54    /// ```
55    /// use fraiseql_core::schema::CyclePath;
56    ///
57    /// let cycle = CyclePath::new(vec!["A".to_string(), "B".to_string(), "C".to_string()]);
58    /// assert_eq!(cycle.path_string(), "A → B → C → A");
59    /// ```
60    #[must_use]
61    pub fn path_string(&self) -> String {
62        if self.nodes.is_empty() {
63            return String::new();
64        }
65        let mut path = self.nodes.join(" → ");
66        // Add the first node at the end to show it's a cycle
67        path.push_str(" → ");
68        path.push_str(&self.nodes[0]);
69        path
70    }
71
72    /// Check if this is a self-referencing cycle (single node).
73    #[must_use]
74    pub fn is_self_reference(&self) -> bool {
75        self.nodes.len() == 1
76    }
77
78    /// Get the length of the cycle (number of types involved).
79    #[must_use]
80    pub fn len(&self) -> usize {
81        self.nodes.len()
82    }
83
84    /// Check if the cycle is empty (should never happen in practice).
85    #[must_use]
86    pub fn is_empty(&self) -> bool {
87        self.nodes.is_empty()
88    }
89}
90
91/// Result of analyzing the impact of deleting or modifying a type.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct ChangeImpact {
94    /// Types that would be affected by this change.
95    pub affected_types:   HashSet<String>,
96    /// Human-readable descriptions of breaking changes.
97    pub breaking_changes: Vec<String>,
98}
99
100impl ChangeImpact {
101    /// Create a new change impact result.
102    #[must_use]
103    pub fn new(affected_types: HashSet<String>, breaking_changes: Vec<String>) -> Self {
104        Self {
105            affected_types,
106            breaking_changes,
107        }
108    }
109
110    /// Check if this change has any impact.
111    #[must_use]
112    pub fn has_impact(&self) -> bool {
113        !self.affected_types.is_empty()
114    }
115}
116
117/// Schema dependency graph for analyzing type relationships.
118///
119/// This graph tracks which types depend on which other types, enabling:
120/// - Circular dependency detection
121/// - Unused type detection
122/// - Impact analysis for schema changes
123#[derive(Debug, Clone)]
124pub struct SchemaDependencyGraph {
125    /// Map of type name to types it depends on (outgoing edges).
126    outgoing:   HashMap<String, HashSet<String>>,
127    /// Map of type name to types that depend on it (incoming edges).
128    incoming:   HashMap<String, HashSet<String>>,
129    /// All type names in the schema.
130    all_types:  HashSet<String>,
131    /// Root types that are always considered "used" (Query, Mutation, Subscription).
132    root_types: HashSet<String>,
133}
134
135impl SchemaDependencyGraph {
136    /// Build a dependency graph from a compiled schema.
137    ///
138    /// This analyzes all types, queries, mutations, and subscriptions to
139    /// build a complete dependency graph.
140    #[must_use]
141    pub fn build(schema: &CompiledSchema) -> Self {
142        let mut outgoing: HashMap<String, HashSet<String>> = HashMap::new();
143        let mut incoming: HashMap<String, HashSet<String>> = HashMap::new();
144        let mut all_types: HashSet<String> = HashSet::new();
145        let mut root_types: HashSet<String> = HashSet::new();
146
147        // Collect all type names first
148        for type_def in &schema.types {
149            all_types.insert(type_def.name.clone());
150            outgoing.entry(type_def.name.clone()).or_default();
151            incoming.entry(type_def.name.clone()).or_default();
152        }
153
154        for enum_def in &schema.enums {
155            all_types.insert(enum_def.name.clone());
156            outgoing.entry(enum_def.name.clone()).or_default();
157            incoming.entry(enum_def.name.clone()).or_default();
158        }
159
160        for input_def in &schema.input_types {
161            all_types.insert(input_def.name.clone());
162            outgoing.entry(input_def.name.clone()).or_default();
163            incoming.entry(input_def.name.clone()).or_default();
164        }
165
166        for interface_def in &schema.interfaces {
167            all_types.insert(interface_def.name.clone());
168            outgoing.entry(interface_def.name.clone()).or_default();
169            incoming.entry(interface_def.name.clone()).or_default();
170        }
171
172        for union_def in &schema.unions {
173            all_types.insert(union_def.name.clone());
174            outgoing.entry(union_def.name.clone()).or_default();
175            incoming.entry(union_def.name.clone()).or_default();
176        }
177
178        // Add virtual root types for operations
179        if !schema.queries.is_empty() {
180            root_types.insert("Query".to_string());
181            all_types.insert("Query".to_string());
182            outgoing.entry("Query".to_string()).or_default();
183            incoming.entry("Query".to_string()).or_default();
184        }
185        if !schema.mutations.is_empty() {
186            root_types.insert("Mutation".to_string());
187            all_types.insert("Mutation".to_string());
188            outgoing.entry("Mutation".to_string()).or_default();
189            incoming.entry("Mutation".to_string()).or_default();
190        }
191        if !schema.subscriptions.is_empty() {
192            root_types.insert("Subscription".to_string());
193            all_types.insert("Subscription".to_string());
194            outgoing.entry("Subscription".to_string()).or_default();
195            incoming.entry("Subscription".to_string()).or_default();
196        }
197
198        // Build dependencies for object types
199        for type_def in &schema.types {
200            for field in &type_def.fields {
201                if let Some(ref_type) = Self::extract_referenced_type(&field.field_type) {
202                    if all_types.contains(&ref_type) {
203                        outgoing.entry(type_def.name.clone()).or_default().insert(ref_type.clone());
204                        incoming.entry(ref_type.clone()).or_default().insert(type_def.name.clone());
205                    }
206                }
207            }
208
209            // Track interface implementations
210            for interface_name in &type_def.implements {
211                if all_types.contains(interface_name) {
212                    outgoing
213                        .entry(type_def.name.clone())
214                        .or_default()
215                        .insert(interface_name.clone());
216                    incoming
217                        .entry(interface_name.clone())
218                        .or_default()
219                        .insert(type_def.name.clone());
220                }
221            }
222        }
223
224        // Build dependencies for interfaces
225        for interface_def in &schema.interfaces {
226            for field in &interface_def.fields {
227                if let Some(ref_type) = Self::extract_referenced_type(&field.field_type) {
228                    if all_types.contains(&ref_type) {
229                        outgoing
230                            .entry(interface_def.name.clone())
231                            .or_default()
232                            .insert(ref_type.clone());
233                        incoming
234                            .entry(ref_type.clone())
235                            .or_default()
236                            .insert(interface_def.name.clone());
237                    }
238                }
239            }
240        }
241
242        // Build dependencies for unions
243        for union_def in &schema.unions {
244            for member_type in &union_def.member_types {
245                if all_types.contains(member_type) {
246                    outgoing.entry(union_def.name.clone()).or_default().insert(member_type.clone());
247                    incoming.entry(member_type.clone()).or_default().insert(union_def.name.clone());
248                }
249            }
250        }
251
252        // Build dependencies for input types (they can reference other input types)
253        for input_def in &schema.input_types {
254            for field in &input_def.fields {
255                // Parse the field_type string to extract type references
256                let parsed = FieldType::parse(&field.field_type, &all_types);
257                if let Some(ref_type) = Self::extract_referenced_type(&parsed) {
258                    if all_types.contains(&ref_type) {
259                        outgoing
260                            .entry(input_def.name.clone())
261                            .or_default()
262                            .insert(ref_type.clone());
263                        incoming
264                            .entry(ref_type.clone())
265                            .or_default()
266                            .insert(input_def.name.clone());
267                    }
268                }
269            }
270        }
271
272        // Build dependencies from queries to their return types
273        for query in &schema.queries {
274            let parsed = FieldType::parse(&query.return_type, &all_types);
275            if let Some(ref_type) = Self::extract_referenced_type(&parsed) {
276                if all_types.contains(&ref_type) {
277                    outgoing.entry("Query".to_string()).or_default().insert(ref_type.clone());
278                    incoming.entry(ref_type.clone()).or_default().insert("Query".to_string());
279                }
280            }
281        }
282
283        // Build dependencies from mutations to their return types
284        for mutation in &schema.mutations {
285            let parsed = FieldType::parse(&mutation.return_type, &all_types);
286            if let Some(ref_type) = Self::extract_referenced_type(&parsed) {
287                if all_types.contains(&ref_type) {
288                    outgoing.entry("Mutation".to_string()).or_default().insert(ref_type.clone());
289                    incoming.entry(ref_type.clone()).or_default().insert("Mutation".to_string());
290                }
291            }
292        }
293
294        // Build dependencies from subscriptions to their return types
295        for subscription in &schema.subscriptions {
296            let parsed = FieldType::parse(&subscription.return_type, &all_types);
297            if let Some(ref_type) = Self::extract_referenced_type(&parsed) {
298                if all_types.contains(&ref_type) {
299                    outgoing
300                        .entry("Subscription".to_string())
301                        .or_default()
302                        .insert(ref_type.clone());
303                    incoming
304                        .entry(ref_type.clone())
305                        .or_default()
306                        .insert("Subscription".to_string());
307                }
308            }
309        }
310
311        Self {
312            outgoing,
313            incoming,
314            all_types,
315            root_types,
316        }
317    }
318
319    /// Extract the referenced type name from a `FieldType`, recursively unwrapping lists.
320    fn extract_referenced_type(field_type: &FieldType) -> Option<String> {
321        match field_type {
322            FieldType::Object(name)
323            | FieldType::Enum(name)
324            | FieldType::Input(name)
325            | FieldType::Interface(name)
326            | FieldType::Union(name) => Some(name.clone()),
327            FieldType::List(inner) => Self::extract_referenced_type(inner),
328            _ => None, // Scalars don't create dependencies
329        }
330    }
331
332    /// Get all types that a given type depends on (outgoing edges).
333    #[must_use]
334    pub fn dependencies_of(&self, type_name: &str) -> Vec<String> {
335        self.outgoing
336            .get(type_name)
337            .map(|deps| {
338                let mut v: Vec<_> = deps.iter().cloned().collect();
339                v.sort();
340                v
341            })
342            .unwrap_or_default()
343    }
344
345    /// Get all types that depend on a given type (incoming edges).
346    #[must_use]
347    pub fn dependents_of(&self, type_name: &str) -> Vec<String> {
348        self.incoming
349            .get(type_name)
350            .map(|refs| {
351                let mut v: Vec<_> = refs.iter().cloned().collect();
352                v.sort();
353                v
354            })
355            .unwrap_or_default()
356    }
357
358    /// Get all type names in the graph.
359    #[must_use]
360    pub fn all_types(&self) -> Vec<String> {
361        let mut types: Vec<_> = self.all_types.iter().cloned().collect();
362        types.sort();
363        types
364    }
365
366    /// Get the total number of types in the graph.
367    #[must_use]
368    pub fn type_count(&self) -> usize {
369        self.all_types.len()
370    }
371
372    /// Check if a type exists in the graph.
373    #[must_use]
374    pub fn has_type(&self, type_name: &str) -> bool {
375        self.all_types.contains(type_name)
376    }
377
378    /// Find all circular dependencies in the schema.
379    ///
380    /// Returns a list of cycle paths. Each cycle is reported once,
381    /// starting from the lexicographically smallest type name.
382    #[must_use]
383    pub fn find_cycles(&self) -> Vec<CyclePath> {
384        let mut visited = HashSet::new();
385        let mut rec_stack = HashSet::new();
386        let mut cycles = Vec::new();
387
388        for type_name in &self.all_types {
389            if !visited.contains(type_name) {
390                self.dfs_find_cycles(
391                    type_name,
392                    &mut visited,
393                    &mut rec_stack,
394                    &mut Vec::new(),
395                    &mut cycles,
396                );
397            }
398        }
399
400        // Normalize and deduplicate cycles
401        Self::normalize_cycles(cycles)
402    }
403
404    /// DFS helper for cycle detection.
405    fn dfs_find_cycles(
406        &self,
407        node: &str,
408        visited: &mut HashSet<String>,
409        rec_stack: &mut HashSet<String>,
410        path: &mut Vec<String>,
411        cycles: &mut Vec<CyclePath>,
412    ) {
413        visited.insert(node.to_string());
414        rec_stack.insert(node.to_string());
415        path.push(node.to_string());
416
417        if let Some(deps) = self.outgoing.get(node) {
418            for dep in deps {
419                if !visited.contains(dep) {
420                    self.dfs_find_cycles(dep, visited, rec_stack, path, cycles);
421                } else if rec_stack.contains(dep) {
422                    // Found a cycle - extract it from the path
423                    if let Some(start_idx) = path.iter().position(|x| x == dep) {
424                        let cycle_nodes: Vec<String> = path[start_idx..].to_vec();
425                        cycles.push(CyclePath::new(cycle_nodes));
426                    }
427                }
428            }
429        }
430
431        rec_stack.remove(node);
432        path.pop();
433    }
434
435    /// Normalize cycles so each cycle starts from its lexicographically smallest node,
436    /// and deduplicate equivalent cycles.
437    fn normalize_cycles(mut cycles: Vec<CyclePath>) -> Vec<CyclePath> {
438        // Normalize each cycle to start from the smallest element
439        for cycle in &mut cycles {
440            if cycle.nodes.is_empty() {
441                continue;
442            }
443            // Find the position of the minimum element
444            let min_pos = cycle
445                .nodes
446                .iter()
447                .enumerate()
448                .min_by_key(|(_, name)| *name)
449                .map(|(i, _)| i)
450                .unwrap_or(0);
451
452            // Rotate so minimum is first
453            cycle.nodes.rotate_left(min_pos);
454        }
455
456        // Sort by nodes for consistent ordering
457        cycles.sort_by(|a, b| a.nodes.cmp(&b.nodes));
458
459        // Deduplicate
460        cycles.dedup();
461
462        cycles
463    }
464
465    /// Find all types that have no incoming references (orphaned types).
466    ///
467    /// Root types (Query, Mutation, Subscription) are excluded from this list
468    /// as they are always considered used.
469    #[must_use]
470    pub fn find_unused(&self) -> Vec<String> {
471        let mut unused = Vec::new();
472
473        for type_name in &self.all_types {
474            // Skip root types - they're always used
475            if self.root_types.contains(type_name) {
476                continue;
477            }
478
479            // Check if any type references this one
480            let has_references =
481                self.incoming.get(type_name).map(|refs| !refs.is_empty()).unwrap_or(false);
482
483            if !has_references {
484                unused.push(type_name.clone());
485            }
486        }
487
488        unused.sort();
489        unused
490    }
491
492    /// Analyze the impact of deleting a type from the schema.
493    ///
494    /// Returns all types that would be affected (transitively) by removing
495    /// the specified type.
496    #[must_use]
497    pub fn impact_of_deletion(&self, type_name: &str) -> ChangeImpact {
498        let mut affected = HashSet::new();
499        let mut breaking_changes = Vec::new();
500
501        // Find all types that depend on this type (directly or transitively)
502        let mut to_visit = vec![type_name.to_string()];
503        let mut visited = HashSet::new();
504
505        while let Some(current) = to_visit.pop() {
506            if !visited.insert(current.clone()) {
507                continue;
508            }
509
510            // Get types that depend on current
511            if let Some(dependents) = self.incoming.get(&current) {
512                for dependent in dependents {
513                    if !visited.contains(dependent) {
514                        affected.insert(dependent.clone());
515                        to_visit.push(dependent.clone());
516                        breaking_changes.push(format!(
517                            "Type '{}' references '{}' which would be deleted",
518                            dependent, type_name
519                        ));
520                    }
521                }
522            }
523        }
524
525        // Remove the type itself from affected (it's the one being deleted)
526        affected.remove(type_name);
527
528        ChangeImpact::new(affected, breaking_changes)
529    }
530
531    /// Get transitive dependencies of a type (all types it depends on, recursively).
532    #[must_use]
533    pub fn transitive_dependencies(&self, type_name: &str) -> HashSet<String> {
534        let mut visited = HashSet::new();
535        let mut to_visit = vec![type_name.to_string()];
536
537        while let Some(current) = to_visit.pop() {
538            if !visited.insert(current.clone()) {
539                continue;
540            }
541
542            if let Some(deps) = self.outgoing.get(&current) {
543                for dep in deps {
544                    if !visited.contains(dep) {
545                        to_visit.push(dep.clone());
546                    }
547                }
548            }
549        }
550
551        // Remove the starting type
552        visited.remove(type_name);
553        visited
554    }
555
556    /// Get transitive dependents of a type (all types that depend on it, recursively).
557    #[must_use]
558    pub fn transitive_dependents(&self, type_name: &str) -> HashSet<String> {
559        let mut visited = HashSet::new();
560        let mut to_visit = vec![type_name.to_string()];
561
562        while let Some(current) = to_visit.pop() {
563            if !visited.insert(current.clone()) {
564                continue;
565            }
566
567            if let Some(refs) = self.incoming.get(&current) {
568                for ref_type in refs {
569                    if !visited.contains(ref_type) {
570                        to_visit.push(ref_type.clone());
571                    }
572                }
573            }
574        }
575
576        // Remove the starting type
577        visited.remove(type_name);
578        visited
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use crate::schema::{
586        EnumDefinition, EnumValueDefinition, FieldDefinition, InputFieldDefinition,
587        InputObjectDefinition, InterfaceDefinition, MutationDefinition, QueryDefinition,
588        SubscriptionDefinition, TypeDefinition, UnionDefinition,
589    };
590
591    /// Helper to create a simple type with the given fields.
592    fn make_type(name: &str, fields: Vec<(&str, FieldType)>) -> TypeDefinition {
593        TypeDefinition {
594            name:                name.to_string(),
595            sql_source:          format!("v_{}", name.to_lowercase()),
596            jsonb_column:        "data".to_string(),
597            fields:              fields
598                .into_iter()
599                .map(|(n, ft)| FieldDefinition::new(n, ft))
600                .collect(),
601            description:         None,
602            sql_projection_hint: None,
603            implements:          vec![],
604            is_error:            false,
605        }
606    }
607
608    // =========================================================================
609    // Basic Graph Construction Tests
610    // =========================================================================
611
612    #[test]
613    fn test_empty_schema() {
614        let schema = CompiledSchema::default();
615        let graph = SchemaDependencyGraph::build(&schema);
616
617        assert_eq!(graph.type_count(), 0);
618        assert!(graph.find_cycles().is_empty());
619        assert!(graph.find_unused().is_empty());
620    }
621
622    #[test]
623    fn test_single_type_no_dependencies() {
624        let schema = CompiledSchema {
625            types: vec![make_type(
626                "User",
627                vec![
628                    ("id", FieldType::Id),
629                    ("name", FieldType::String),
630                    ("email", FieldType::String),
631                ],
632            )],
633            queries: vec![QueryDefinition::new("users", "User").returning_list()],
634            ..Default::default()
635        };
636
637        let graph = SchemaDependencyGraph::build(&schema);
638
639        assert!(graph.has_type("User"));
640        assert!(graph.has_type("Query"));
641        assert_eq!(graph.dependencies_of("User").len(), 0);
642        assert_eq!(graph.dependents_of("User"), vec!["Query"]);
643    }
644
645    #[test]
646    fn test_type_with_object_reference() {
647        let schema = CompiledSchema {
648            types: vec![
649                make_type(
650                    "User",
651                    vec![
652                        ("id", FieldType::Id),
653                        ("profile", FieldType::Object("Profile".to_string())),
654                    ],
655                ),
656                make_type("Profile", vec![("bio", FieldType::String)]),
657            ],
658            queries: vec![QueryDefinition::new("users", "User").returning_list()],
659            ..Default::default()
660        };
661
662        let graph = SchemaDependencyGraph::build(&schema);
663
664        // User depends on Profile
665        assert_eq!(graph.dependencies_of("User"), vec!["Profile"]);
666        // Profile is referenced by User
667        assert_eq!(graph.dependents_of("Profile"), vec!["User"]);
668    }
669
670    #[test]
671    fn test_type_with_list_reference() {
672        let schema = CompiledSchema {
673            types: vec![
674                make_type(
675                    "User",
676                    vec![
677                        ("id", FieldType::Id),
678                        ("posts", FieldType::List(Box::new(FieldType::Object("Post".to_string())))),
679                    ],
680                ),
681                make_type("Post", vec![("title", FieldType::String)]),
682            ],
683            queries: vec![QueryDefinition::new("users", "User").returning_list()],
684            ..Default::default()
685        };
686
687        let graph = SchemaDependencyGraph::build(&schema);
688
689        // User depends on Post (through list)
690        assert_eq!(graph.dependencies_of("User"), vec!["Post"]);
691        assert_eq!(graph.dependents_of("Post"), vec!["User"]);
692    }
693
694    #[test]
695    fn test_enum_reference() {
696        let schema = CompiledSchema {
697            types: vec![make_type(
698                "User",
699                vec![
700                    ("id", FieldType::Id),
701                    ("status", FieldType::Enum("UserStatus".to_string())),
702                ],
703            )],
704            enums: vec![EnumDefinition {
705                name:        "UserStatus".to_string(),
706                values:      vec![
707                    EnumValueDefinition::new("ACTIVE"),
708                    EnumValueDefinition::new("INACTIVE"),
709                ],
710                description: None,
711            }],
712            queries: vec![QueryDefinition::new("users", "User").returning_list()],
713            ..Default::default()
714        };
715
716        let graph = SchemaDependencyGraph::build(&schema);
717
718        assert!(graph.has_type("UserStatus"));
719        assert_eq!(graph.dependencies_of("User"), vec!["UserStatus"]);
720        assert_eq!(graph.dependents_of("UserStatus"), vec!["User"]);
721    }
722
723    // =========================================================================
724    // Cycle Detection Tests
725    // =========================================================================
726
727    #[test]
728    fn test_no_cycles() {
729        let schema = CompiledSchema {
730            types: vec![
731                make_type("User", vec![("profile", FieldType::Object("Profile".to_string()))]),
732                make_type("Profile", vec![("bio", FieldType::String)]),
733            ],
734            queries: vec![QueryDefinition::new("users", "User").returning_list()],
735            ..Default::default()
736        };
737
738        let graph = SchemaDependencyGraph::build(&schema);
739        let cycles = graph.find_cycles();
740
741        assert!(cycles.is_empty());
742    }
743
744    #[test]
745    fn test_self_referencing_cycle() {
746        let schema = CompiledSchema {
747            types: vec![make_type(
748                "Node",
749                vec![
750                    ("id", FieldType::Id),
751                    ("next", FieldType::Object("Node".to_string())),
752                ],
753            )],
754            queries: vec![QueryDefinition::new("nodes", "Node").returning_list()],
755            ..Default::default()
756        };
757
758        let graph = SchemaDependencyGraph::build(&schema);
759        let cycles = graph.find_cycles();
760
761        assert_eq!(cycles.len(), 1);
762        assert_eq!(cycles[0].nodes, vec!["Node"]);
763        assert!(cycles[0].is_self_reference());
764        assert_eq!(cycles[0].path_string(), "Node → Node");
765    }
766
767    #[test]
768    fn test_two_node_cycle() {
769        let schema = CompiledSchema {
770            types: vec![
771                make_type("A", vec![("b", FieldType::Object("B".to_string()))]),
772                make_type("B", vec![("a", FieldType::Object("A".to_string()))]),
773            ],
774            queries: vec![QueryDefinition::new("items", "A").returning_list()],
775            ..Default::default()
776        };
777
778        let graph = SchemaDependencyGraph::build(&schema);
779        let cycles = graph.find_cycles();
780
781        assert_eq!(cycles.len(), 1);
782        assert_eq!(cycles[0].len(), 2);
783        // Normalized to start from "A"
784        assert_eq!(cycles[0].nodes, vec!["A", "B"]);
785        assert_eq!(cycles[0].path_string(), "A → B → A");
786    }
787
788    #[test]
789    fn test_three_node_cycle() {
790        let schema = CompiledSchema {
791            types: vec![
792                make_type("A", vec![("b", FieldType::Object("B".to_string()))]),
793                make_type("B", vec![("c", FieldType::Object("C".to_string()))]),
794                make_type("C", vec![("a", FieldType::Object("A".to_string()))]),
795            ],
796            queries: vec![QueryDefinition::new("items", "A").returning_list()],
797            ..Default::default()
798        };
799
800        let graph = SchemaDependencyGraph::build(&schema);
801        let cycles = graph.find_cycles();
802
803        assert_eq!(cycles.len(), 1);
804        assert_eq!(cycles[0].len(), 3);
805        assert_eq!(cycles[0].nodes, vec!["A", "B", "C"]);
806        assert_eq!(cycles[0].path_string(), "A → B → C → A");
807    }
808
809    #[test]
810    fn test_multiple_independent_cycles() {
811        let schema = CompiledSchema {
812            types: vec![
813                // Cycle 1: A <-> B
814                make_type("A", vec![("b", FieldType::Object("B".to_string()))]),
815                make_type("B", vec![("a", FieldType::Object("A".to_string()))]),
816                // Cycle 2: X <-> Y
817                make_type("X", vec![("y", FieldType::Object("Y".to_string()))]),
818                make_type("Y", vec![("x", FieldType::Object("X".to_string()))]),
819            ],
820            queries: vec![
821                QueryDefinition::new("aItems", "A").returning_list(),
822                QueryDefinition::new("xItems", "X").returning_list(),
823            ],
824            ..Default::default()
825        };
826
827        let graph = SchemaDependencyGraph::build(&schema);
828        let cycles = graph.find_cycles();
829
830        assert_eq!(cycles.len(), 2);
831    }
832
833    // =========================================================================
834    // Unused Type Detection Tests
835    // =========================================================================
836
837    #[test]
838    fn test_no_unused_types() {
839        let schema = CompiledSchema {
840            types: vec![
841                make_type("User", vec![("profile", FieldType::Object("Profile".to_string()))]),
842                make_type("Profile", vec![("bio", FieldType::String)]),
843            ],
844            queries: vec![QueryDefinition::new("users", "User").returning_list()],
845            ..Default::default()
846        };
847
848        let graph = SchemaDependencyGraph::build(&schema);
849        let unused = graph.find_unused();
850
851        assert!(unused.is_empty());
852    }
853
854    #[test]
855    fn test_unused_type_no_references() {
856        let schema = CompiledSchema {
857            types: vec![
858                make_type("User", vec![("name", FieldType::String)]),
859                make_type("OrphanType", vec![("data", FieldType::String)]),
860            ],
861            queries: vec![QueryDefinition::new("users", "User").returning_list()],
862            ..Default::default()
863        };
864
865        let graph = SchemaDependencyGraph::build(&schema);
866        let unused = graph.find_unused();
867
868        assert_eq!(unused, vec!["OrphanType"]);
869    }
870
871    #[test]
872    fn test_multiple_unused_types() {
873        let schema = CompiledSchema {
874            types: vec![
875                make_type("User", vec![("name", FieldType::String)]),
876                make_type("Orphan1", vec![("data", FieldType::String)]),
877                make_type("Orphan2", vec![("data", FieldType::String)]),
878            ],
879            queries: vec![QueryDefinition::new("users", "User").returning_list()],
880            ..Default::default()
881        };
882
883        let graph = SchemaDependencyGraph::build(&schema);
884        let unused = graph.find_unused();
885
886        assert_eq!(unused, vec!["Orphan1", "Orphan2"]);
887    }
888
889    #[test]
890    fn test_root_types_never_unused() {
891        // Query type exists but has no incoming references (it's a root)
892        let schema = CompiledSchema {
893            types: vec![make_type("User", vec![("name", FieldType::String)])],
894            queries: vec![QueryDefinition::new("users", "User").returning_list()],
895            ..Default::default()
896        };
897
898        let graph = SchemaDependencyGraph::build(&schema);
899        let unused = graph.find_unused();
900
901        // Query should NOT appear in unused list (it's a root type)
902        assert!(!unused.contains(&"Query".to_string()));
903    }
904
905    // =========================================================================
906    // Impact Analysis Tests
907    // =========================================================================
908
909    #[test]
910    fn test_impact_of_deletion_no_dependents() {
911        let schema = CompiledSchema {
912            types: vec![
913                make_type("User", vec![("profile", FieldType::Object("Profile".to_string()))]),
914                make_type("Profile", vec![("bio", FieldType::String)]),
915            ],
916            queries: vec![QueryDefinition::new("users", "User").returning_list()],
917            ..Default::default()
918        };
919
920        let graph = SchemaDependencyGraph::build(&schema);
921
922        // Deleting Profile affects User (and Query transitively)
923        let impact = graph.impact_of_deletion("Profile");
924        assert!(impact.has_impact());
925        assert!(impact.affected_types.contains("User"));
926    }
927
928    #[test]
929    fn test_impact_of_deletion_chain() {
930        let schema = CompiledSchema {
931            types: vec![
932                make_type("A", vec![("b", FieldType::Object("B".to_string()))]),
933                make_type("B", vec![("c", FieldType::Object("C".to_string()))]),
934                make_type("C", vec![("d", FieldType::Object("D".to_string()))]),
935                make_type("D", vec![("value", FieldType::String)]),
936            ],
937            queries: vec![QueryDefinition::new("items", "A").returning_list()],
938            ..Default::default()
939        };
940
941        let graph = SchemaDependencyGraph::build(&schema);
942
943        // Deleting D affects C, B, A (and Query)
944        let impact = graph.impact_of_deletion("D");
945        assert!(impact.affected_types.contains("C"));
946        assert!(impact.affected_types.contains("B"));
947        assert!(impact.affected_types.contains("A"));
948    }
949
950    // =========================================================================
951    // Transitive Dependency Tests
952    // =========================================================================
953
954    #[test]
955    fn test_transitive_dependencies() {
956        let schema = CompiledSchema {
957            types: vec![
958                make_type("A", vec![("b", FieldType::Object("B".to_string()))]),
959                make_type("B", vec![("c", FieldType::Object("C".to_string()))]),
960                make_type("C", vec![("value", FieldType::String)]),
961            ],
962            queries: vec![QueryDefinition::new("items", "A").returning_list()],
963            ..Default::default()
964        };
965
966        let graph = SchemaDependencyGraph::build(&schema);
967
968        let deps = graph.transitive_dependencies("A");
969        assert!(deps.contains("B"));
970        assert!(deps.contains("C"));
971        assert!(!deps.contains("A")); // Should not include self
972    }
973
974    #[test]
975    fn test_transitive_dependents() {
976        let schema = CompiledSchema {
977            types: vec![
978                make_type("A", vec![("b", FieldType::Object("B".to_string()))]),
979                make_type("B", vec![("c", FieldType::Object("C".to_string()))]),
980                make_type("C", vec![("value", FieldType::String)]),
981            ],
982            queries: vec![QueryDefinition::new("items", "A").returning_list()],
983            ..Default::default()
984        };
985
986        let graph = SchemaDependencyGraph::build(&schema);
987
988        let refs = graph.transitive_dependents("C");
989        assert!(refs.contains("B"));
990        assert!(refs.contains("A"));
991        assert!(refs.contains("Query"));
992        assert!(!refs.contains("C")); // Should not include self
993    }
994
995    // =========================================================================
996    // Interface and Union Tests
997    // =========================================================================
998
999    #[test]
1000    fn test_interface_dependencies() {
1001        let schema = CompiledSchema {
1002            types: vec![TypeDefinition {
1003                name:                "User".to_string(),
1004                sql_source:          "v_user".to_string(),
1005                jsonb_column:        "data".to_string(),
1006                fields:              vec![FieldDefinition::new("id", FieldType::Id)],
1007                description:         None,
1008                sql_projection_hint: None,
1009                implements:          vec!["Node".to_string()],
1010                is_error:            false,
1011            }],
1012            interfaces: vec![InterfaceDefinition {
1013                name:        "Node".to_string(),
1014                fields:      vec![FieldDefinition::new("id", FieldType::Id)],
1015                description: None,
1016            }],
1017            queries: vec![QueryDefinition::new("users", "User").returning_list()],
1018            ..Default::default()
1019        };
1020
1021        let graph = SchemaDependencyGraph::build(&schema);
1022
1023        // User depends on Node (implements it)
1024        assert!(graph.dependencies_of("User").contains(&"Node".to_string()));
1025        // Node is referenced by User
1026        assert!(graph.dependents_of("Node").contains(&"User".to_string()));
1027    }
1028
1029    #[test]
1030    fn test_union_dependencies() {
1031        let schema = CompiledSchema {
1032            types: vec![
1033                make_type("User", vec![("name", FieldType::String)]),
1034                make_type("Post", vec![("title", FieldType::String)]),
1035            ],
1036            unions: vec![UnionDefinition {
1037                name:         "SearchResult".to_string(),
1038                member_types: vec!["User".to_string(), "Post".to_string()],
1039                description:  None,
1040            }],
1041            queries: vec![QueryDefinition::new("search", "SearchResult").returning_list()],
1042            ..Default::default()
1043        };
1044
1045        let graph = SchemaDependencyGraph::build(&schema);
1046
1047        // SearchResult depends on User and Post
1048        let deps = graph.dependencies_of("SearchResult");
1049        assert!(deps.contains(&"User".to_string()));
1050        assert!(deps.contains(&"Post".to_string()));
1051    }
1052
1053    // =========================================================================
1054    // Input Type Tests
1055    // =========================================================================
1056
1057    #[test]
1058    fn test_input_type_dependencies() {
1059        let schema = CompiledSchema {
1060            types: vec![make_type("User", vec![("name", FieldType::String)])],
1061            input_types: vec![
1062                InputObjectDefinition {
1063                    name:        "UserFilter".to_string(),
1064                    fields:      vec![InputFieldDefinition::new("status", "UserStatus")],
1065                    description: None,
1066                    metadata:    None,
1067                },
1068                InputObjectDefinition {
1069                    name:        "UserStatus".to_string(),
1070                    fields:      vec![InputFieldDefinition::new("active", "Boolean")],
1071                    description: None,
1072                    metadata:    None,
1073                },
1074            ],
1075            queries: vec![QueryDefinition::new("users", "User").returning_list()],
1076            ..Default::default()
1077        };
1078
1079        let graph = SchemaDependencyGraph::build(&schema);
1080
1081        // UserFilter depends on UserStatus
1082        assert!(graph.has_type("UserFilter"));
1083        assert!(graph.has_type("UserStatus"));
1084        assert!(graph.dependencies_of("UserFilter").contains(&"UserStatus".to_string()));
1085    }
1086
1087    // =========================================================================
1088    // Mutation and Subscription Tests
1089    // =========================================================================
1090
1091    #[test]
1092    fn test_mutation_return_type_dependency() {
1093        let schema = CompiledSchema {
1094            types: vec![make_type("User", vec![("name", FieldType::String)])],
1095            mutations: vec![MutationDefinition::new("createUser", "User")],
1096            ..Default::default()
1097        };
1098
1099        let graph = SchemaDependencyGraph::build(&schema);
1100
1101        assert!(graph.has_type("Mutation"));
1102        assert!(graph.dependencies_of("Mutation").contains(&"User".to_string()));
1103        assert!(graph.dependents_of("User").contains(&"Mutation".to_string()));
1104    }
1105
1106    #[test]
1107    fn test_subscription_return_type_dependency() {
1108        let schema = CompiledSchema {
1109            types: vec![make_type("User", vec![("name", FieldType::String)])],
1110            subscriptions: vec![SubscriptionDefinition::new("userCreated", "User")],
1111            ..Default::default()
1112        };
1113
1114        let graph = SchemaDependencyGraph::build(&schema);
1115
1116        assert!(graph.has_type("Subscription"));
1117        assert!(graph.dependencies_of("Subscription").contains(&"User".to_string()));
1118    }
1119
1120    // =========================================================================
1121    // CyclePath Tests
1122    // =========================================================================
1123
1124    #[test]
1125    fn test_cycle_path_formatting() {
1126        let cycle = CyclePath::new(vec!["A".to_string(), "B".to_string(), "C".to_string()]);
1127        assert_eq!(cycle.path_string(), "A → B → C → A");
1128        assert_eq!(cycle.len(), 3);
1129        assert!(!cycle.is_self_reference());
1130        assert!(!cycle.is_empty());
1131    }
1132
1133    #[test]
1134    fn test_cycle_path_self_reference() {
1135        let cycle = CyclePath::new(vec!["Node".to_string()]);
1136        assert_eq!(cycle.path_string(), "Node → Node");
1137        assert!(cycle.is_self_reference());
1138    }
1139
1140    #[test]
1141    fn test_cycle_path_empty() {
1142        let cycle = CyclePath::new(vec![]);
1143        assert_eq!(cycle.path_string(), "");
1144        assert!(cycle.is_empty());
1145    }
1146}