Skip to main content

fraiseql_core/schema/compiled/
federation_keys.rs

1//! Federation entities: refusal of an `@key` the router would reject (#1395).
2//!
3//! An entity's `key_fields` reach the served SDL verbatim — `generate_service_sdl` joins
4//! them with spaces into `@key(fields: "…")` — and the router composes the supergraph
5//! from that SDL. A key naming a field the type does not publish compiled clean, booted
6//! clean, and was then refused by the router with `KEY_INVALID_FIELDS`, far from the
7//! declaration that caused it. Nothing in the subgraph checked the key at all:
8//! `fraiseql federation check` read a `federation.types` shape that no producer writes,
9//! so on every compiled schema it validated nothing and reported success.
10//!
11//! This is the one decision. `fraiseql compile` reports it with the rest of the schema's
12//! problems, and [`CompiledSchema`]'s load path refuses a document that carries one, so
13//! a hand-edited artifact cannot reach a server either. `fraiseql federation check`
14//! loads through the same path.
15//!
16//! # What a key is checked against
17//!
18//! The key is parsed as a GraphQL field set (names and braces; commas are insignificant,
19//! as everywhere in GraphQL) and walked through the entity's type — or interface, for an
20//! entity interface. Each name must be a field of the type it is selected on, spelled
21//! exactly as published: the SDL prints [`FieldDefinition::name`] verbatim, and the router
22//! compares exactly, so a case- or style-folding match here would accept the
23//! `organization_id` key Apollo refuses when the field is published as `organizationId`.
24//! A near miss earns a hint instead.
25//!
26//! An object field must carry a nested selection and a leaf must not. A list, interface,
27//! union or input-object field cannot be part of a key (`KEY_FIELDS_SELECT_INVALID_TYPE`). Aliases,
28//! arguments, directives and fragments have no meaning in a key and are refused as
29//! malformed.
30//!
31//! An `embedded` type (#687) is a value object with no identity of its own and no
32//! `sql_source`, so `_entities` could never resolve it: it is refused as an entity
33//! whatever its key says.
34//!
35//! [`FieldDefinition::name`]: crate::schema::FieldDefinition::name
36
37use std::{collections::HashSet, fmt};
38
39use super::CompiledSchema;
40use crate::{
41    runtime::suggest_similar,
42    schema::{FieldDefinition, FieldType},
43    utils::casing::to_camel_case,
44};
45
46/// One reason a federation entity's `@key` cannot be served as declared.
47///
48/// Every variant names the entity, so a report over many entities stays attributable.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum FederationKeyProblem {
51    /// The entity names neither an object type nor an interface the schema declares.
52    UnknownType {
53        /// The entity as declared.
54        entity:      String,
55        /// Declared object types and interfaces spelled like it.
56        suggestions: Vec<String>,
57    },
58    /// The entity is declared twice; only one declaration can reach the SDL.
59    DuplicateEntity {
60        /// The entity as declared.
61        entity: String,
62    },
63    /// The entity is an `embedded` value type, which has no identity to key on.
64    EmbeddedType {
65        /// The entity as declared.
66        entity: String,
67    },
68    /// The key selects no field at all.
69    EmptyKey {
70        /// The entity as declared.
71        entity: String,
72    },
73    /// The key is not a field set: an alias, an argument, a directive, a fragment, an
74    /// unbalanced brace or an empty nested selection.
75    MalformedFieldSet {
76        /// The entity as declared.
77        entity:    String,
78        /// The key, joined as the SDL renders it.
79        field_set: String,
80        /// What is wrong with it.
81        reason:    String,
82    },
83    /// The key selects a field the type it is selected on does not declare.
84    MissingField {
85        /// The entity as declared.
86        entity: String,
87        /// Dotted path of the missing field from the entity (`org.region`).
88        path:   String,
89        /// The type the field was looked up on.
90        owner:  String,
91        /// The published spelling of a near miss (`organizationId` for `organization_id`).
92        hint:   Option<String>,
93    },
94    /// The key gives a nested selection to a field that has no sub-fields.
95    SelectionOnLeaf {
96        /// The entity as declared.
97        entity: String,
98        /// Dotted path of the leaf field.
99        path:   String,
100    },
101    /// The key names an object field without selecting any of its fields.
102    ObjectWithoutSelection {
103        /// The entity as declared.
104        entity: String,
105        /// Dotted path of the object field.
106        path:   String,
107    },
108    /// The key selects a list, interface, union or input-object field, which no key may
109    /// contain.
110    InvalidKeyFieldType {
111        /// The entity as declared.
112        entity: String,
113        /// Dotted path of the field.
114        path:   String,
115        /// `"a list"`, `"an interface"`, `"a union"` or `"an input object"`.
116        kind:   &'static str,
117    },
118    /// A nested selection reaches an object type the schema does not declare.
119    UnknownNestedType {
120        /// The entity as declared.
121        entity:    String,
122        /// Dotted path of the field whose type is missing.
123        path:      String,
124        /// The undeclared type name.
125        type_name: String,
126    },
127}
128
129impl fmt::Display for FederationKeyProblem {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            Self::UnknownType {
133                entity,
134                suggestions,
135            } => {
136                write!(
137                    f,
138                    "federation entity '{entity}' names no object type or interface this \
139                     schema declares"
140                )?;
141                if !suggestions.is_empty() {
142                    write!(f, " (did you mean: {}?)", suggestions.join(", "))?;
143                }
144                Ok(())
145            },
146            Self::DuplicateEntity { entity } => write!(
147                f,
148                "federation entity '{entity}' is declared more than once; one entity carries \
149                 one `@key`, so every declaration after the first would be dropped from the SDL"
150            ),
151            Self::EmbeddedType { entity } => write!(
152                f,
153                "federation entity '{entity}' is an embedded value type: it has no identity of \
154                 its own and no `sql_source`, so `_entities` could never resolve it. Drop it \
155                 from `federation.entities` (it is served as part of its parent)"
156            ),
157            Self::EmptyKey { entity } => {
158                write!(f, "federation entity '{entity}' has an `@key` that selects no field")
159            },
160            Self::MalformedFieldSet {
161                entity,
162                field_set,
163                reason,
164            } => write!(
165                f,
166                "federation entity '{entity}' has `@key(fields: \"{field_set}\")`, which is not \
167                 a field set: {reason}"
168            ),
169            Self::MissingField {
170                entity,
171                path,
172                owner,
173                hint,
174            } => {
175                write!(
176                    f,
177                    "federation entity '{entity}' is keyed on '{path}', but type '{owner}' has \
178                     no field '{}'; the router would refuse the supergraph with \
179                     KEY_INVALID_FIELDS",
180                    path.rsplit('.').next().unwrap_or(path)
181                )?;
182                if let Some(hint) = hint {
183                    write!(f, " (did you mean '{hint}'? keys name the published field)")?;
184                }
185                Ok(())
186            },
187            Self::SelectionOnLeaf { entity, path } => write!(
188                f,
189                "federation entity '{entity}' selects sub-fields of '{path}', which has none"
190            ),
191            Self::ObjectWithoutSelection { entity, path } => write!(
192                f,
193                "federation entity '{entity}' is keyed on '{path}', an object; a key must \
194                 select the object's fields, e.g. '{path} {{ id }}'"
195            ),
196            Self::InvalidKeyFieldType { entity, path, kind } => write!(
197                f,
198                "federation entity '{entity}' is keyed on '{path}', which is {kind}; a key \
199                 cannot contain a list, interface, union or input-object field \
200                 (KEY_FIELDS_SELECT_INVALID_TYPE)"
201            ),
202            Self::UnknownNestedType {
203                entity,
204                path,
205                type_name,
206            } => write!(
207                f,
208                "federation entity '{entity}' selects into '{path}', whose type '{type_name}' \
209                 this schema does not declare"
210            ),
211        }
212    }
213}
214
215/// A parsed field-set selection: a field name and its (possibly empty) sub-selection.
216#[derive(Debug)]
217struct Selection {
218    name:     String,
219    children: Vec<Selection>,
220}
221
222/// Parse a federation field set. Names and braces only; commas and whitespace separate.
223fn parse_field_set(input: &str) -> Result<Vec<Selection>, String> {
224    let mut stack: Vec<Vec<Selection>> = vec![Vec::new()];
225    let mut chars = input.char_indices().peekable();
226
227    while let Some(&(start, c)) = chars.peek() {
228        if c.is_whitespace() || c == ',' {
229            chars.next();
230        } else if c == '_' || c.is_ascii_alphabetic() {
231            let mut end = start;
232            while let Some(&(i, c)) = chars.peek() {
233                if c == '_' || c.is_ascii_alphanumeric() {
234                    end = i + c.len_utf8();
235                    chars.next();
236                } else {
237                    break;
238                }
239            }
240            let level = stack.last_mut().ok_or("unbalanced braces")?;
241            level.push(Selection {
242                name:     input[start..end].to_string(),
243                children: Vec::new(),
244            });
245        } else if c == '{' {
246            chars.next();
247            let has_parent = stack
248                .last()
249                .is_some_and(|level| level.last().is_some_and(|s| s.children.is_empty()));
250            if !has_parent {
251                return Err("'{' must follow the field it selects into".to_string());
252            }
253            stack.push(Vec::new());
254        } else if c == '}' {
255            chars.next();
256            if stack.len() < 2 {
257                return Err("unbalanced braces".to_string());
258            }
259            let children = stack.pop().unwrap_or_default();
260            if children.is_empty() {
261                return Err("an empty selection '{}'".to_string());
262            }
263            let parent =
264                stack.last_mut().and_then(|level| level.last_mut()).ok_or("unbalanced braces")?;
265            parent.children = children;
266        } else {
267            return Err(format!(
268                "unexpected '{c}' (aliases, arguments, directives and fragments have no \
269                 meaning in a key)"
270            ));
271        }
272    }
273
274    if stack.len() != 1 {
275        return Err("unbalanced braces".to_string());
276    }
277    Ok(stack.pop().unwrap_or_default())
278}
279
280/// The field a key selects on `fields`, or the near miss the author probably meant.
281fn lookup<'a>(
282    fields: &'a [FieldDefinition],
283    name: &str,
284) -> Result<&'a FieldDefinition, Option<String>> {
285    if let Some(field) = fields.iter().find(|f| f.name == name) {
286        return Ok(field);
287    }
288    let wanted = to_camel_case(name);
289    Err(fields
290        .iter()
291        .find(|f| to_camel_case(f.name.as_str()) == wanted)
292        .map(|f| f.name.to_string()))
293}
294
295impl CompiledSchema {
296    /// Every reason a federation entity's `@key` cannot be served as declared (#1395).
297    ///
298    /// Empty when the schema declares no federation block, or every entity is a
299    /// declared, non-embedded type or interface whose key selects published fields.
300    /// Called from `finish_load` and from `fraiseql compile`, so neither path can accept a
301    /// key the other would refuse.
302    #[must_use]
303    pub fn federation_key_problems(&self) -> Vec<FederationKeyProblem> {
304        let Some(federation) = &self.federation else {
305            return Vec::new();
306        };
307        let mut problems = Vec::new();
308        let mut seen: HashSet<&str> = HashSet::new();
309
310        for entity in &federation.entities {
311            let name = entity.name.as_str();
312            if !seen.insert(name) {
313                problems.push(FederationKeyProblem::DuplicateEntity {
314                    entity: name.to_string(),
315                });
316                continue;
317            }
318
319            let fields = if let Some(type_def) = self.find_type(name) {
320                if type_def.embedded {
321                    problems.push(FederationKeyProblem::EmbeddedType {
322                        entity: name.to_string(),
323                    });
324                    continue;
325                }
326                &type_def.fields
327            } else if let Some(interface) = self.find_interface(name) {
328                &interface.fields
329            } else {
330                let known: Vec<&str> = self
331                    .types
332                    .iter()
333                    .map(|t| t.name.as_str())
334                    .chain(self.interfaces.iter().map(|i| i.name.as_str()))
335                    .collect();
336                problems.push(FederationKeyProblem::UnknownType {
337                    entity:      name.to_string(),
338                    suggestions: suggest_similar(name, &known)
339                        .into_iter()
340                        .map(ToString::to_string)
341                        .collect(),
342                });
343                continue;
344            };
345
346            let field_set = entity.key_fields.join(" ");
347            match parse_field_set(&field_set) {
348                Err(reason) => problems.push(FederationKeyProblem::MalformedFieldSet {
349                    entity: name.to_string(),
350                    field_set,
351                    reason,
352                }),
353                Ok(selections) if selections.is_empty() => {
354                    problems.push(FederationKeyProblem::EmptyKey {
355                        entity: name.to_string(),
356                    });
357                },
358                Ok(selections) => {
359                    self.check_selections(name, name, "", fields, &selections, &mut problems);
360                },
361            }
362        }
363        problems
364    }
365
366    /// [`Self::federation_key_problems`], one sentence each.
367    #[must_use]
368    pub fn federation_key_violations(&self) -> Vec<String> {
369        self.federation_key_problems().iter().map(ToString::to_string).collect()
370    }
371
372    fn check_selections(
373        &self,
374        entity: &str,
375        owner: &str,
376        prefix: &str,
377        fields: &[FieldDefinition],
378        selections: &[Selection],
379        problems: &mut Vec<FederationKeyProblem>,
380    ) {
381        for selection in selections {
382            let path = if prefix.is_empty() {
383                selection.name.clone()
384            } else {
385                format!("{prefix}.{}", selection.name)
386            };
387            let field = match lookup(fields, &selection.name) {
388                Ok(field) => field,
389                Err(hint) => {
390                    problems.push(FederationKeyProblem::MissingField {
391                        entity: entity.to_string(),
392                        path,
393                        owner: owner.to_string(),
394                        hint,
395                    });
396                    continue;
397                },
398            };
399
400            let invalid = |kind| FederationKeyProblem::InvalidKeyFieldType {
401                entity: entity.to_string(),
402                path: path.clone(),
403                kind,
404            };
405            match &field.field_type {
406                FieldType::List(_) => problems.push(invalid("a list")),
407                FieldType::Interface(_) => problems.push(invalid("an interface")),
408                FieldType::Union(_) => problems.push(invalid("a union")),
409                FieldType::Input(_) => problems.push(invalid("an input object")),
410                FieldType::Object(type_name) => {
411                    if selection.children.is_empty() {
412                        problems.push(FederationKeyProblem::ObjectWithoutSelection {
413                            entity: entity.to_string(),
414                            path,
415                        });
416                    } else if let Some(nested) = self.find_type(type_name) {
417                        self.check_selections(
418                            entity,
419                            type_name,
420                            &path,
421                            &nested.fields,
422                            &selection.children,
423                            problems,
424                        );
425                    } else {
426                        problems.push(FederationKeyProblem::UnknownNestedType {
427                            entity: entity.to_string(),
428                            path,
429                            type_name: type_name.clone(),
430                        });
431                    }
432                },
433                FieldType::String
434                | FieldType::Int
435                | FieldType::Float
436                | FieldType::Boolean
437                | FieldType::Id
438                | FieldType::DateTime
439                | FieldType::Date
440                | FieldType::Time
441                | FieldType::Json
442                | FieldType::Uuid
443                | FieldType::Decimal
444                | FieldType::Vector
445                | FieldType::BitVector
446                | FieldType::HalfVector
447                | FieldType::SparseVector
448                | FieldType::Scalar(_)
449                | FieldType::Enum(_) => {
450                    if !selection.children.is_empty() {
451                        problems.push(FederationKeyProblem::SelectionOnLeaf {
452                            entity: entity.to_string(),
453                            path,
454                        });
455                    }
456                },
457            }
458        }
459    }
460}