Skip to main content

fraiseql_core/federation/
composition_validator.rs

1//! Multi-subgraph federation composition validation and schema merging
2//!
3//! This module implements comprehensive validation for composing multiple federated subgraphs
4//! into a single Apollo Federation supergraph. It validates:
5//!
6//! - **@key Consistency**: All @extends types must use the same @key as the primary type
7//! - **@external Ownership**: Each @external field must have exactly one owning subgraph
8//! - **@shareable Consistency**: If a field is @shareable in one subgraph, it must be in all
9//! - **Type Merging**: Proper composition of type definitions across subgraphs
10//!
11//! # Example
12//!
13//! ```ignore
14//! let subgraphs = vec![
15//!     ("users".to_string(), users_metadata),
16//!     ("orders".to_string(), orders_metadata),
17//! ];
18//!
19//! let validator = CompositionValidator::new();
20//! let composed = validator.validate_composition(subgraphs)?;
21//! ```
22//!
23//! # Architecture
24//!
25//! The composition process works in two phases:
26//!
27//! 1. **Consistency Validation** (CrossSubgraphValidator)
28//!    - Validates across all subgraphs simultaneously
29//!    - Checks federation directives for conflicts
30//!    - Collects all errors before returning
31//!
32//! 2. **Schema Composition** (CompositionValidator)
33//!    - Merges type definitions from all subgraphs
34//!    - Errors on type conflicts (safest default)
35//!    - Produces final supergraph schema
36
37use std::collections::HashMap;
38
39use tracing::{debug, info, warn};
40
41use crate::federation::types::{FederatedType, FederationMetadata};
42
43/// Errors during schema composition
44///
45/// These errors indicate problems with multi-subgraph federation that prevent
46/// the supergraph from being composed. Each error includes context for debugging.
47#[derive(Debug, Clone)]
48pub enum CompositionError {
49    /// @external field has no owning subgraph
50    ///
51    /// An @external field was marked in a subgraph extension, but no other subgraph
52    /// defines this field as local (non-external).
53    ExternalFieldNoOwner { field: String },
54
55    /// @external field owned by multiple subgraphs
56    ///
57    /// An @external field reference conflicts: multiple subgraphs claim to own it.
58    /// Only one subgraph can own each @external field.
59    ExternalFieldMultipleOwners { field: String, owners: Vec<String> },
60
61    /// @key directive mismatch across subgraphs
62    ///
63    /// The @key directive on a type differs across subgraphs. All subgraphs must
64    /// agree on the @key for a given type.
65    KeyMismatch {
66        typename: String,
67        key_a:    Vec<String>,
68        key_b:    Vec<String>,
69    },
70
71    /// @shareable field conflict (shareable in one, not in another)
72    ///
73    /// A field is marked @shareable in one subgraph but not in another.
74    /// @shareable must be consistent across all subgraphs that define a field.
75    ShareableFieldConflict {
76        typename:   String,
77        field:      String,
78        subgraph_a: String,
79        subgraph_b: String,
80    },
81
82    /// Type definition conflict
83    ///
84    /// A type definition conflict that doesn't fit other categories.
85    TypeConflict { typename: String, reason: String },
86}
87
88impl std::fmt::Display for CompositionError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::ExternalFieldNoOwner { field } => {
92                write!(
93                    f,
94                    "External field '{}' has no owning subgraph. \
95                     This field was marked @external but no subgraph defines it.",
96                    field
97                )
98            },
99            Self::ExternalFieldMultipleOwners { field, owners } => {
100                write!(
101                    f,
102                    "External field '{}' owned by multiple subgraphs: {}. \
103                     Each @external field must be owned by exactly one subgraph.",
104                    field,
105                    owners.join(", ")
106                )
107            },
108            Self::KeyMismatch {
109                typename,
110                key_a,
111                key_b,
112            } => {
113                write!(
114                    f,
115                    "@key mismatch for type '{}': {} vs {}. \
116                     All subgraphs must use the same @key directive.",
117                    typename,
118                    key_a.join(","),
119                    key_b.join(",")
120                )
121            },
122            Self::ShareableFieldConflict {
123                typename,
124                field,
125                subgraph_a,
126                subgraph_b,
127            } => {
128                write!(
129                    f,
130                    "@shareable conflict on {}.{} between '{}' and '{}'. \
131                     Either both subgraphs must mark the field @shareable or neither.",
132                    typename, field, subgraph_a, subgraph_b
133                )
134            },
135            Self::TypeConflict { typename, reason } => {
136                write!(f, "Type '{}' conflict: {}", typename, reason)
137            },
138        }
139    }
140}
141
142impl std::error::Error for CompositionError {}
143
144/// Validator for cross-subgraph federation consistency
145///
146/// Validates that multiple federated subgraphs form a valid federation by checking:
147/// - @key directive consistency across all subgraphs
148/// - @external field ownership rules
149/// - @shareable field consistency
150///
151/// Named subgraphs are used in error messages for clear debugging context.
152#[derive(Debug)]
153pub struct CrossSubgraphValidator {
154    subgraphs: Vec<(String, FederationMetadata)>,
155}
156
157impl CrossSubgraphValidator {
158    /// Create a new cross-subgraph validator with named subgraphs
159    ///
160    /// # Arguments
161    /// - `subgraphs`: Vector of (subgraph_name, metadata) tuples
162    ///
163    /// Subgraph names are used in error messages to identify problem sources.
164    pub fn new(subgraphs: Vec<(String, FederationMetadata)>) -> Self {
165        Self { subgraphs }
166    }
167
168    /// Validate consistency across all subgraphs
169    ///
170    /// Performs comprehensive cross-subgraph validation and collects all errors
171    /// before returning. This allows callers to see all problems at once.
172    ///
173    /// # Errors
174    ///
175    /// Returns `Err` with vector of all consistency errors found.
176    /// Returns `Ok(())` if all validation passes.
177    pub fn validate_consistency(&self) -> Result<(), Vec<CompositionError>> {
178        let mut errors = Vec::new();
179
180        info!("Starting cross-subgraph validation for {} subgraph(s)", self.subgraphs.len());
181
182        // Validate @key consistency
183        debug!("Validating @key directive consistency");
184        if let Err(key_errors) = self.validate_key_consistency() {
185            errors.extend(key_errors);
186        }
187
188        // Validate @external field ownership
189        debug!("Validating @external field ownership");
190        if let Err(external_errors) = self.validate_external_field_ownership() {
191            errors.extend(external_errors);
192        }
193
194        // Validate @shareable field consistency
195        debug!("Validating @shareable field consistency");
196        if let Err(shareable_errors) = self.validate_shareable_consistency() {
197            errors.extend(shareable_errors);
198        }
199
200        if errors.is_empty() {
201            info!("Validation successful - all subgraph consistent");
202            Ok(())
203        } else {
204            warn!("Validation failed with {} errors", errors.len());
205            Err(errors)
206        }
207    }
208
209    /// Validate @key directives are consistent across subgraphs
210    ///
211    /// Ensures that all @extends definitions of a type use the same @key as the primary definition.
212    fn validate_key_consistency(&self) -> Result<(), Vec<CompositionError>> {
213        let mut errors = Vec::new();
214        let mut type_keys: HashMap<String, Vec<String>> = HashMap::new();
215
216        // Collect all @key definitions per type (from primary definitions only)
217        for (sg_name, metadata) in &self.subgraphs {
218            for ftype in &metadata.types {
219                if !ftype.is_extends {
220                    // Primary definition of this type
221                    if let Some(key_directive) = ftype.keys.first() {
222                        type_keys
223                            .entry(ftype.name.clone())
224                            .or_insert_with(|| key_directive.fields.clone());
225                        debug!(
226                            "Found primary definition of {} in {} with @key({})",
227                            ftype.name,
228                            sg_name,
229                            key_directive.fields.join(",")
230                        );
231                    }
232                }
233            }
234        }
235
236        // Validate all extensions have same keys as primary
237        for (sg_name, metadata) in &self.subgraphs {
238            for ftype in &metadata.types {
239                if ftype.is_extends {
240                    // Extended definition - must match primary key
241                    if let Some(primary_key) = type_keys.get(&ftype.name) {
242                        if let Some(key_directive) = ftype.keys.first() {
243                            if &key_directive.fields != primary_key {
244                                warn!(
245                                    "Key mismatch for {} in {}: expected @key({}), found @key({})",
246                                    ftype.name,
247                                    sg_name,
248                                    primary_key.join(","),
249                                    key_directive.fields.join(",")
250                                );
251                                errors.push(CompositionError::KeyMismatch {
252                                    typename: ftype.name.clone(),
253                                    key_a:    primary_key.clone(),
254                                    key_b:    key_directive.fields.clone(),
255                                });
256                            }
257                        }
258                    }
259                }
260            }
261        }
262
263        if errors.is_empty() {
264            Ok(())
265        } else {
266            Err(errors)
267        }
268    }
269
270    /// Validate @external field ownership rules
271    fn validate_external_field_ownership(&self) -> Result<(), Vec<CompositionError>> {
272        let mut errors = Vec::new();
273        let mut field_owners: HashMap<String, Vec<String>> = HashMap::new();
274
275        // Collect which subgraphs own each field
276        for (sg_name, metadata) in &self.subgraphs {
277            for ftype in &metadata.types {
278                if !ftype.is_extends {
279                    for field in ftype.field_directives.keys() {
280                        let field_key = format!("{}.{}", ftype.name, field);
281                        field_owners
282                            .entry(field_key)
283                            .or_insert_with(Vec::new)
284                            .push(sg_name.clone());
285                    }
286                }
287            }
288        }
289
290        // Check external field declarations don't conflict
291        for (_sg_name, metadata) in &self.subgraphs {
292            for ftype in &metadata.types {
293                if ftype.is_extends {
294                    for external_field in &ftype.external_fields {
295                        let field_key = format!("{}.{}", ftype.name, external_field);
296
297                        // External field must have exactly one owner
298                        let owners = field_owners.get(&field_key);
299                        if owners.is_none() {
300                            errors.push(CompositionError::ExternalFieldNoOwner {
301                                field: field_key.clone(),
302                            });
303                        }
304                    }
305                }
306            }
307        }
308
309        if errors.is_empty() {
310            Ok(())
311        } else {
312            Err(errors)
313        }
314    }
315
316    /// Validate @shareable field consistency
317    fn validate_shareable_consistency(&self) -> Result<(), Vec<CompositionError>> {
318        let mut errors = Vec::new();
319        let mut field_shareable: HashMap<String, HashMap<String, bool>> = HashMap::new();
320
321        // Collect shareable status per field
322        for (sg_name, metadata) in &self.subgraphs {
323            for ftype in &metadata.types {
324                for (field_name, directives) in &ftype.field_directives {
325                    let field_key = format!("{}.{}", ftype.name, field_name);
326                    field_shareable
327                        .entry(field_key)
328                        .or_insert_with(HashMap::new)
329                        .insert(sg_name.clone(), directives.shareable);
330                }
331            }
332        }
333
334        // Check consistency: if shareable in one subgraph, must be in all
335        for (field_key, shareable_map) in &field_shareable {
336            let any_shareable = shareable_map.values().any(|&s| s);
337            if any_shareable {
338                // If any subgraph marks as shareable, all must
339                if let Some((sg1, &s1)) = shareable_map.iter().next() {
340                    for (sg2, &s2) in shareable_map.iter().skip(1) {
341                        if s1 != s2 {
342                            let parts: Vec<&str> = field_key.split('.').collect();
343                            if parts.len() == 2 {
344                                errors.push(CompositionError::ShareableFieldConflict {
345                                    typename:   parts[0].to_string(),
346                                    field:      parts[1].to_string(),
347                                    subgraph_a: sg1.clone(),
348                                    subgraph_b: sg2.clone(),
349                                });
350                            }
351                        }
352                    }
353                }
354            }
355        }
356
357        if errors.is_empty() {
358            Ok(())
359        } else {
360            Err(errors)
361        }
362    }
363}
364
365/// Configuration for schema composition
366///
367/// Determines how composition handles conflicts when multiple subgraphs define the same type or
368/// field.
369#[derive(Debug, Clone, Copy)]
370pub enum ConflictResolutionStrategy {
371    /// Fail on any conflict (default)
372    ///
373    /// The composition fails immediately when any conflict is detected.
374    /// This is the safest strategy for production deployments.
375    Error,
376
377    /// First definition wins
378    ///
379    /// When multiple subgraphs define the same type or field, the first one in the
380    /// composition order is used. Other definitions are ignored (with warnings).
381    FirstWins,
382
383    /// Allow only if both are @shareable
384    ///
385    /// Conflicts are only allowed if all definitions are marked @shareable.
386    /// Non-shareable conflicting definitions cause composition failure.
387    ShareableOnly,
388}
389
390/// Composes multiple subgraph schemas into a supergraph
391///
392/// Validates cross-subgraph consistency and produces a composed supergraph schema
393/// that combines all federated types and directives from input subgraphs.
394#[derive(Debug)]
395pub struct CompositionValidator;
396
397impl CompositionValidator {
398    /// Create a new composition validator
399    ///
400    /// Type conflicts always produce errors (safest default).
401    ///
402    /// # Example
403    /// ```ignore
404    /// let validator = CompositionValidator::new();
405    /// ```
406    pub fn new() -> Self {
407        Self
408    }
409
410    /// Validate and compose multiple subgraphs into a supergraph
411    ///
412    /// Performs two-phase composition:
413    /// 1. **Validate**: Cross-subgraph consistency checking
414    /// 2. **Compose**: Merge types into supergraph schema
415    ///
416    /// # Arguments
417    /// - `subgraphs`: Named subgraph metadata to compose
418    ///
419    /// # Errors
420    /// Returns vector of all composition errors if validation or composition fails.
421    ///
422    /// # Example
423    /// ```ignore
424    /// let subgraphs = vec![
425    ///     ("users".to_string(), users_metadata),
426    ///     ("orders".to_string(), orders_metadata),
427    /// ];
428    ///
429    /// let validator = CompositionValidator::new();
430    /// let composed = validator.validate_composition(subgraphs)?;
431    /// ```
432    pub fn validate_composition(
433        &self,
434        subgraphs: Vec<(String, FederationMetadata)>,
435    ) -> Result<ComposedSchema, Vec<CompositionError>> {
436        info!("Starting schema composition for {} subgraph(s)", subgraphs.len());
437
438        // First validate cross-subgraph consistency
439        let cross_validator = CrossSubgraphValidator::new(subgraphs.clone());
440        if let Err(errors) = cross_validator.validate_consistency() {
441            warn!("Composition validation failed with {} errors", errors.len());
442            return Err(errors);
443        }
444
445        // Then compose into supergraph
446        debug!("Validation passed - proceeding with schema composition");
447        let composed = self.compose_subgraphs(subgraphs)?;
448
449        info!("Composition complete - produced supergraph with {} types", composed.types.len());
450        Ok(composed)
451    }
452
453    /// Compose subgraphs into a single supergraph schema
454    fn compose_subgraphs(
455        &self,
456        subgraphs: Vec<(String, FederationMetadata)>,
457    ) -> Result<ComposedSchema, Vec<CompositionError>> {
458        let mut composed = ComposedSchema::new();
459        let mut type_definitions: HashMap<String, ComposedType> = HashMap::new();
460
461        // Merge all types from all subgraphs
462        for (_sg_name, metadata) in subgraphs {
463            for ftype in metadata.types {
464                let key = ftype.name.clone();
465
466                match type_definitions.entry(key) {
467                    std::collections::hash_map::Entry::Vacant(entry) => {
468                        // First definition of this type
469                        entry.insert(ComposedType::from_federated(&ftype));
470                    },
471                    std::collections::hash_map::Entry::Occupied(mut entry) => {
472                        // Merge with existing definition
473                        entry.get_mut().merge_from(&ftype);
474                    },
475                }
476            }
477        }
478
479        composed.types = type_definitions.values().cloned().collect();
480        Ok(composed)
481    }
482}
483
484impl Default for CompositionValidator {
485    fn default() -> Self {
486        Self::new()
487    }
488}
489
490/// Composed supergraph schema
491///
492/// Represents the final composed schema combining all federated types from input subgraphs.
493/// This is the output of the composition process and serves as the supergraph schema.
494#[derive(Debug, Clone)]
495pub struct ComposedSchema {
496    /// All types in the composed supergraph
497    ///
498    /// This includes both primary type definitions and extended definitions
499    /// from all subgraphs, merged appropriately.
500    pub types: Vec<ComposedType>,
501}
502
503impl ComposedSchema {
504    /// Create a new empty composed schema
505    pub fn new() -> Self {
506        Self { types: Vec::new() }
507    }
508}
509
510impl Default for ComposedSchema {
511    fn default() -> Self {
512        Self::new()
513    }
514}
515
516/// A type in the composed supergraph
517///
518/// Represents a single GraphQL type that may be composed from:
519/// - A primary definition in one subgraph
520/// - Extensions in other subgraphs
521///
522/// All definitions are preserved to track the composition structure.
523#[derive(Debug, Clone)]
524pub struct ComposedType {
525    /// Type name (e.g., "User", "Order")
526    pub name: String,
527
528    /// All definitions of this type (primary + extensions)
529    ///
530    /// Typically:
531    /// - First definition is the primary (non-extended) definition
532    /// - Remaining are @extends definitions from other subgraphs
533    pub definitions: Vec<FederatedType>,
534
535    /// Is this type extended in any subgraph?
536    ///
537    /// True if any @extends definition exists for this type.
538    pub is_extended: bool,
539}
540
541impl ComposedType {
542    /// Create a composed type from a federated type definition
543    ///
544    /// # Arguments
545    /// - `ftype`: The initial federated type definition
546    pub fn from_federated(ftype: &FederatedType) -> Self {
547        Self {
548            name:        ftype.name.clone(),
549            definitions: vec![ftype.clone()],
550            is_extended: ftype.is_extends,
551        }
552    }
553
554    /// Merge another federated type definition into this composed type
555    ///
556    /// Used to build up the complete composed type from multiple subgraph definitions.
557    pub fn merge_from(&mut self, ftype: &FederatedType) {
558        self.definitions.push(ftype.clone());
559        if ftype.is_extends {
560            self.is_extended = true;
561        }
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn test_composition_validator_creation() {
571        let _validator = CompositionValidator::new();
572    }
573
574    #[test]
575    fn test_cross_subgraph_validator_creation() {
576        let subgraphs = vec![];
577        let _validator = CrossSubgraphValidator::new(subgraphs);
578    }
579
580    #[test]
581    fn test_composed_schema_creation() {
582        let schema = ComposedSchema::new();
583        assert!(schema.types.is_empty());
584    }
585
586    #[test]
587    fn test_composed_type_from_federated() {
588        let ftype = FederatedType::new("User".to_string());
589        let composed = ComposedType::from_federated(&ftype);
590        assert_eq!(composed.name, "User");
591        assert!(!composed.is_extended);
592    }
593
594    #[test]
595    fn test_composed_type_merge() {
596        let user_primary = FederatedType::new("User".to_string());
597        let mut user_extension = FederatedType::new("User".to_string());
598        user_extension.is_extends = true;
599
600        let mut composed = ComposedType::from_federated(&user_primary);
601        composed.merge_from(&user_extension);
602
603        assert_eq!(composed.definitions.len(), 2);
604        assert!(composed.is_extended);
605    }
606}