diff --git a/crates/libgraphql-core-v1/src/schema/mod.rs b/crates/libgraphql-core-v1/src/schema/mod.rs index 5d37c4b..be3433a 100644 --- a/crates/libgraphql-core-v1/src/schema/mod.rs +++ b/crates/libgraphql-core-v1/src/schema/mod.rs @@ -1,3 +1,4 @@ +mod pending_type_extension; mod schema_build_error; mod schema_builder; mod schema_def; diff --git a/crates/libgraphql-core-v1/src/schema/pending_type_extension.rs b/crates/libgraphql-core-v1/src/schema/pending_type_extension.rs new file mode 100644 index 0000000..ff5d478 --- /dev/null +++ b/crates/libgraphql-core-v1/src/schema/pending_type_extension.rs @@ -0,0 +1,295 @@ +use crate::directive_annotation::DirectiveAnnotation; +use crate::located::Located; +use crate::names::TypeName; +use crate::schema::schema_build_error::SchemaBuildError; +use crate::span::SourceMapId; +use crate::span::Span; +use crate::type_builders::ast_helpers; +use crate::type_builders::EnumValueDefBuilder; +use crate::type_builders::FieldDefBuilder; +use crate::type_builders::InputFieldDefBuilder; +use crate::types::GraphQLTypeKind; +use libgraphql_parser::ast; + +/// An owned, deferred representation of a parsed type extension. +/// +/// [`SchemaBuilder`](crate::schema::SchemaBuilder) converts each +/// `extend Name ...` AST node into this owned form +/// immediately (the parser AST borrows source text and cannot +/// outlive `load_str()`). If the extension's target type is +/// already registered, the extension is applied right away; +/// otherwise it is held pending until the target's definition +/// arrives -- extensions may legally precede the definition in +/// load order. Extensions still pending when +/// [`SchemaBuilder::build()`](crate::schema::SchemaBuilder::build) +/// runs produce `ExtensionOfUndefinedType` errors. +/// +/// See [Type Extensions](https://spec.graphql.org/September2025/#sec-Type-Extensions). +#[derive(Debug)] +pub(crate) enum PendingTypeExtension { + Enum(PendingEnumTypeExtension), + InputObject(PendingInputObjectTypeExtension), + Interface(PendingFieldedTypeExtension), + Object(PendingFieldedTypeExtension), + Scalar(PendingScalarTypeExtension), + Union(PendingUnionTypeExtension), +} + +/// Owned contributions of an `extend enum Name ...` extension. +/// +/// See [Enum Extensions](https://spec.graphql.org/September2025/#sec-Enum-Extensions). +#[derive(Debug)] +pub(crate) struct PendingEnumTypeExtension { + pub(crate) directives: Vec, + pub(crate) span: Span, + pub(crate) type_name: TypeName, + pub(crate) values: Vec, +} + +/// Owned contributions of an `extend type Name ...` or +/// `extend interface Name ...` extension (object and interface +/// extensions share the same shape: fields, `implements` +/// declarations, and directives). +/// +/// See +/// [Object Extensions](https://spec.graphql.org/September2025/#sec-Object-Extensions) +/// and +/// [Interface Extensions](https://spec.graphql.org/September2025/#sec-Interface-Extensions). +#[derive(Debug)] +pub(crate) struct PendingFieldedTypeExtension { + pub(crate) directives: Vec, + pub(crate) fields: Vec, + pub(crate) implements: Vec>, + pub(crate) span: Span, + pub(crate) type_name: TypeName, +} + +/// Owned contributions of an `extend input Name ...` extension. +/// +/// See [Input Object Extensions](https://spec.graphql.org/September2025/#sec-Input-Object-Extensions). +#[derive(Debug)] +pub(crate) struct PendingInputObjectTypeExtension { + pub(crate) directives: Vec, + pub(crate) fields: Vec, + pub(crate) span: Span, + pub(crate) type_name: TypeName, +} + +/// Owned contributions of an `extend scalar Name ...` extension +/// (scalar extensions may only contribute directives). +/// +/// See [Scalar Extensions](https://spec.graphql.org/September2025/#sec-Scalar-Extensions). +#[derive(Debug)] +pub(crate) struct PendingScalarTypeExtension { + pub(crate) directives: Vec, + pub(crate) span: Span, + pub(crate) type_name: TypeName, +} + +/// Owned contributions of an `extend union Name ...` extension. +/// +/// See [Union Extensions](https://spec.graphql.org/September2025/#sec-Union-Extensions). +#[derive(Debug)] +pub(crate) struct PendingUnionTypeExtension { + pub(crate) directives: Vec, + pub(crate) members: Vec>, + pub(crate) span: Span, + pub(crate) type_name: TypeName, +} + +impl PendingTypeExtension { + /// Converts a parsed type extension AST node into an owned + /// `PendingTypeExtension`. Returns `Err` with all collected + /// conversion errors if any are found (matching the eager + /// `from_ast()` convention used by the type builders). + pub(crate) fn from_ast( + ext: &ast::TypeExtension<'_>, + source_map_id: SourceMapId, + ) -> Result> { + match ext { + ast::TypeExtension::Enum(e) => { + Ok(Self::Enum(PendingEnumTypeExtension { + directives: directives_from_ast( + &e.directives, source_map_id, + ), + span: ast_helpers::span_from_ast( + e.span, source_map_id, + ), + type_name: TypeName::new(e.name.value.as_ref()), + values: e.values.iter().map(|v| { + EnumValueDefBuilder::from_ast(v, source_map_id) + }).collect(), + })) + }, + ast::TypeExtension::InputObject(io) => { + Ok(Self::InputObject(PendingInputObjectTypeExtension { + directives: directives_from_ast( + &io.directives, source_map_id, + ), + fields: io.fields.iter().map(|f| { + InputFieldDefBuilder::from_ast(f, source_map_id) + }).collect(), + span: ast_helpers::span_from_ast( + io.span, source_map_id, + ), + type_name: TypeName::new(io.name.value.as_ref()), + })) + }, + ast::TypeExtension::Interface(i) => { + Ok(Self::Interface(fielded_from_ast( + &i.directives, + &i.fields, + &i.implements, + &i.name, + i.span, + source_map_id, + )?)) + }, + ast::TypeExtension::Object(o) => { + Ok(Self::Object(fielded_from_ast( + &o.directives, + &o.fields, + &o.implements, + &o.name, + o.span, + source_map_id, + )?)) + }, + ast::TypeExtension::Scalar(s) => { + Ok(Self::Scalar(PendingScalarTypeExtension { + directives: directives_from_ast( + &s.directives, source_map_id, + ), + span: ast_helpers::span_from_ast( + s.span, source_map_id, + ), + type_name: TypeName::new(s.name.value.as_ref()), + })) + }, + ast::TypeExtension::Union(u) => { + Ok(Self::Union(PendingUnionTypeExtension { + directives: directives_from_ast( + &u.directives, source_map_id, + ), + members: u.members.iter().map(|m| Located { + value: TypeName::new(m.value.as_ref()), + span: ast_helpers::span_from_ast( + m.span, source_map_id, + ), + }).collect(), + span: ast_helpers::span_from_ast( + u.span, source_map_id, + ), + type_name: TypeName::new(u.name.value.as_ref()), + })) + }, + } + } + + /// The [`GraphQLTypeKind`] this extension's `extend ` + /// keyword corresponds to (used for kind-mismatch + /// diagnostics). + pub(crate) fn extension_kind(&self) -> GraphQLTypeKind { + match self { + Self::Enum(_) => GraphQLTypeKind::Enum, + Self::InputObject(_) => GraphQLTypeKind::InputObject, + Self::Interface(_) => GraphQLTypeKind::Interface, + Self::Object(_) => GraphQLTypeKind::Object, + Self::Scalar(_) => GraphQLTypeKind::Scalar, + Self::Union(_) => GraphQLTypeKind::Union, + } + } + + /// The source span of the whole `extend ...` production. + pub(crate) fn span(&self) -> Span { + match self { + Self::Enum(ext) => ext.span, + Self::InputObject(ext) => ext.span, + Self::Interface(ext) => ext.span, + Self::Object(ext) => ext.span, + Self::Scalar(ext) => ext.span, + Self::Union(ext) => ext.span, + } + } + + /// The URL of the spec section defining this extension + /// kind's validation rules. + pub(crate) fn spec_url(&self) -> &'static str { + match self { + Self::Enum(_) => { + "https://spec.graphql.org/September2025/#sec-Enum-Extensions" + }, + Self::InputObject(_) => { + "https://spec.graphql.org/September2025/#sec-Input-Object-Extensions" + }, + Self::Interface(_) => { + "https://spec.graphql.org/September2025/#sec-Interface-Extensions" + }, + Self::Object(_) => { + "https://spec.graphql.org/September2025/#sec-Object-Extensions" + }, + Self::Scalar(_) => { + "https://spec.graphql.org/September2025/#sec-Scalar-Extensions" + }, + Self::Union(_) => { + "https://spec.graphql.org/September2025/#sec-Union-Extensions" + }, + } + } + + /// The name of the type this extension targets. + pub(crate) fn type_name(&self) -> &TypeName { + match self { + Self::Enum(ext) => &ext.type_name, + Self::InputObject(ext) => &ext.type_name, + Self::Interface(ext) => &ext.type_name, + Self::Object(ext) => &ext.type_name, + Self::Scalar(ext) => &ext.type_name, + Self::Union(ext) => &ext.type_name, + } + } +} + +/// Converts a slice of AST directive annotations into owned +/// [`DirectiveAnnotation`]s. +fn directives_from_ast( + ast_directives: &[ast::DirectiveAnnotation<'_>], + source_map_id: SourceMapId, +) -> Vec { + ast_directives.iter().map(|d| { + ast_helpers::directive_annotation_from_ast(d, source_map_id) + }).collect() +} + +/// Shared conversion for object and interface type extensions +/// (identical AST shapes). +fn fielded_from_ast( + ast_directives: &[ast::DirectiveAnnotation<'_>], + ast_fields: &[ast::FieldDefinition<'_>], + ast_implements: &[ast::Name<'_>], + ast_name: &ast::Name<'_>, + ast_span: libgraphql_parser::ByteSpan, + source_map_id: SourceMapId, +) -> Result> { + let mut errors = vec![]; + let mut fields = vec![]; + for field in ast_fields { + match FieldDefBuilder::from_ast(field, source_map_id) { + Ok(field_builder) => fields.push(field_builder), + Err(field_errors) => errors.extend(field_errors), + } + } + if !errors.is_empty() { + return Err(errors); + } + Ok(PendingFieldedTypeExtension { + directives: directives_from_ast(ast_directives, source_map_id), + fields, + implements: ast_implements.iter().map(|iface| Located { + value: TypeName::new(iface.value.as_ref()), + span: ast_helpers::span_from_ast(iface.span, source_map_id), + }).collect(), + span: ast_helpers::span_from_ast(ast_span, source_map_id), + type_name: TypeName::new(ast_name.value.as_ref()), + }) +} diff --git a/crates/libgraphql-core-v1/src/schema/schema_build_error.rs b/crates/libgraphql-core-v1/src/schema/schema_build_error.rs index a825d3a..5d454a4 100644 --- a/crates/libgraphql-core-v1/src/schema/schema_build_error.rs +++ b/crates/libgraphql-core-v1/src/schema/schema_build_error.rs @@ -190,8 +190,13 @@ pub enum SchemaBuildErrorKind { value_name: String, }, - #[error("type extension kind mismatch for `{type_name}`")] + #[error( + "type extension kind mismatch for `{type_name}`: \ + {extension_kind} extension applied to {actual_kind} type" + )] InvalidExtensionTypeKind { + actual_kind: crate::types::GraphQLTypeKind, + extension_kind: crate::types::GraphQLTypeKind, type_name: String, }, @@ -203,6 +208,14 @@ pub enum SchemaBuildErrorKind { #[error("schema has no Query root operation type defined")] NoQueryOperationTypeDefined, + #[error( + "the `@oneOf` directive must not be provided by an input \ + object type extension (on `{type_name}`)" + )] + OneOfDirectiveProvidedByInputObjectExtension { + type_name: String, + }, + #[error("error parsing schema string: {message}")] ParseError { message: String, diff --git a/crates/libgraphql-core-v1/src/schema/schema_builder.rs b/crates/libgraphql-core-v1/src/schema/schema_builder.rs index c9fdf79..f4b23af 100644 --- a/crates/libgraphql-core-v1/src/schema/schema_builder.rs +++ b/crates/libgraphql-core-v1/src/schema/schema_builder.rs @@ -4,6 +4,11 @@ use crate::names::DirectiveName; use crate::names::FieldName; use crate::names::TypeName; use crate::operation_kind::OperationKind; +use crate::schema::pending_type_extension::PendingEnumTypeExtension; +use crate::schema::pending_type_extension::PendingFieldedTypeExtension; +use crate::schema::pending_type_extension::PendingInputObjectTypeExtension; +use crate::schema::pending_type_extension::PendingTypeExtension; +use crate::schema::pending_type_extension::PendingUnionTypeExtension; use crate::schema::schema_build_error::SchemaBuildError; use crate::schema::schema_build_error::SchemaBuildErrorKind; use crate::schema::schema_def::Schema; @@ -12,6 +17,9 @@ use crate::schema_source_map::SchemaSourceMap; use crate::span::SourceMapId; use crate::span::Span; use crate::type_builders::ast_helpers; +use crate::type_builders::conversion_helpers::enum_value_from_builder; +use crate::type_builders::conversion_helpers::field_def_from_builder; +use crate::type_builders::conversion_helpers::input_field_from_builder; use crate::type_builders::conversion_helpers::param_def_from_builder; use crate::type_builders::DirectiveBuilder; use crate::type_builders::EnumTypeBuilder; @@ -24,11 +32,15 @@ use crate::type_builders::UnionTypeBuilder; use crate::types::DirectiveDefinition; use crate::types::DirectiveDefinitionKind; use crate::types::DirectiveLocationKind; +use crate::types::EnumType; +use crate::types::FieldedTypeData; use crate::types::GraphQLType; +use crate::types::InputObjectType; use crate::types::ParameterDefinition; use crate::types::ScalarKind; use crate::types::ScalarType; use crate::types::TypeAnnotation; +use crate::types::UnionType; use crate::validators::InputObjectTypeValidator; use crate::validators::ObjectOrInterfaceTypeValidator; use crate::validators::UnionTypeValidator; @@ -54,6 +66,12 @@ pub struct SchemaBuilder { directive_defs: IndexMap, errors: Vec, mutation_type_name: Option<(TypeName, Span)>, + /// Type extensions whose target type has not been defined + /// yet. Keyed by target type name; each entry holds the + /// extensions in arrival order. Applied when the target's + /// definition is absorbed; any still pending at `build()` + /// produce `ExtensionOfUndefinedType` errors. + pending_extensions: IndexMap>, query_type_name: Option<(TypeName, Span)>, source_maps: Vec, subscription_type_name: Option<(TypeName, Span)>, @@ -80,6 +98,7 @@ impl SchemaBuilder { directive_defs: IndexMap::new(), errors: vec![], mutation_type_name: None, + pending_extensions: IndexMap::new(), query_type_name: None, source_maps: vec![SchemaSourceMap::builtin()], subscription_type_name: None, @@ -290,7 +309,19 @@ impl SchemaBuilder { // Convert builder to GraphQLType and insert let graphql_type = builder.into_graphql_type(); - self.types.insert(name, graphql_type); + self.types.insert(name.clone(), graphql_type); + + // Apply any extensions that arrived before this type's + // definition (extensions may legally precede the + // definition in load order), in arrival order. Merge + // errors are deferred to `build()`. + // + // https://spec.graphql.org/September2025/#sec-Type-Extensions + if let Some(pending) = self.pending_extensions.shift_remove(&name) { + for ext in pending { + self.apply_type_extension(ext); + } + } Ok(self) } @@ -436,10 +467,10 @@ impl SchemaBuilder { } /// Iterates over all definitions in a parsed document and - /// absorbs type definitions, directive definitions, and - /// `schema { ... }` definitions. Skips schema extensions, - /// type extensions, operation definitions, and fragment - /// definitions (which are not first-pass schema-level + /// absorbs type definitions, directive definitions, + /// `schema { ... }` definitions, type extensions, and + /// schema extensions. Skips operation definitions and + /// fragment definitions (which are not schema-level /// definitions). fn load_document( &mut self, @@ -470,10 +501,14 @@ impl SchemaBuilder { ast::Definition::SchemaDefinition(sd) => { self.load_schema_definition(sd, source_map_id); }, - // Skip extensions, operations, fragments - ast::Definition::SchemaExtension(_) - | ast::Definition::TypeExtension(_) - | ast::Definition::OperationDefinition(_) + ast::Definition::SchemaExtension(se) => { + self.load_schema_extension(se, source_map_id); + }, + ast::Definition::TypeExtension(te) => { + self.load_type_extension(te, source_map_id); + }, + // Skip operations and fragments + ast::Definition::OperationDefinition(_) | ast::Definition::FragmentDefinition(_) => {}, } } @@ -529,7 +564,39 @@ impl SchemaBuilder { sd: &ast::SchemaDefinition<'_>, source_map_id: SourceMapId, ) { - for root_op in &sd.root_operations { + self.load_root_operations(&sd.root_operations, source_map_id); + } + + /// Processes an `extend schema { ... }` extension, merging + /// its root operation type bindings with the same duplicate + /// handling as a `schema { ... }` definition. + /// + /// Schema-level directive annotations are not yet stored on + /// [`Schema`] (they are dropped for `schema { ... }` + /// definitions too), so extension directives are likewise + /// not retained here. + /// + /// See [Schema Extension](https://spec.graphql.org/September2025/#sec-Schema-Extension). + fn load_schema_extension( + &mut self, + se: &ast::SchemaExtension<'_>, + source_map_id: SourceMapId, + ) { + self.load_root_operations(&se.root_operations, source_map_id); + } + + /// Binds root operation types (query, mutation, + /// subscription) from a `schema { ... }` definition or an + /// `extend schema { ... }` extension. Rebinding an + /// already-bound root operation kind is an error. + /// + /// See [Root Operation Types](https://spec.graphql.org/September2025/#sec-Root-Operation-Types). + fn load_root_operations( + &mut self, + root_operations: &[ast::RootOperationTypeDefinition<'_>], + source_map_id: SourceMapId, + ) { + for root_op in root_operations { let type_name = TypeName::new( root_op.named_type.value.as_ref(), ); @@ -562,6 +629,10 @@ impl SchemaBuilder { "first defined here", *existing_span, ), + ErrorNote::spec( + "https://spec.graphql.org/September2025/\ + #sec-Root-Operation-Types", + ), ], )); } else { @@ -570,6 +641,114 @@ impl SchemaBuilder { } } + /// Processes an `extend Name ...` type extension. + /// + /// If the target type is already registered, the extension + /// is merged into it immediately. Otherwise the extension + /// is stored pending and applied when (if) the target's + /// definition is absorbed -- extensions may legally precede + /// the definition in load order. Extensions still pending + /// at [`build()`](Self::build) time produce + /// `ExtensionOfUndefinedType` errors. + /// + /// See [Type Extensions](https://spec.graphql.org/September2025/#sec-Type-Extensions). + fn load_type_extension( + &mut self, + te: &ast::TypeExtension<'_>, + source_map_id: SourceMapId, + ) { + let ext = match PendingTypeExtension::from_ast(te, source_map_id) { + Ok(ext) => ext, + Err(errs) => { + self.errors.extend(errs); + return; + }, + }; + if self.types.contains_key(ext.type_name()) { + self.apply_type_extension(ext); + } else { + self.pending_extensions + .entry(ext.type_name().clone()) + .or_default() + .push(ext); + } + } + + /// Merges a type extension into its (already-registered) + /// target type in place. If the extension's kind does not + /// match the target type's kind, an + /// `InvalidExtensionTypeKind` error is recorded and the + /// extension is not applied. + /// + /// See [Type Extensions](https://spec.graphql.org/September2025/#sec-Type-Extensions). + fn apply_type_extension(&mut self, ext: PendingTypeExtension) { + let ext_kind = ext.extension_kind(); + let ext_span = ext.span(); + let spec_url = ext.spec_url(); + let Some(target) = self.types.get_mut(ext.type_name()) else { + // Callers only invoke this for registered targets. + return; + }; + match (target, ext) { + (GraphQLType::Enum(t), PendingTypeExtension::Enum(pe)) => { + merge_enum_type_extension( + t, pe, spec_url, &mut self.errors, + ); + }, + ( + GraphQLType::InputObject(t), + PendingTypeExtension::InputObject(pe), + ) => { + merge_input_object_type_extension( + t, pe, spec_url, &mut self.errors, + ); + }, + ( + GraphQLType::Interface(t), + PendingTypeExtension::Interface(pe), + ) => { + merge_fielded_type_extension( + &mut t.0, pe, spec_url, &mut self.errors, + ); + }, + (GraphQLType::Object(t), PendingTypeExtension::Object(pe)) => { + merge_fielded_type_extension( + &mut t.0, pe, spec_url, &mut self.errors, + ); + }, + (GraphQLType::Scalar(t), PendingTypeExtension::Scalar(pe)) => { + // Scalar extensions may only contribute + // directives. + // + // https://spec.graphql.org/September2025/#sec-Scalar-Extensions + t.directives.extend(pe.directives); + }, + (GraphQLType::Union(t), PendingTypeExtension::Union(pe)) => { + merge_union_type_extension( + t, pe, spec_url, &mut self.errors, + ); + }, + (target, ext) => { + // https://spec.graphql.org/September2025/#sec-Type-Extensions + self.errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::InvalidExtensionTypeKind { + actual_kind: target.type_kind(), + extension_kind: ext_kind, + type_name: ext.type_name().to_string(), + }, + ext_span, + vec![ + ErrorNote::general_with_span( + "target type defined here", + target.span(), + ), + ErrorNote::spec(spec_url), + ], + )); + }, + } + } + /// Validates and finalizes the schema. /// /// Resolves root operation types, validates all type and @@ -600,6 +779,23 @@ impl SchemaBuilder { // large. Consider boxing once error strategy is finalized. #[allow(clippy::result_large_err)] pub fn build(mut self) -> Result { + // Any extension still pending at build time targets a + // type that was never defined. + let pending_extensions = + std::mem::take(&mut self.pending_extensions); + for (_, extensions) in pending_extensions { + for ext in extensions { + // https://spec.graphql.org/September2025/#sec-Type-Extensions + self.errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::ExtensionOfUndefinedType { + type_name: ext.type_name().to_string(), + }, + ext.span(), + vec![ErrorNote::spec(ext.spec_url())], + )); + } + } + // Step 1: Resolve root query type name. // // If an explicit `schema { query: ... }` was provided, use @@ -867,6 +1063,232 @@ impl SchemaBuilder { } } +// --------------------------------------------------------- +// Type extension merge helpers +// --------------------------------------------------------- + +/// Merges an enum type extension into the stored [`EnumType`] +/// in place: extension values are appended (duplicates +/// rejected) and extension directives are appended. +/// +/// See [Enum Extensions](https://spec.graphql.org/September2025/#sec-Enum-Extensions). +fn merge_enum_type_extension( + enum_type: &mut EnumType, + ext: PendingEnumTypeExtension, + spec_url: &'static str, + errors: &mut Vec, +) { + for value in ext.values { + if let Some(existing) = enum_type.values.get(&value.name) { + // https://spec.graphql.org/September2025/#sec-Enum-Extensions + errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::DuplicateEnumValueDefinition { + type_name: enum_type.name.to_string(), + value_name: value.name.to_string(), + }, + value.span, + vec![ + ErrorNote::general_with_span( + "first defined here", + existing.span(), + ), + ErrorNote::spec(spec_url), + ], + )); + continue; + } + let enum_value = enum_value_from_builder(value, &enum_type.name); + enum_type.values.insert(enum_value.name.clone(), enum_value); + } + enum_type.directives.extend(ext.directives); +} + +/// Merges an object or interface type extension into the stored +/// type's [`FieldedTypeData`] in place: extension fields and +/// `implements` declarations are appended (duplicates rejected, +/// `__`-prefixed field names rejected) and extension directives +/// are appended. +/// +/// See +/// [Object Extensions](https://spec.graphql.org/September2025/#sec-Object-Extensions) +/// and +/// [Interface Extensions](https://spec.graphql.org/September2025/#sec-Interface-Extensions). +fn merge_fielded_type_extension( + data: &mut FieldedTypeData, + ext: PendingFieldedTypeExtension, + spec_url: &'static str, + errors: &mut Vec, +) { + for iface in ext.implements { + let existing = data.interfaces + .iter() + .find(|l| l.value == iface.value); + if let Some(existing) = existing { + // https://spec.graphql.org/September2025/#sec-Object-Extensions + // https://spec.graphql.org/September2025/#sec-Interface-Extensions + errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::DuplicateInterfaceImplementsDeclaration { + interface_name: iface.value.to_string(), + type_name: data.name.to_string(), + }, + iface.span, + vec![ + ErrorNote::general_with_span( + "first declared here", + existing.span, + ), + ErrorNote::spec(spec_url), + ], + )); + continue; + } + data.interfaces.push(iface); + } + for field in ext.fields { + if field.name.as_str().starts_with("__") { + // https://spec.graphql.org/September2025/#sec-Names.Reserved-Names + errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::InvalidDunderPrefixedFieldName { + field_name: field.name.to_string(), + type_name: data.name.to_string(), + }, + field.span, + vec![], + )); + continue; + } + if let Some(existing) = data.fields.get(&field.name) { + // https://spec.graphql.org/September2025/#sec-Object-Extensions + // https://spec.graphql.org/September2025/#sec-Interface-Extensions + errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::DuplicateFieldNameDefinition { + field_name: field.name.to_string(), + type_name: data.name.to_string(), + }, + field.span, + vec![ + ErrorNote::general_with_span( + "first defined here", + existing.span(), + ), + ErrorNote::spec(spec_url), + ], + )); + continue; + } + let field_def = field_def_from_builder(field, &data.name); + data.fields.insert(field_def.name.clone(), field_def); + } + data.directives.extend(ext.directives); +} + +/// Merges an input object type extension into the stored +/// [`InputObjectType`] in place: extension input fields are +/// appended (duplicates rejected, `__`-prefixed field names +/// rejected) and extension directives are appended. +/// +/// See [Input Object Extensions](https://spec.graphql.org/September2025/#sec-Input-Object-Extensions). +fn merge_input_object_type_extension( + input_object_type: &mut InputObjectType, + ext: PendingInputObjectTypeExtension, + spec_url: &'static str, + errors: &mut Vec, +) { + for field in ext.fields { + if field.name.as_str().starts_with("__") { + // https://spec.graphql.org/September2025/#sec-Names.Reserved-Names + errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::InvalidDunderPrefixedFieldName { + field_name: field.name.to_string(), + type_name: input_object_type.name.to_string(), + }, + field.span, + vec![], + )); + continue; + } + if let Some(existing) = input_object_type.fields.get(&field.name) { + // https://spec.graphql.org/September2025/#sec-Input-Object-Extensions + errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::DuplicateFieldNameDefinition { + field_name: field.name.to_string(), + type_name: input_object_type.name.to_string(), + }, + field.span, + vec![ + ErrorNote::general_with_span( + "first defined here", + existing.span(), + ), + ErrorNote::spec(spec_url), + ], + )); + continue; + } + let input_field = input_field_from_builder( + field, &input_object_type.name, + ); + input_object_type.fields.insert( + input_field.name.clone(), input_field, + ); + } + for directive in ext.directives { + if directive.name().as_str() == "oneOf" { + // Input Object Extensions rule 5: "The `@oneOf` + // directive must not be provided by an Input Object + // type extension." + // https://spec.graphql.org/September2025/#sec-Input-Object-Extensions + errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::OneOfDirectiveProvidedByInputObjectExtension { + type_name: input_object_type.name.to_string(), + }, + directive.span(), + vec![ErrorNote::spec(spec_url)], + )); + continue; + } + input_object_type.directives.push(directive); + } +} + +/// Merges a union type extension into the stored [`UnionType`] +/// in place: extension members are appended (duplicates +/// rejected) and extension directives are appended. +/// +/// See [Union Extensions](https://spec.graphql.org/September2025/#sec-Union-Extensions). +fn merge_union_type_extension( + union_type: &mut UnionType, + ext: PendingUnionTypeExtension, + spec_url: &'static str, + errors: &mut Vec, +) { + for member in ext.members { + let existing = union_type.members + .iter() + .find(|m| m.value == member.value); + if let Some(existing) = existing { + // https://spec.graphql.org/September2025/#sec-Union-Extensions + errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::DuplicateUnionMember { + member_name: member.value.to_string(), + type_name: union_type.name.to_string(), + }, + member.span, + vec![ + ErrorNote::general_with_span( + "first defined here", + existing.span, + ), + ErrorNote::spec(spec_url), + ], + )); + continue; + } + union_type.members.push(member); + } + union_type.directives.extend(ext.directives); +} + // --------------------------------------------------------- // AST conversion helpers // --------------------------------------------------------- diff --git a/crates/libgraphql-core-v1/src/schema/tests/mod.rs b/crates/libgraphql-core-v1/src/schema/tests/mod.rs index 4c8ad9a..ba78e92 100644 --- a/crates/libgraphql-core-v1/src/schema/tests/mod.rs +++ b/crates/libgraphql-core-v1/src/schema/tests/mod.rs @@ -1,4 +1,5 @@ mod schema_build_error_tests; +mod schema_builder_extension_tests; mod schema_builder_tests; mod schema_errors_tests; mod type_validation_error_tests; diff --git a/crates/libgraphql-core-v1/src/schema/tests/schema_builder_extension_tests.rs b/crates/libgraphql-core-v1/src/schema/tests/schema_builder_extension_tests.rs new file mode 100644 index 0000000..81d05a4 --- /dev/null +++ b/crates/libgraphql-core-v1/src/schema/tests/schema_builder_extension_tests.rs @@ -0,0 +1,1042 @@ +use crate::error_note::ErrorNoteKind; +use crate::operation_kind::OperationKind; +use crate::schema::SchemaBuildErrorKind; +use crate::schema::SchemaBuilder; +use crate::schema::TypeValidationErrorKind; +use crate::types::GraphQLTypeKind; + +// --------------------------------------------------------- +// Object type extensions +// --------------------------------------------------------- + +// Verifies that an object type extension merges its fields and +// directive annotations into the target object type. +// +// See https://spec.graphql.org/September2025/#sec-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn object_extension_merges_fields_and_directives() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend type Query @tag { y: String }", + ).unwrap(); + let query = schema.object_type("Query").unwrap(); + assert!(query.fields().contains_key("x")); + assert!(query.fields().contains_key("y")); + assert!( + query.directives().iter().any(|d| d.name().as_str() == "tag"), + "extension directive should be merged onto the type", + ); +} + +// Verifies that an object type extension appearing textually +// BEFORE the target type's definition is deferred and then +// merged once the definition is loaded (v0-parity deferred +// extension behavior). +// +// See https://spec.graphql.org/September2025/#sec-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn object_extension_before_definition_merges() { + let schema = SchemaBuilder::build_from_str( + "extend type Query { y: String }\n\ + type Query { x: Int }", + ).unwrap(); + let query = schema.object_type("Query").unwrap(); + assert!(query.fields().contains_key("x")); + assert!(query.fields().contains_key("y")); +} + +// Verifies that an object type extension whose target type is +// never defined produces an ExtensionOfUndefinedType error at +// build() time, and that the error carries a spec-reference +// note. +// +// See https://spec.graphql.org/September2025/#sec-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn object_extension_of_undefined_type_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend type Missing { y: String }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let ext_error = errors.errors().iter().find(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::ExtensionOfUndefinedType { + type_name, + } if type_name == "Missing", + ) + }); + let ext_error = ext_error + .expect("expected ExtensionOfUndefinedType for `Missing`"); + assert!( + ext_error.notes().iter().any(|n| { + n.kind == ErrorNoteKind::Spec + }), + "expected a spec-reference note on the error", + ); +} + +// Verifies that `extend type` applied to a non-object type +// (here: an enum) produces an InvalidExtensionTypeKind error +// carrying both the extension kind and the actual type kind, +// and that the extension is not applied. +// +// See https://spec.graphql.org/September2025/#sec-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn object_extension_kind_mismatch_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + enum Color { RED }\n\ + extend type Color { y: String }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::InvalidExtensionTypeKind { + actual_kind: GraphQLTypeKind::Enum, + extension_kind: GraphQLTypeKind::Object, + type_name, + } if type_name == "Color", + ) + }); + assert!(has_error, "expected InvalidExtensionTypeKind for `Color`"); +} + +// Verifies that an object type extension contributing a field +// that already exists on the target type produces a +// DuplicateFieldNameDefinition error. +// +// See https://spec.graphql.org/September2025/#sec-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn object_extension_duplicate_field_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend type Query { x: Int }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::DuplicateFieldNameDefinition { + field_name, + type_name, + } if field_name == "x" && type_name == "Query", + ) + }); + assert!(has_error, "expected DuplicateFieldNameDefinition"); +} + +// Verifies that an object type extension can add an +// `implements` declaration, and that the resulting schema +// reports the object as implementing the interface. +// +// See https://spec.graphql.org/September2025/#sec-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn object_extension_merges_implements_declaration() { + let schema = SchemaBuilder::build_from_str( + "interface Node { id: ID }\n\ + type Query { id: ID }\n\ + extend type Query implements Node", + ).unwrap(); + let query = schema.object_type("Query").unwrap(); + assert!( + query.interfaces().iter().any(|l| l.value.as_str() == "Node"), + "extension `implements Node` should be merged", + ); + assert!( + schema.types_implementing("Node").iter().any(|t| { + t.name().as_str() == "Query" + }), + ); +} + +// Verifies that an object type extension re-declaring an +// interface the target already implements produces a +// DuplicateInterfaceImplementsDeclaration error. +// +// See https://spec.graphql.org/September2025/#sec-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn object_extension_duplicate_implements_fails() { + let result = SchemaBuilder::build_from_str( + "interface Node { id: ID }\n\ + type Query implements Node { id: ID }\n\ + extend type Query implements Node", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::DuplicateInterfaceImplementsDeclaration { + interface_name, + type_name, + } if interface_name == "Node" && type_name == "Query", + ) + }); + assert!( + has_error, + "expected DuplicateInterfaceImplementsDeclaration", + ); +} + +// Verifies that an object type extension contributing a +// `__`-prefixed field name is rejected with an +// InvalidDunderPrefixedFieldName error (matching the check +// performed for fields contributed by type definitions). +// +// See https://spec.graphql.org/September2025/#sec-Names.Reserved-Names +// +// Written by Claude Code, reviewed by a human. +#[test] +fn object_extension_dunder_field_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend type Query { __secret: Int }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::InvalidDunderPrefixedFieldName { + field_name, + type_name, + } if field_name == "__secret" && type_name == "Query", + ) + }); + assert!(has_error, "expected InvalidDunderPrefixedFieldName"); +} + +// --------------------------------------------------------- +// Interface type extensions +// --------------------------------------------------------- + +// Verifies that an interface type extension merges its fields +// and directive annotations into the target interface type. +// +// See https://spec.graphql.org/September2025/#sec-Interface-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn interface_extension_merges_fields_and_directives() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + interface Node { id: ID }\n\ + extend interface Node @tag { name: String }", + ).unwrap(); + let node = schema.interface_type("Node").unwrap(); + assert!(node.fields().contains_key("id")); + assert!(node.fields().contains_key("name")); + assert!(node.directives().iter().any(|d| d.name().as_str() == "tag")); +} + +// Verifies that an interface type extension appearing textually +// BEFORE the interface's definition is deferred and merged once +// the definition is loaded. +// +// See https://spec.graphql.org/September2025/#sec-Interface-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn interface_extension_before_definition_merges() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend interface Node { name: String }\n\ + interface Node { id: ID }", + ).unwrap(); + let node = schema.interface_type("Node").unwrap(); + assert!(node.fields().contains_key("id")); + assert!(node.fields().contains_key("name")); +} + +// Verifies that an interface type extension whose target type +// is never defined produces an ExtensionOfUndefinedType error +// at build() time. +// +// See https://spec.graphql.org/September2025/#sec-Interface-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn interface_extension_of_undefined_type_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend interface Missing { name: String }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::ExtensionOfUndefinedType { + type_name, + } if type_name == "Missing", + ) + }); + assert!(has_error, "expected ExtensionOfUndefinedType"); +} + +// Verifies that `extend interface` applied to an object type +// produces an InvalidExtensionTypeKind error. +// +// See https://spec.graphql.org/September2025/#sec-Interface-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn interface_extension_kind_mismatch_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend interface Query { name: String }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::InvalidExtensionTypeKind { + actual_kind: GraphQLTypeKind::Object, + extension_kind: GraphQLTypeKind::Interface, + type_name, + } if type_name == "Query", + ) + }); + assert!(has_error, "expected InvalidExtensionTypeKind"); +} + +// Verifies that an interface type extension contributing a +// field that already exists on the target interface produces a +// DuplicateFieldNameDefinition error. +// +// See https://spec.graphql.org/September2025/#sec-Interface-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn interface_extension_duplicate_field_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + interface Node { id: ID }\n\ + extend interface Node { id: ID }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::DuplicateFieldNameDefinition { + field_name, + type_name, + } if field_name == "id" && type_name == "Node", + ) + }); + assert!(has_error, "expected DuplicateFieldNameDefinition"); +} + +// --------------------------------------------------------- +// Enum type extensions +// --------------------------------------------------------- + +// Verifies that an enum type extension merges its values and +// directive annotations into the target enum type. +// +// See https://spec.graphql.org/September2025/#sec-Enum-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn enum_extension_merges_values_and_directives() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + enum Color { RED }\n\ + extend enum Color @tag { GREEN BLUE }", + ).unwrap(); + let color = schema.enum_type("Color").unwrap(); + assert!(color.values().contains_key("RED")); + assert!(color.values().contains_key("GREEN")); + assert!(color.values().contains_key("BLUE")); + assert!(color.directives().iter().any(|d| d.name().as_str() == "tag")); +} + +// Verifies that an enum type extension appearing textually +// BEFORE the enum's definition is deferred and merged once the +// definition is loaded. +// +// See https://spec.graphql.org/September2025/#sec-Enum-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn enum_extension_before_definition_merges() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend enum Color { GREEN }\n\ + enum Color { RED }", + ).unwrap(); + let color = schema.enum_type("Color").unwrap(); + assert!(color.values().contains_key("RED")); + assert!(color.values().contains_key("GREEN")); +} + +// Verifies that an enum type extension whose target type is +// never defined produces an ExtensionOfUndefinedType error at +// build() time. +// +// See https://spec.graphql.org/September2025/#sec-Enum-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn enum_extension_of_undefined_type_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend enum Missing { GREEN }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::ExtensionOfUndefinedType { + type_name, + } if type_name == "Missing", + ) + }); + assert!(has_error, "expected ExtensionOfUndefinedType"); +} + +// Verifies that `extend enum` applied to an object type +// produces an InvalidExtensionTypeKind error. +// +// See https://spec.graphql.org/September2025/#sec-Enum-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn enum_extension_kind_mismatch_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend enum Query { GREEN }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::InvalidExtensionTypeKind { + actual_kind: GraphQLTypeKind::Object, + extension_kind: GraphQLTypeKind::Enum, + type_name, + } if type_name == "Query", + ) + }); + assert!(has_error, "expected InvalidExtensionTypeKind"); +} + +// Verifies that an enum type extension contributing a value +// that already exists on the target enum produces a +// DuplicateEnumValueDefinition error. +// +// See https://spec.graphql.org/September2025/#sec-Enum-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn enum_extension_duplicate_value_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + enum Color { RED }\n\ + extend enum Color { RED }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::DuplicateEnumValueDefinition { + type_name, + value_name, + } if type_name == "Color" && value_name == "RED", + ) + }); + assert!(has_error, "expected DuplicateEnumValueDefinition"); +} + +// --------------------------------------------------------- +// Union type extensions +// --------------------------------------------------------- + +// Verifies that a union type extension merges its members and +// directive annotations into the target union type. +// +// See https://spec.graphql.org/September2025/#sec-Union-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn union_extension_merges_members_and_directives() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + type A { a: Int }\n\ + type B { b: Int }\n\ + union U = A\n\ + extend union U @tag = B", + ).unwrap(); + let u = schema.union_type("U").unwrap(); + assert!(u.members().iter().any(|m| m.value.as_str() == "A")); + assert!(u.members().iter().any(|m| m.value.as_str() == "B")); + assert!(u.directives().iter().any(|d| d.name().as_str() == "tag")); +} + +// Verifies that a union type extension appearing textually +// BEFORE the union's definition is deferred and merged once the +// definition is loaded. +// +// See https://spec.graphql.org/September2025/#sec-Union-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn union_extension_before_definition_merges() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + type A { a: Int }\n\ + type B { b: Int }\n\ + extend union U = B\n\ + union U = A", + ).unwrap(); + let u = schema.union_type("U").unwrap(); + assert!(u.members().iter().any(|m| m.value.as_str() == "A")); + assert!(u.members().iter().any(|m| m.value.as_str() == "B")); +} + +// Verifies that a union type extension whose target type is +// never defined produces an ExtensionOfUndefinedType error at +// build() time. +// +// See https://spec.graphql.org/September2025/#sec-Union-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn union_extension_of_undefined_type_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + type B { b: Int }\n\ + extend union Missing = B", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::ExtensionOfUndefinedType { + type_name, + } if type_name == "Missing", + ) + }); + assert!(has_error, "expected ExtensionOfUndefinedType"); +} + +// Verifies that `extend union` applied to an object type +// produces an InvalidExtensionTypeKind error. +// +// See https://spec.graphql.org/September2025/#sec-Union-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn union_extension_kind_mismatch_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + type B { b: Int }\n\ + extend union Query = B", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::InvalidExtensionTypeKind { + actual_kind: GraphQLTypeKind::Object, + extension_kind: GraphQLTypeKind::Union, + type_name, + } if type_name == "Query", + ) + }); + assert!(has_error, "expected InvalidExtensionTypeKind"); +} + +// Verifies that a union type extension contributing a member +// that already exists on the target union produces a +// DuplicateUnionMember error. +// +// See https://spec.graphql.org/September2025/#sec-Union-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn union_extension_duplicate_member_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + type A { a: Int }\n\ + union U = A\n\ + extend union U = A", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::DuplicateUnionMember { + member_name, + type_name, + } if member_name == "A" && type_name == "U", + ) + }); + assert!(has_error, "expected DuplicateUnionMember"); +} + +// --------------------------------------------------------- +// Input object type extensions +// --------------------------------------------------------- + +// Verifies that an input object type extension merges its input +// fields and directive annotations into the target input object +// type. +// +// See https://spec.graphql.org/September2025/#sec-Input-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn input_object_extension_merges_fields_and_directives() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + input In { a: Int }\n\ + extend input In @tag { b: String }", + ).unwrap(); + let input = schema.input_object_type("In").unwrap(); + assert!(input.fields().contains_key("a")); + assert!(input.fields().contains_key("b")); + assert!(input.directives().iter().any(|d| d.name().as_str() == "tag")); +} + +// Verifies that an input object type extension appearing +// textually BEFORE the input object's definition is deferred +// and merged once the definition is loaded. +// +// See https://spec.graphql.org/September2025/#sec-Input-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn input_object_extension_before_definition_merges() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend input In { b: String }\n\ + input In { a: Int }", + ).unwrap(); + let input = schema.input_object_type("In").unwrap(); + assert!(input.fields().contains_key("a")); + assert!(input.fields().contains_key("b")); +} + +// Verifies that an input object type extension whose target +// type is never defined produces an ExtensionOfUndefinedType +// error at build() time. +// +// See https://spec.graphql.org/September2025/#sec-Input-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn input_object_extension_of_undefined_type_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend input Missing { b: String }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::ExtensionOfUndefinedType { + type_name, + } if type_name == "Missing", + ) + }); + assert!(has_error, "expected ExtensionOfUndefinedType"); +} + +// Verifies that `extend input` applied to an object type +// produces an InvalidExtensionTypeKind error. +// +// See https://spec.graphql.org/September2025/#sec-Input-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn input_object_extension_kind_mismatch_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend input Query { b: String }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::InvalidExtensionTypeKind { + actual_kind: GraphQLTypeKind::Object, + extension_kind: GraphQLTypeKind::InputObject, + type_name, + } if type_name == "Query", + ) + }); + assert!(has_error, "expected InvalidExtensionTypeKind"); +} + +// Verifies that an input object type extension contributing an +// input field that already exists on the target type produces a +// DuplicateFieldNameDefinition error. +// +// See https://spec.graphql.org/September2025/#sec-Input-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn input_object_extension_duplicate_field_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + input In { a: Int }\n\ + extend input In { a: Int }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::DuplicateFieldNameDefinition { + field_name, + type_name, + } if field_name == "a" && type_name == "In", + ) + }); + assert!(has_error, "expected DuplicateFieldNameDefinition"); +} + +// Verifies that an input object type extension contributing a +// `__`-prefixed input field name is rejected with an +// InvalidDunderPrefixedFieldName error. +// +// See https://spec.graphql.org/September2025/#sec-Names.Reserved-Names +// +// Written by Claude Code, reviewed by a human. +#[test] +fn input_object_extension_dunder_field_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + input In { a: Int }\n\ + extend input In { __b: Int }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::InvalidDunderPrefixedFieldName { + field_name, + type_name, + } if field_name == "__b" && type_name == "In", + ) + }); + assert!(has_error, "expected InvalidDunderPrefixedFieldName"); +} + +// Verifies Input Object Extensions rule 5: "The `@oneOf` +// directive must not be provided by an Input Object type +// extension." Providing it via `extend input` is an error +// regardless of the fields' nullability, and the directive is +// NOT merged onto the type (an otherwise-valid all-nullable +// input does not silently become a oneOf input). +// +// https://spec.graphql.org/September2025/#sec-Input-Object-Extensions +// Written by Claude Code, reviewed by a human. +#[test] +fn oneof_via_extension_rejected() { + // All-nullable fields: without rule 5 this would build + // successfully as a spec-invalid oneOf input object. + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + input X { a: Int }\n\ + extend input X @oneOf", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::OneOfDirectiveProvidedByInputObjectExtension { + type_name, + } if type_name == "X", + ) + }); + assert!( + has_error, + "expected OneOfDirectiveProvidedByInputObjectExtension \ + for `X`", + ); +} + +// Verifies that when `@oneOf` arrives via an extension (rule 5 +// violation), the directive is not merged, so the oneOf +// field-nullability constraints do NOT additionally fire — the +// rule-5 error is the only oneOf-related error even when the +// original definition has a non-nullable field. +// +// https://spec.graphql.org/September2025/#sec-Input-Object-Extensions +// Written by Claude Code, reviewed by a human. +#[test] +fn oneof_via_extension_not_merged_no_constraint_errors() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + input X { a: Int! }\n\ + extend input X @oneOf", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_rule5_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::OneOfDirectiveProvidedByInputObjectExtension { .. }, + ) + }); + let has_constraint_error = errors.errors().iter().any(|e| { + if let SchemaBuildErrorKind::TypeValidation(tve) = e.kind() { + matches!( + tve.kind(), + TypeValidationErrorKind::InvalidNonNullableOneOfInputField { .. }, + ) + } else { + false + } + }); + assert!(has_rule5_error, "expected the rule-5 error"); + assert!( + !has_constraint_error, + "the unmerged @oneOf must not trigger field constraints", + ); +} + +// Verifies Input Object Extensions rule 6 territory: extending +// an input object that is ALREADY `@oneOf` with a non-nullable +// field is rejected — validation runs over the fully-merged +// type, so extension-contributed fields are subject to the +// oneOf constraints (merge-before-validate ordering). +// +// https://spec.graphql.org/September2025/#sec-Input-Object-Extensions +// Written by Claude Code, reviewed by a human. +#[test] +fn extension_field_on_oneof_input_subject_to_constraints() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + input X @oneOf { a: Int }\n\ + extend input X { b: Int! }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + if let SchemaBuildErrorKind::TypeValidation(tve) = e.kind() { + matches!( + tve.kind(), + TypeValidationErrorKind::InvalidNonNullableOneOfInputField { + field_name, + parent_type_name, + } if field_name == "b" && parent_type_name == "X", + ) + } else { + false + } + }); + assert!( + has_error, + "expected InvalidNonNullableOneOfInputField for the \ + extension-contributed field `X.b`", + ); +} + +// Verifies that `extend schema` works against a schema with no +// explicit `schema { }` definition (the schema is implicitly +// defined by its root types, matching graphql-js's permissive +// handling of §3.3.2 rule 1). +// +// https://spec.graphql.org/September2025/#sec-Schema-Extension +// Written by Claude Code, reviewed by a human. +#[test] +fn extend_schema_with_implicit_schema_definition() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + type M { y: Int }\n\ + extend schema { mutation: M }", + ).unwrap(); + assert_eq!( + schema.mutation_type().map(|t| t.name().as_str()), + Some("M"), + ); +} + +// --------------------------------------------------------- +// Scalar type extensions +// --------------------------------------------------------- + +// Verifies that a scalar type extension merges its directive +// annotations into the target scalar type (directives are the +// only contribution a scalar extension can make). +// +// See https://spec.graphql.org/September2025/#sec-Scalar-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn scalar_extension_merges_directives() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + scalar Date\n\ + extend scalar Date @specifiedBy(url: \"https://example.com\")", + ).unwrap(); + let date = schema.scalar_type("Date").unwrap(); + assert!( + date.directives().iter().any(|d| d.name().as_str() == "specifiedBy"), + "extension directive should be merged onto the scalar", + ); +} + +// Verifies that a scalar type extension appearing textually +// BEFORE the scalar's definition is deferred and merged once +// the definition is loaded. +// +// See https://spec.graphql.org/September2025/#sec-Scalar-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn scalar_extension_before_definition_merges() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend scalar Date @specifiedBy(url: \"https://example.com\")\n\ + scalar Date", + ).unwrap(); + let date = schema.scalar_type("Date").unwrap(); + assert!( + date.directives().iter().any(|d| d.name().as_str() == "specifiedBy"), + ); +} + +// Verifies that a scalar type extension whose target type is +// never defined produces an ExtensionOfUndefinedType error at +// build() time. +// +// See https://spec.graphql.org/September2025/#sec-Scalar-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn scalar_extension_of_undefined_type_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend scalar Missing @specifiedBy(url: \"https://example.com\")", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::ExtensionOfUndefinedType { + type_name, + } if type_name == "Missing", + ) + }); + assert!(has_error, "expected ExtensionOfUndefinedType"); +} + +// Verifies that `extend scalar` applied to an object type +// produces an InvalidExtensionTypeKind error. +// +// See https://spec.graphql.org/September2025/#sec-Scalar-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn scalar_extension_kind_mismatch_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend scalar Query @specifiedBy(url: \"https://example.com\")", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::InvalidExtensionTypeKind { + actual_kind: GraphQLTypeKind::Object, + extension_kind: GraphQLTypeKind::Scalar, + type_name, + } if type_name == "Query", + ) + }); + assert!(has_error, "expected InvalidExtensionTypeKind"); +} + +// --------------------------------------------------------- +// Schema extensions +// --------------------------------------------------------- + +// Verifies that `extend schema { mutation: ... }` merges a new +// root operation type binding into the schema alongside the +// bindings from the original `schema { ... }` definition. +// +// See https://spec.graphql.org/September2025/#sec-Schema-Extension +// +// Written by Claude Code, reviewed by a human. +#[test] +fn schema_extension_merges_root_operations() { + let schema = SchemaBuilder::build_from_str( + "schema { query: Q }\n\ + type Q { x: Int }\n\ + type M { doThing: Boolean }\n\ + extend schema { mutation: M }", + ).unwrap(); + assert_eq!(schema.query_type_name().as_str(), "Q"); + assert_eq!( + schema.mutation_type_name().unwrap().as_str(), + "M", + ); + assert!(schema.mutation_type().is_some()); +} + +// Verifies that `extend schema` re-binding an already-bound +// root operation kind produces a DuplicateOperationDefinition +// error (same handling as duplicates within `schema { ... }` +// definitions). +// +// See https://spec.graphql.org/September2025/#sec-Schema-Extension +// +// Written by Claude Code, reviewed by a human. +#[test] +fn schema_extension_duplicate_root_operation_fails() { + let result = SchemaBuilder::build_from_str( + "schema { query: Q }\n\ + type Q { x: Int }\n\ + type Q2 { y: Int }\n\ + extend schema { query: Q2 }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::DuplicateOperationDefinition { + operation: OperationKind::Query, + type_name, + } if type_name == "Q", + ) + }); + assert!(has_error, "expected DuplicateOperationDefinition"); +} diff --git a/libgraphql-core-v1-plan.md b/libgraphql-core-v1-plan.md index 69008aa..6791e0c 100644 --- a/libgraphql-core-v1-plan.md +++ b/libgraphql-core-v1-plan.md @@ -3835,23 +3835,73 @@ rejected, both-violations-on-one-field reports two errors, non-oneOf control, `[Int]!` rejected / `[Int!]` accepted list-nullability pair) + 2 end-to-end via `build_from_str` proving the annotation survives parse → builder → validator. **Known gap (blocked on 16.6b):** `@oneOf` applied via `extend input X @oneOf` is not -seen today because ALL type extensions are silently dropped — 16.6b must add a -regression test for oneOf-via-extension when extension merging lands. +seen today because ALL type extensions are silently dropped. *(Resolved in 16.6b — +but NOT by merging: Input Object Extensions rule 5 says "The `@oneOf` directive must +not be provided by an Input Object type extension", so 16.6b REJECTS it with +`OneOfDirectiveProvidedByInputObjectExtension` and does not merge the directive.)* **16.6b — Type extensions:** Currently `SchemaBuilder` silently drops every `TypeExtension` (`TypeExtension(_) => {}`), a data-loss regression vs v0; `ExtensionOfUndefinedType` and `InvalidExtensionTypeKind` error kinds exist but are never constructed. -- [ ] Implement `absorb_type_extension()` with v0-parity merge semantics for all 6 +- [x] Implement `absorb_type_extension()` with v0-parity merge semantics for all 6 type kinds (fields/values/members/directives merged; duplicate contributions rejected), including v0's deferred-extension behavior (extension may precede the type definition in load order) -- [ ] Wire `ExtensionOfUndefinedType` + `InvalidExtensionTypeKind` -- [ ] Tests: all 6 kinds × (merge, undefined target, kind mismatch, duplicate member/field) -- [ ] Regression test from 16.6a: `extend input X @oneOf` — the merged directive must - trigger the oneOf constraints on X's fields +- [x] Wire `ExtensionOfUndefinedType` + `InvalidExtensionTypeKind` +- [x] Tests: all 6 kinds × (merge, undefined target, kind mismatch, duplicate member/field) +- [x] oneOf × extensions (corrected from the original 16.6a framing): `extend input X + @oneOf` is REJECTED per Input Object Extensions rule 5 + (`OneOfDirectiveProvidedByInputObjectExtension`; directive not merged), while an + extension-contributed non-nullable field on an already-`@oneOf` input IS caught + by the 16.6a constraints (validation runs over the merged type) - [ ] Commit: `[libgraphql-core-v1] Implement type extension merging` +**Completion Notes (16.6b):** Implemented as private `load_type_extension()` / +`apply_type_extension()` on `SchemaBuilder` (dispatch from `load_document`), not a +public `absorb_type_extension()` — extensions only arrive via parsed documents. +Pending storage: new private `schema/pending_type_extension.rs` module with +`PendingTypeExtension` enum (6 variants; Object+Interface share +`PendingFieldedTypeExtension`) holding OWNED data (`FieldDefBuilder` / +`InputFieldDefBuilder` / `EnumValueDefBuilder` / `Located` / +`DirectiveAnnotation`) extracted eagerly from the borrowed AST via the existing +`ast_helpers` + builder `from_ast()` conversions. Builder field +`pending_extensions: IndexMap>`; target already +registered → merged immediately in place (mutating the stored `GraphQLType`); +otherwise deferred and applied in arrival order when `absorb_type()` registers the +target (v0-parity extension-before-definition, per Unresolved Question 3). Kind +mismatch → `InvalidExtensionTypeKind` (now carries `actual_kind` + `extension_kind` +`GraphQLTypeKind` fields), extension not applied. Still pending at `build()` → one +`ExtensionOfUndefinedType` per extension with the extension's span + per-kind +`ErrorNote::spec` anchor. Merges reuse the existing duplicate error kinds +(`DuplicateFieldNameDefinition`, `DuplicateEnumValueDefinition`, +`DuplicateUnionMember`, `DuplicateInterfaceImplementsDeclaration`) with +"first defined here" + spec notes; per-item error accumulation (a duplicate +contribution is skipped, the rest of the extension still merges). +`__`-prefixed field names contributed via object/interface/input extensions are +rejected (`InvalidDunderPrefixedFieldName`, matching the definition path); reserved +enum value names need no check (parser rejects them). `extend schema`: root +operations factored into shared `load_root_operations()` used by both +`load_schema_definition()` and new `load_schema_extension()` (duplicate root-op +handling identical; the duplicate error now also carries an `ErrorNote::spec`); +schema-level directive annotations are NOT stored on `Schema` at all yet (dropped +for `schema { ... }` definitions too), so extension directives are likewise not +retained — only root ops merge. oneOf × extensions per Input Object Extensions +rule 5: `@oneOf` arriving via `extend input` is rejected +(`OneOfDirectiveProvidedByInputObjectExtension`) and NOT merged — even an +all-nullable input must not become oneOf via extension; conversely, fields +contributed by extensions to an already-`@oneOf` input are validated (merge runs +before build-time validation). 39 tests added (per-kind merge / +extension-before-definition / undefined target / kind mismatch / duplicate, +implements merge+dup, dunder rejections, extend-schema merge+dup + implicit-schema +case, oneOf rule-5 rejection ×2 + rule-6 merged-field constraint) in +`schema/tests/schema_builder_extension_tests.rs`. +**Tracking (non-repeatable directives across extensions):** every extension kind +carries the spec rule "any non-repeatable directives provided must not already apply +to the previous type"; v1 has NO non-repeatable-application validation anywhere yet +(definitions included). Owned by Task 16.6f — its §5.7.3 validator must run over the +MERGED type so extension-provided duplicates are caught. + **16.6c — IsValidImplementation step 2.f (deprecated-field consistency):** - [ ] Make `DeprecationState` queryable from `FieldDefinition` - [ ] Enforce: interface field not deprecated → implementing field must not be @@ -3895,7 +3945,10 @@ directive *applications* in SDL — `type Query @bogus { ... }` builds without e - [ ] Type-system directive annotations: directive must be defined (§5.7.1), applied in a valid location (§5.7.2), non-repeatable applied at most once per location (§5.7.3) — across ALL SDL locations (types, fields, params, enum values, input - fields, unions, scalars, schema block, variable defs come later w/ operations) + fields, unions, scalars, schema block, variable defs come later w/ operations). + MUST run over the MERGED type (post-16.6b extensions) so it also enforces the + per-extension-kind rule "non-repeatable directives provided must not already + apply to the previous type" - [ ] Directive-application arguments: names correspond to the definition (§5.4.1), unique (§5.4.2), required args provided (§5.4.3), values coerce (§5.6.1 — coordinate with Task 21.1 so the coercion engine is shared) @@ -4926,11 +4979,11 @@ no "deferred past 1.0" bucket. (Checklist state below verified against code at T - [x] `@oneOf` input objects: all fields must be nullable with no default values (§3.10 *Input Objects* Type Validation; the `@oneOf` directive itself is §3.13.5) — Task 16.6a - [x] All type references resolve to defined types - [x] Directive argument types must be input types -- [ ] **(Task 16.6b)** Extension of undefined types rejected (error kind exists but is never constructed — extensions are currently silently dropped) -- [ ] **(Task 16.6b)** Extension type kind mismatch rejected (same) -- [ ] **(Task 16.6b)** Type extensions merged with v0-parity semantics (fields/values/members/directives; duplicates rejected) -- [ ] **(Task 16.6b)** `extend schema` handled (§3.3.2 schema extension) — currently - silently dropped alongside type extensions, mis-building root operation types +- [x] Extension of undefined types rejected — Task 16.6b +- [x] Extension type kind mismatch rejected — Task 16.6b +- [x] Type extensions merged with v0-parity semantics (fields/values/members/directives; duplicates rejected) — Task 16.6b +- [x] `extend schema` handled (§3.3.2 schema extension; root operations merged — + schema-level directives still unstored, see 16.6b completion notes) — Task 16.6b - [ ] **(Task 16.6c)** `@deprecated` must not be applied to required arguments or required input fields (§3.13.3) - [ ] **(Task 16.6f)** Root operation types must be distinct (§3.3.1 — same Object @@ -5053,6 +5106,8 @@ Only the following 18 functions should receive `#[inline]`. All others should be whichever preserves span fidelity for compile-error snapshots. 2. **`_macro_runtime` signature:** exact `build_from_macro_serialized()` signature is coordinated between Task 21 (runtime side) and Task 22 (emit side). -3. **Extension ordering semantics (16.6b):** confirm v1 matches v0's +3. ~~**Extension ordering semantics (16.6b):** confirm v1 matches v0's deferred-extension behavior (extension may precede the type definition in load - order) vs. requiring definition-first. Default assumption: match v0. + order) vs. requiring definition-first. Default assumption: match v0.~~ + **Resolved (16.6b):** v1 matches v0 — extensions preceding their target's + definition are deferred and merged in arrival order when the definition arrives.