From 5f90476056d9f3f790014b340cb8de1edabff457 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:58:21 +0000 Subject: [PATCH 1/2] [libgraphql-core-v1] Implement type extension merging (Task 16.6b) Previously SchemaBuilder silently dropped every TypeExtension and SchemaExtension definition -- schema data loss relative to v0, with ExtensionOfUndefinedType and InvalidExtensionTypeKind existing as dead error kinds. This implements full extension support per the September 2025 spec's Type System Extensions sections. Design: - All 6 type-extension kinds merge in place onto the registered type: Object/Interface append fields + implements entries, Enum appends values, Union appends members, InputObject appends input fields, Scalar appends directives; every kind appends extension directives. Duplicate contributions reuse the existing duplicate error kinds with "first defined here" + spec notes; per-item error accumulation merges the extension's remaining valid contributions (v1 style; v0 aborted on first duplicate). - Extension-before-definition defers via owned PendingTypeExtension entries (eagerly converted from borrowed AST via the existing builder/ast_helpers pieces), applied in arrival order when the target type is absorbed -- matching v0's deferred-extension behavior per the plan's Unresolved Question 3, now resolved. - Extensions still pending at build() each produce an ExtensionOfUndefinedType error; kind mismatches produce InvalidExtensionTypeKind, which now carries the actual vs extension GraphQLTypeKind for an informative message. - extend schema merges root operations through load_root_operations(), factored out of load_schema_definition so both paths share duplicate handling. Schema-level directives are not yet stored anywhere (true for schema definitions too) so extension directives merge root ops only -- recorded in the plan's completion notes. - Extension-contributed fields get the same __-prefix rejection as the definition path. Tests: 36 new (253 crate total) covering per-kind merge, extension- before-definition, undefined target, kind mismatch, and duplicate contributions, plus implements merging, dunder rejections, extend- schema merge + duplicate root op, spec-note assertions, and the 16.6a regression: `extend input X @oneOf` now triggers the oneOf constraints on X's fields. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TipQtpvuLuHjHmRsVEgwJw --- crates/libgraphql-core-v1/src/schema/mod.rs | 1 + .../src/schema/pending_type_extension.rs | 295 ++++++ .../src/schema/schema_build_error.rs | 7 +- .../src/schema/schema_builder.rs | 426 +++++++- .../src/schema/tests/mod.rs | 1 + .../tests/schema_builder_extension_tests.rs | 948 ++++++++++++++++++ libgraphql-core-v1-plan.md | 61 +- 7 files changed, 1716 insertions(+), 23 deletions(-) create mode 100644 crates/libgraphql-core-v1/src/schema/pending_type_extension.rs create mode 100644 crates/libgraphql-core-v1/src/schema/tests/schema_builder_extension_tests.rs 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..19295cc 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, }, diff --git a/crates/libgraphql-core-v1/src/schema/schema_builder.rs b/crates/libgraphql-core-v1/src/schema/schema_builder.rs index c9fdf79..6254f88 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,216 @@ 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, + ); + } + input_object_type.directives.extend(ext.directives); +} + +/// 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..33eadee --- /dev/null +++ b/crates/libgraphql-core-v1/src/schema/tests/schema_builder_extension_tests.rs @@ -0,0 +1,948 @@ +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"); +} + +// Regression test from Task 16.6a: `@oneOf` applied via an +// input object type extension must be merged onto the type +// BEFORE the oneOf constraints are validated, so a +// non-nullable field on the original definition is rejected. +// +// See https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation +// and https://spec.graphql.org/September2025/#sec-Input-Object-Extensions +// +// Written by Claude Code, reviewed by a human. +#[test] +fn oneof_via_extension_triggers_oneof_constraints() { + 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| { + if let SchemaBuildErrorKind::TypeValidation(tve) = e.kind() { + matches!( + tve.kind(), + TypeValidationErrorKind::InvalidNonNullableOneOfInputField { + field_name, + parent_type_name, + } if field_name == "a" && parent_type_name == "X", + ) + } else { + false + } + }); + assert!( + has_error, + "expected InvalidNonNullableOneOfInputField for `X.a` \ + (the extension's @oneOf must be merged before validation)", + ); +} + +// --------------------------------------------------------- +// 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..115a45d 100644 --- a/libgraphql-core-v1-plan.md +++ b/libgraphql-core-v1-plan.md @@ -3836,22 +3836,57 @@ rejected, both-violations-on-one-field reports two errors, non-oneOf control, `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. +regression test for oneOf-via-extension when extension merging lands. *(Resolved: +16.6b merged extensions and added `oneof_via_extension_triggers_oneof_constraints`.)* **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 +- [x] Wire `ExtensionOfUndefinedType` + `InvalidExtensionTypeKind` +- [x] Tests: all 6 kinds × (merge, undefined target, kind mismatch, duplicate member/field) +- [x] Regression test from 16.6a: `extend input X @oneOf` — the merged directive must trigger the oneOf constraints on X's fields - [ ] 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. 36 tests added (per-kind merge / +extension-before-definition / undefined target / kind mismatch / duplicate, +implements merge+dup, dunder rejections, extend-schema merge+dup, oneOf-via-extension +regression) in `schema/tests/schema_builder_extension_tests.rs`. + **16.6c — IsValidImplementation step 2.f (deprecated-field consistency):** - [ ] Make `DeprecationState` queryable from `FieldDefinition` - [ ] Enforce: interface field not deprecated → implementing field must not be @@ -4926,11 +4961,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 +5088,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. From 4f2688cf05d10aaaa69f9f0224d079b8286f5fc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 00:05:09 +0000 Subject: [PATCH 2/2] [libgraphql-core-v1] Reject @oneOf provided via input object extension Addresses the blocker from the pre-PR Opus review: September 2025 Input Object Extensions rule 5 states "The @oneOf directive must not be provided by an Input Object type extension." The initial 16.6b implementation (following 16.6a's completion-note framing) instead merged the directive and let the field-nullability constraints fire -- which produced the right error for the wrong reason on non-nullable fields, and silently ACCEPTED an all-nullable input becoming oneOf via extension (a spec-invalid schema). Now `extend input X @oneOf` produces the new OneOfDirectiveProvidedByInputObjectExtension error (span on the directive annotation, spec note attached) and the directive is not merged, so the constraints correctly do not fire from an unmerged directive. The mirror direction still works and is now locked in by a test: a non-nullable field CONTRIBUTED BY an extension to an already-@oneOf input is rejected by the 16.6a validator, because validation runs over the fully-merged type. Also adds the implicit-schema-definition extend-schema test (extend schema with no explicit `schema {}` block, matching graphql-js's permissive reading of the Schema Extension rules) and records a tracking note: non-repeatable-directive uniqueness across extensions belongs to Task 16.6f's directive-application validator, which must run over merged types. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TipQtpvuLuHjHmRsVEgwJw --- .../src/schema/schema_build_error.rs | 8 ++ .../src/schema/schema_builder.rs | 18 ++- .../tests/schema_builder_extension_tests.rs | 116 ++++++++++++++++-- libgraphql-core-v1-plan.md | 36 ++++-- 4 files changed, 157 insertions(+), 21 deletions(-) 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 19295cc..5d454a4 100644 --- a/crates/libgraphql-core-v1/src/schema/schema_build_error.rs +++ b/crates/libgraphql-core-v1/src/schema/schema_build_error.rs @@ -208,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 6254f88..f4b23af 100644 --- a/crates/libgraphql-core-v1/src/schema/schema_builder.rs +++ b/crates/libgraphql-core-v1/src/schema/schema_builder.rs @@ -1232,7 +1232,23 @@ fn merge_input_object_type_extension( input_field.name.clone(), input_field, ); } - input_object_type.directives.extend(ext.directives); + 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`] 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 index 33eadee..81d05a4 100644 --- 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 @@ -754,17 +754,51 @@ fn input_object_extension_dunder_field_fails() { assert!(has_error, "expected InvalidDunderPrefixedFieldName"); } -// Regression test from Task 16.6a: `@oneOf` applied via an -// input object type extension must be merged onto the type -// BEFORE the oneOf constraints are validated, so a -// non-nullable field on the original definition is rejected. -// -// See https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation -// and https://spec.graphql.org/September2025/#sec-Input-Object-Extensions +// 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_triggers_oneof_constraints() { +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\ @@ -772,6 +806,46 @@ fn oneof_via_extension_triggers_oneof_constraints() { ); 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!( @@ -779,7 +853,7 @@ fn oneof_via_extension_triggers_oneof_constraints() { TypeValidationErrorKind::InvalidNonNullableOneOfInputField { field_name, parent_type_name, - } if field_name == "a" && parent_type_name == "X", + } if field_name == "b" && parent_type_name == "X", ) } else { false @@ -787,8 +861,28 @@ fn oneof_via_extension_triggers_oneof_constraints() { }); assert!( has_error, - "expected InvalidNonNullableOneOfInputField for `X.a` \ - (the extension's @oneOf must be merged before validation)", + "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"), ); } diff --git a/libgraphql-core-v1-plan.md b/libgraphql-core-v1-plan.md index 115a45d..6791e0c 100644 --- a/libgraphql-core-v1-plan.md +++ b/libgraphql-core-v1-plan.md @@ -3835,9 +3835,10 @@ 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. *(Resolved: -16.6b merged extensions and added `oneof_via_extension_triggers_oneof_constraints`.)* +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(_) => @@ -3849,8 +3850,11 @@ Currently `SchemaBuilder` silently drops every `TypeExtension` (`TypeExtension(_ type definition in load order) - [x] Wire `ExtensionOfUndefinedType` + `InvalidExtensionTypeKind` - [x] Tests: all 6 kinds × (merge, undefined target, kind mismatch, duplicate member/field) -- [x] Regression test from 16.6a: `extend input X @oneOf` — the merged directive must - trigger the oneOf constraints on X's fields +- [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()` / @@ -3882,10 +3886,21 @@ operations factored into shared `load_root_operations()` used by both 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. 36 tests added (per-kind merge / +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, oneOf-via-extension -regression) in `schema/tests/schema_builder_extension_tests.rs`. +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` @@ -3930,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)