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}