diff --git a/crates/libgraphql-core-v1/src/schema/schema_builder.rs b/crates/libgraphql-core-v1/src/schema/schema_builder.rs index f4b23af..d7cbe35 100644 --- a/crates/libgraphql-core-v1/src/schema/schema_builder.rs +++ b/crates/libgraphql-core-v1/src/schema/schema_builder.rs @@ -29,6 +29,7 @@ use crate::type_builders::IntoGraphQLType; use crate::type_builders::ObjectTypeBuilder; use crate::type_builders::ScalarTypeBuilder; use crate::type_builders::UnionTypeBuilder; +use crate::types::DeprecationState; use crate::types::DirectiveDefinition; use crate::types::DirectiveDefinitionKind; use crate::types::DirectiveLocationKind; @@ -221,7 +222,7 @@ impl SchemaBuilder { FieldName::new("reason"), ParameterDefinition { default_value: Some(Value::String( - "No longer supported".to_string(), + DeprecationState::DEFAULT_REASON.to_string(), )), description: None, directives: vec![], 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 81d05a4..e487f11 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 @@ -1040,3 +1040,135 @@ fn schema_extension_duplicate_root_operation_fails() { }); assert!(has_error, "expected DuplicateOperationDefinition"); } + +// --------------------------------------------------------- +// @deprecated constraints on extension-contributed items +// --------------------------------------------------------- + +// Verifies that a `@deprecated` required (non-null, no default +// value) argument contributed by an object type EXTENSION is +// rejected. Regression coverage for the 16.6b extension-merge +// path: validation runs over the merged type, so +// extension-contributed parameters must be subject to the same +// `@deprecated` constraints as parameters defined directly on +// the type. +// +// See https://spec.graphql.org/September2025/#sec--deprecated +// +// Written by Claude Code, reviewed by a human. +#[test] +fn extension_contributed_deprecated_required_argument_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + extend type Query {\n\ + search(oldArg: String! @deprecated): String\n\ + }", + ); + 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::DeprecatedRequiredParameter { + field_name, + parameter_name, + type_name, + } if field_name == "search" + && parameter_name == "oldArg" + && type_name == "Query", + ) + } else { + false + } + }); + assert!( + has_error, + "expected DeprecatedRequiredParameter, got: {errors:?}", + ); +} + +// Verifies that a `@deprecated` required (non-null, no default +// value) input field contributed by an input object type +// EXTENSION is rejected. Regression coverage for the 16.6b +// extension-merge path: validation runs over the merged type, +// so extension-contributed input fields must be subject to the +// same `@deprecated` constraints as input fields defined +// directly on the type. +// +// See https://spec.graphql.org/September2025/#sec--deprecated +// +// Written by Claude Code, reviewed by a human. +#[test] +fn extension_contributed_deprecated_required_input_field_fails() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + input CreateUserInput { name: String }\n\ + extend input CreateUserInput {\n\ + oldField: String! @deprecated\n\ + }", + ); + 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::DeprecatedRequiredInputField { + field_name, + parent_type_name, + } if field_name == "oldField" + && parent_type_name == "CreateUserInput", + ) + } else { + false + } + }); + assert!( + has_error, + "expected DeprecatedRequiredInputField, got: {errors:?}", + ); +} + +// Verifies that a `@deprecated` field contributed by an object +// type EXTENSION still participates in IsValidImplementation +// step 2.6 against the merged type's interfaces: the extension +// adds a `@deprecated` field implementing a non-deprecated +// interface field, which must be rejected. +// +// See https://spec.graphql.org/September2025/#IsValidImplementation() +// +// Written by Claude Code, reviewed by a human. +#[test] +fn extension_contributed_deprecated_implementing_field_fails() { + let result = SchemaBuilder::build_from_str( + "interface Node { name: String }\n\ + type Query implements Node { name: String }\n\ + type User { id: ID }\n\ + extend type User { name: String @deprecated }\n\ + extend type User implements Node", + ); + 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::DeprecatedFieldImplementingNonDeprecatedInterfaceField { + field_name, + interface_name, + type_name, + } if field_name == "name" + && interface_name == "Node" + && type_name == "User", + ) + } else { + false + } + }); + assert!( + has_error, + "expected DeprecatedFieldImplementingNonDeprecatedInterfaceField, \ + got: {errors:?}", + ); +} diff --git a/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs b/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs index 931c869..ce8aed8 100644 --- a/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs +++ b/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs @@ -6,6 +6,7 @@ use crate::schema::SchemaBuilder; use crate::schema::TypeValidationErrorKind; use crate::span::Span; use crate::type_builders::ObjectTypeBuilder; +use crate::types::DeprecationState; use crate::types::GraphQLTypeKind; use crate::types::ScalarKind; @@ -946,3 +947,288 @@ fn build_valid_oneof_input_object_succeeds() { ).unwrap(); assert!(schema.input_object_type("UserLookup").is_some()); } + +// Verifies end-to-end (parse -> build -> validate) that a field +// marked `@deprecated` on an implementing type is rejected when +// the corresponding interface field is NOT deprecated +// (IsValidImplementation step 2.6: "If {field} is deprecated +// then {implementedField} must also be deprecated"). +// +// See https://spec.graphql.org/September2025/#IsValidImplementation() +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_deprecated_field_implementing_non_deprecated_field_rejected() { + let result = SchemaBuilder::build_from_str( + "interface Node { name: String }\n\ + type Query implements Node {\n\ + name: String @deprecated(reason: \"Use `fullName`.\")\n\ + }", + ); + 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::DeprecatedFieldImplementingNonDeprecatedInterfaceField { + field_name, + interface_name, + type_name, + } if field_name == "name" + && interface_name == "Node" + && type_name == "Query", + ) + } else { + false + } + }); + assert!( + has_error, + "expected DeprecatedFieldImplementingNonDeprecatedInterfaceField, \ + got: {errors:?}", + ); +} + +// Verifies end-to-end that a `@deprecated` implementing field is +// accepted when the corresponding interface field is also +// `@deprecated`, and that deprecation_state() surfaces the +// extracted reason (explicit reason on the object field; default +// reason on the interface field). +// +// See https://spec.graphql.org/September2025/#IsValidImplementation() +// and https://spec.graphql.org/September2025/#sec--deprecated +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_deprecated_field_implementing_deprecated_field_succeeds() { + let schema = SchemaBuilder::build_from_str( + "interface Node { name: String @deprecated }\n\ + type Query implements Node {\n\ + name: String @deprecated(reason: \"Use `fullName`.\")\n\ + }", + ).unwrap(); + + let iface = schema.interface_type("Node").unwrap(); + let iface_field = iface.fields().get("name").unwrap(); + assert_eq!( + iface_field.deprecation_state(), + DeprecationState::Deprecated { + reason: Some(DeprecationState::DEFAULT_REASON), + }, + ); + + let obj = schema.object_type("Query").unwrap(); + let obj_field = obj.fields().get("name").unwrap(); + assert_eq!( + obj_field.deprecation_state(), + DeprecationState::Deprecated { + reason: Some("Use `fullName`."), + }, + ); +} + +// Verifies end-to-end that a NON-deprecated implementing field +// is accepted when the corresponding interface field IS +// deprecated -- step 2.6 only requires the interface field to be +// deprecated when the implementing field is; the converse is not +// constrained. +// +// See https://spec.graphql.org/September2025/#IsValidImplementation() +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_non_deprecated_field_implementing_deprecated_field_succeeds() { + let schema = SchemaBuilder::build_from_str( + "interface Node { name: String @deprecated }\n\ + type Query implements Node { name: String }", + ).unwrap(); + let obj = schema.object_type("Query").unwrap(); + let obj_field = obj.fields().get("name").unwrap(); + assert_eq!( + obj_field.deprecation_state(), + DeprecationState::Active, + ); +} + +// Verifies end-to-end that `@deprecated` on a required +// (non-null, no default value) field argument is rejected. +// +// See https://spec.graphql.org/September2025/#sec--deprecated +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_deprecated_required_argument_rejected() { + let result = SchemaBuilder::build_from_str( + "type Query {\n\ + search(oldArg: String! @deprecated): String\n\ + }", + ); + 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::DeprecatedRequiredParameter { + field_name, + parameter_name, + type_name, + } if field_name == "search" + && parameter_name == "oldArg" + && type_name == "Query", + ) + } else { + false + } + }); + assert!( + has_error, + "expected DeprecatedRequiredParameter, got: {errors:?}", + ); +} + +// Verifies end-to-end that `@deprecated` on OPTIONAL field +// arguments (a nullable argument, and a non-null argument with a +// default value) is accepted, and that deprecation_state() +// surfaces the extracted reasons. +// +// See https://spec.graphql.org/September2025/#sec--deprecated +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_deprecated_optional_arguments_succeed() { + let schema = SchemaBuilder::build_from_str( + "type Query {\n\ + search(\n\ + nullableArg: String @deprecated(reason: \"Use `newArg`.\")\n\ + defaultedArg: Int! = 42 @deprecated\n\ + ): String\n\ + }", + ).unwrap(); + let obj = schema.object_type("Query").unwrap(); + let field = obj.fields().get("search").unwrap(); + + let nullable_arg = field.parameters().get("nullableArg").unwrap(); + assert_eq!( + nullable_arg.deprecation_state(), + DeprecationState::Deprecated { + reason: Some("Use `newArg`."), + }, + ); + + let defaulted_arg = field.parameters().get("defaultedArg").unwrap(); + assert_eq!( + defaulted_arg.deprecation_state(), + DeprecationState::Deprecated { + reason: Some(DeprecationState::DEFAULT_REASON), + }, + ); +} + +// Verifies end-to-end that `@deprecated` on a required +// (non-null, no default value) input field is rejected. +// +// See https://spec.graphql.org/September2025/#sec--deprecated +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_deprecated_required_input_field_rejected() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + input CreateUserInput {\n\ + oldField: String! @deprecated\n\ + }", + ); + 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::DeprecatedRequiredInputField { + field_name, + parent_type_name, + } if field_name == "oldField" + && parent_type_name == "CreateUserInput", + ) + } else { + false + } + }); + assert!( + has_error, + "expected DeprecatedRequiredInputField, got: {errors:?}", + ); +} + +// Verifies end-to-end that `@deprecated` on OPTIONAL input +// fields (nullable, or non-null with a default value) is +// accepted, and that deprecation_state() surfaces the extracted +// reason. +// +// See https://spec.graphql.org/September2025/#sec--deprecated +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_deprecated_optional_input_fields_succeed() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + input CreateUserInput {\n\ + nullableField: String @deprecated(reason: \"Old.\")\n\ + defaultedField: String! = \"anon\" @deprecated\n\ + }", + ).unwrap(); + let input_obj = schema.input_object_type("CreateUserInput").unwrap(); + + let nullable_field = input_obj.fields().get("nullableField").unwrap(); + assert_eq!( + nullable_field.deprecation_state(), + DeprecationState::Deprecated { reason: Some("Old.") }, + ); + + let defaulted_field = input_obj.fields().get("defaultedField").unwrap(); + assert_eq!( + defaulted_field.deprecation_state(), + DeprecationState::Deprecated { + reason: Some(DeprecationState::DEFAULT_REASON), + }, + ); +} + +// Verifies end-to-end that `@deprecated` on a required +// (non-null, no default value) parameter of a custom directive +// definition is rejected. +// +// See https://spec.graphql.org/September2025/#sec--deprecated +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_deprecated_required_directive_argument_rejected() { + let result = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + directive @myDirective(\n\ + oldArg: String! @deprecated\n\ + ) on FIELD_DEFINITION", + ); + 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::DeprecatedRequiredDirectiveParameter { + directive_name, + parameter_name, + } if directive_name == "myDirective" + && parameter_name == "oldArg", + ) + } else { + false + } + }); + assert!( + has_error, + "expected DeprecatedRequiredDirectiveParameter, got: {errors:?}", + ); +} diff --git a/crates/libgraphql-core-v1/src/schema/type_validation_error.rs b/crates/libgraphql-core-v1/src/schema/type_validation_error.rs index c1c0290..f08851c 100644 --- a/crates/libgraphql-core-v1/src/schema/type_validation_error.rs +++ b/crates/libgraphql-core-v1/src/schema/type_validation_error.rs @@ -65,6 +65,46 @@ pub enum TypeValidationErrorKind { circular_field_path: Vec, }, + #[error( + "`{type_name}.{field_name}` is marked `@deprecated`, but \ + the interface field `{interface_name}.{field_name}` it \ + implements is not deprecated" + )] + DeprecatedFieldImplementingNonDeprecatedInterfaceField { + field_name: String, + interface_name: String, + type_name: String, + }, + + #[error( + "required parameter `{parameter_name}` on directive \ + `@{directive_name}` must not be marked `@deprecated`" + )] + DeprecatedRequiredDirectiveParameter { + directive_name: String, + parameter_name: String, + }, + + #[error( + "required input field `{parent_type_name}.{field_name}` \ + must not be marked `@deprecated`" + )] + DeprecatedRequiredInputField { + field_name: String, + parent_type_name: String, + }, + + #[error( + "required parameter \ + `{type_name}.{field_name}({parameter_name})` must not be \ + marked `@deprecated`" + )] + DeprecatedRequiredParameter { + field_name: String, + parameter_name: String, + type_name: String, + }, + #[error( "`{type_name}` declares it implements \ `{non_interface_type_name}`, but \ diff --git a/crates/libgraphql-core-v1/src/types/deprecation_state.rs b/crates/libgraphql-core-v1/src/types/deprecation_state.rs index 153ed77..f272c95 100644 --- a/crates/libgraphql-core-v1/src/types/deprecation_state.rs +++ b/crates/libgraphql-core-v1/src/types/deprecation_state.rs @@ -1,3 +1,6 @@ +use crate::directive_annotation::DirectiveAnnotation; +use crate::value::Value; + /// Deprecation status of a type, field, enum value, or argument, /// derived from the presence of a /// [`@deprecated`](https://spec.graphql.org/September2025/#sec--deprecated) @@ -8,9 +11,63 @@ pub enum DeprecationState<'a> { Deprecated { reason: Option<&'a str> }, } -impl DeprecationState<'_> { +impl<'a> DeprecationState<'a> { + /// The default value of the `@deprecated` directive's `reason` + /// argument, as defined by the built-in directive definition + /// `directive @deprecated(reason: String! = "No longer supported")`. + /// + /// See + /// [@deprecated](https://spec.graphql.org/September2025/#sec--deprecated). + pub const DEFAULT_REASON: &'static str = "No longer supported"; + + /// Derives a [`DeprecationState`] from a list of directive + /// annotations. + /// + /// If a `@deprecated` annotation is present, the returned state + /// is [`DeprecationState::Deprecated`] with its `reason` taken + /// from the annotation's `reason` argument. When the `reason` + /// argument is omitted, the built-in definition's default value + /// ([`DeprecationState::DEFAULT_REASON`]) applies. A `reason` + /// that is explicitly `null` or a non-string value (both invalid + /// per the `String!` parameter type, but tolerated here) yields + /// a `reason` of `None`. + /// + /// See + /// [@deprecated](https://spec.graphql.org/September2025/#sec--deprecated). + pub(crate) fn from_directives(directives: &'a [DirectiveAnnotation]) -> Self { + let Some(annot) = find_deprecated_annotation(directives) else { + return Self::Active; + }; + let reason = match annot.arguments().get("reason") { + None => Some(Self::DEFAULT_REASON), + Some(Value::String(reason)) => Some(reason.as_str()), + Some(_) => None, + }; + Self::Deprecated { reason } + } + #[inline] pub fn is_deprecated(&self) -> bool { matches!(self, Self::Deprecated { .. }) } } + +/// Finds the first `@deprecated` annotation within `directives`, +/// if any. +/// +/// Useful for pointing error spans at the `@deprecated` annotation +/// itself rather than at the item it is applied to. +/// +/// If multiple `@deprecated` annotations are present the FIRST +/// wins. `@deprecated` is non-repeatable per spec, so duplicates +/// are themselves invalid — rejecting them is owned by Task +/// 16.6f's §5.7.3 non-repeatable-directive validation; until that +/// lands, first-wins is the documented tie-break here. +/// +/// See +/// [@deprecated](https://spec.graphql.org/September2025/#sec--deprecated). +pub(crate) fn find_deprecated_annotation( + directives: &[DirectiveAnnotation], +) -> Option<&DirectiveAnnotation> { + directives.iter().find(|annot| annot.name().as_str() == "deprecated") +} diff --git a/crates/libgraphql-core-v1/src/types/field_definition.rs b/crates/libgraphql-core-v1/src/types/field_definition.rs index 8c07c83..305f294 100644 --- a/crates/libgraphql-core-v1/src/types/field_definition.rs +++ b/crates/libgraphql-core-v1/src/types/field_definition.rs @@ -2,6 +2,7 @@ use crate::directive_annotation::DirectiveAnnotation; use crate::names::FieldName; use crate::names::TypeName; use crate::span::Span; +use crate::types::deprecation_state::DeprecationState; use crate::types::parameter_definition::ParameterDefinition; use crate::types::type_annotation::TypeAnnotation; use indexmap::IndexMap; @@ -29,6 +30,13 @@ pub struct FieldDefinition { } impl FieldDefinition { + /// The deprecation status of this field, derived from the + /// presence of a + /// [`@deprecated`](https://spec.graphql.org/September2025/#sec--deprecated) + /// directive annotation on the field definition. + pub fn deprecation_state(&self) -> DeprecationState<'_> { + DeprecationState::from_directives(&self.directives) + } pub fn description(&self) -> Option<&str> { self.description.as_deref() } diff --git a/crates/libgraphql-core-v1/src/types/input_field.rs b/crates/libgraphql-core-v1/src/types/input_field.rs index c2e34d1..3f72643 100644 --- a/crates/libgraphql-core-v1/src/types/input_field.rs +++ b/crates/libgraphql-core-v1/src/types/input_field.rs @@ -2,6 +2,7 @@ use crate::directive_annotation::DirectiveAnnotation; use crate::names::FieldName; use crate::names::TypeName; use crate::span::Span; +use crate::types::deprecation_state::DeprecationState; use crate::types::type_annotation::TypeAnnotation; use crate::value::Value; @@ -30,6 +31,13 @@ impl InputField { pub fn default_value(&self) -> Option<&Value> { self.default_value.as_ref() } + /// The deprecation status of this input field, derived from + /// the presence of a + /// [`@deprecated`](https://spec.graphql.org/September2025/#sec--deprecated) + /// directive annotation on the input field definition. + pub fn deprecation_state(&self) -> DeprecationState<'_> { + DeprecationState::from_directives(&self.directives) + } pub fn description(&self) -> Option<&str> { self.description.as_deref() } diff --git a/crates/libgraphql-core-v1/src/types/mod.rs b/crates/libgraphql-core-v1/src/types/mod.rs index 4cf270c..41c5165 100644 --- a/crates/libgraphql-core-v1/src/types/mod.rs +++ b/crates/libgraphql-core-v1/src/types/mod.rs @@ -21,6 +21,7 @@ mod scalar_type; mod type_annotation; mod union_type; +pub(crate) use crate::types::deprecation_state::find_deprecated_annotation; pub(crate) use crate::types::fielded_type_data::FieldedTypeData; pub use crate::types::deprecation_state::DeprecationState; diff --git a/crates/libgraphql-core-v1/src/types/parameter_definition.rs b/crates/libgraphql-core-v1/src/types/parameter_definition.rs index a17c4c3..110821d 100644 --- a/crates/libgraphql-core-v1/src/types/parameter_definition.rs +++ b/crates/libgraphql-core-v1/src/types/parameter_definition.rs @@ -1,6 +1,7 @@ use crate::directive_annotation::DirectiveAnnotation; use crate::names::FieldName; use crate::span::Span; +use crate::types::deprecation_state::DeprecationState; use crate::types::type_annotation::TypeAnnotation; use crate::value::Value; @@ -28,6 +29,13 @@ impl ParameterDefinition { pub fn default_value(&self) -> Option<&Value> { self.default_value.as_ref() } + /// The deprecation status of this parameter, derived from the + /// presence of a + /// [`@deprecated`](https://spec.graphql.org/September2025/#sec--deprecated) + /// directive annotation on the parameter definition. + pub fn deprecation_state(&self) -> DeprecationState<'_> { + DeprecationState::from_directives(&self.directives) + } pub fn description(&self) -> Option<&str> { self.description.as_deref() } diff --git a/crates/libgraphql-core-v1/src/types/tests/deprecation_state_tests.rs b/crates/libgraphql-core-v1/src/types/tests/deprecation_state_tests.rs index 9ba7a1c..1d825c8 100644 --- a/crates/libgraphql-core-v1/src/types/tests/deprecation_state_tests.rs +++ b/crates/libgraphql-core-v1/src/types/tests/deprecation_state_tests.rs @@ -1,4 +1,71 @@ +use crate::directive_annotation::DirectiveAnnotation; +use crate::names::DirectiveName; +use crate::names::FieldName; +use crate::names::TypeName; +use crate::span::Span; use crate::types::DeprecationState; +use crate::types::FieldDefinition; +use crate::types::InputField; +use crate::types::ParameterDefinition; +use crate::types::TypeAnnotation; +use crate::value::Value; +use indexmap::IndexMap; + +fn deprecated_annotation(reason: Option) -> DirectiveAnnotation { + let mut arguments = IndexMap::new(); + if let Some(reason) = reason { + arguments.insert(FieldName::new("reason"), reason); + } + DirectiveAnnotation { + arguments, + name: DirectiveName::new("deprecated"), + span: Span::dummy(), + } +} + +fn make_field(directives: Vec) -> FieldDefinition { + FieldDefinition { + description: None, + directives, + name: FieldName::new("oldField"), + parameters: IndexMap::new(), + parent_type_name: TypeName::new("SomeType"), + span: Span::dummy(), + type_annotation: TypeAnnotation::named( + "String", + /* nullable = */ true, + ), + } +} + +fn make_param(directives: Vec) -> ParameterDefinition { + ParameterDefinition { + default_value: None, + description: None, + directives, + name: FieldName::new("oldArg"), + span: Span::dummy(), + type_annotation: TypeAnnotation::named( + "String", + /* nullable = */ true, + ), + } +} + +fn make_input_field(directives: Vec) -> InputField { + InputField { + default_value: None, + description: None, + directives, + name: FieldName::new("oldInputField"), + parent_type_name: TypeName::new("SomeInput"), + span: Span::dummy(), + type_annotation: TypeAnnotation::named( + "String", + /* nullable = */ true, + ), + } +} // Verifies Active state is not deprecated. // https://spec.graphql.org/September2025/#sec--deprecated @@ -16,3 +83,122 @@ fn deprecated_without_reason() { let state = DeprecationState::Deprecated { reason: None }; assert!(state.is_deprecated()); } + +// Verifies that FieldDefinition::deprecation_state() returns +// Active when no `@deprecated` annotation is present, including +// when other (non-deprecation) directives are applied. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn field_definition_without_deprecated_is_active() { + let field = make_field(vec![]); + assert_eq!(field.deprecation_state(), DeprecationState::Active); + + let other_directive = DirectiveAnnotation { + arguments: IndexMap::new(), + name: DirectiveName::new("someOtherDirective"), + span: Span::dummy(), + }; + let field = make_field(vec![other_directive]); + assert_eq!(field.deprecation_state(), DeprecationState::Active); +} + +// Verifies that FieldDefinition::deprecation_state() extracts an +// explicitly-provided `reason` argument from the `@deprecated` +// annotation. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn field_definition_deprecated_with_explicit_reason() { + let field = make_field(vec![deprecated_annotation(Some( + Value::String("Use `newField`.".to_string()), + ))]); + assert_eq!( + field.deprecation_state(), + DeprecationState::Deprecated { + reason: Some("Use `newField`."), + }, + ); +} + +// Verifies that FieldDefinition::deprecation_state() applies the +// spec-defined default reason ("No longer supported") when the +// `@deprecated` annotation omits its `reason` argument, per the +// built-in definition +// `directive @deprecated(reason: String! = "No longer supported")`. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn field_definition_deprecated_with_default_reason() { + let field = make_field(vec![deprecated_annotation(None)]); + assert_eq!( + field.deprecation_state(), + DeprecationState::Deprecated { + reason: Some(DeprecationState::DEFAULT_REASON), + }, + ); + assert_eq!( + DeprecationState::DEFAULT_REASON, + "No longer supported", + ); +} + +// Verifies that FieldDefinition::deprecation_state() yields a +// reason of None when the `reason` argument is explicitly `null`. +// (An explicit `null` is invalid per the `String!` parameter type, +// but the accessor tolerates it rather than panicking; argument +// value coercion is validated elsewhere.) +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn field_definition_deprecated_with_null_reason() { + let field = make_field(vec![deprecated_annotation(Some(Value::Null))]); + assert_eq!( + field.deprecation_state(), + DeprecationState::Deprecated { reason: None }, + ); +} + +// Verifies that ParameterDefinition::deprecation_state() reports +// Active with no annotation and Deprecated (with extracted +// reason) when `@deprecated` is applied. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn parameter_definition_deprecation_state() { + let param = make_param(vec![]); + assert_eq!(param.deprecation_state(), DeprecationState::Active); + + let param = make_param(vec![deprecated_annotation(Some( + Value::String("Use `newArg`.".to_string()), + ))]); + assert_eq!( + param.deprecation_state(), + DeprecationState::Deprecated { + reason: Some("Use `newArg`."), + }, + ); +} + +// Verifies that InputField::deprecation_state() reports Active +// with no annotation, and Deprecated with the spec-defined +// default reason when `@deprecated` is applied without a +// `reason` argument. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn input_field_deprecation_state() { + let input_field = make_input_field(vec![]); + assert_eq!( + input_field.deprecation_state(), + DeprecationState::Active, + ); + + let input_field = make_input_field(vec![deprecated_annotation(None)]); + assert_eq!( + input_field.deprecation_state(), + DeprecationState::Deprecated { + reason: Some(DeprecationState::DEFAULT_REASON), + }, + ); +} diff --git a/crates/libgraphql-core-v1/src/validators/directive_definition_validator.rs b/crates/libgraphql-core-v1/src/validators/directive_definition_validator.rs index e0c6277..c2a1936 100644 --- a/crates/libgraphql-core-v1/src/validators/directive_definition_validator.rs +++ b/crates/libgraphql-core-v1/src/validators/directive_definition_validator.rs @@ -4,6 +4,7 @@ use crate::names::TypeName; use crate::schema::TypeValidationError; use crate::schema::TypeValidationErrorKind; use crate::types::DirectiveDefinition; +use crate::types::find_deprecated_annotation; use crate::types::GraphQLType; use crate::validators::edit_distance::find_similar_names; use indexmap::IndexMap; @@ -11,10 +12,13 @@ use indexmap::IndexMap; /// Validates custom directive definitions. /// /// Checks that every parameter on a custom (non-builtin) directive -/// definition references a valid input type. Built-in directives -/// are skipped since they are validated by the spec itself. +/// definition references a valid input type and that `@deprecated` +/// is not applied to any required (non-null without a default +/// value) parameter. Built-in directives are skipped since they +/// are validated by the spec itself. /// -/// See [Type System Directives](https://spec.graphql.org/September2025/#sec-Type-System.Directives). +/// See [Type System Directives](https://spec.graphql.org/September2025/#sec-Type-System.Directives) +/// and [@deprecated](https://spec.graphql.org/September2025/#sec--deprecated). pub(crate) fn validate_directive_definitions( directive_defs: &IndexMap, types_map: &IndexMap, @@ -29,6 +33,42 @@ pub(crate) fn validate_directive_definitions( } for (param_name, param) in directive_def.parameters() { + // `@deprecated` must not appear on a required + // (non-null type without a default value) parameter. + // To deprecate a required parameter, it must first be + // made optional. + // + // https://spec.graphql.org/September2025/#sec--deprecated + let is_required = !param.type_annotation().nullable() + && param.default_value().is_none(); + if is_required && param.deprecation_state().is_deprecated() { + // Point the error at the parameter's `@deprecated` + // annotation when possible; otherwise fall back to + // the parameter itself. + let error_span = + find_deprecated_annotation(param.directives()) + .map(|annot| annot.span()) + .unwrap_or_else(|| param.span()); + errors.push(TypeValidationError::new( + TypeValidationErrorKind::DeprecatedRequiredDirectiveParameter { + directive_name: + directive_def.name().to_string(), + parameter_name: param_name.to_string(), + }, + error_span, + vec![ + ErrorNote::help( + "to deprecate a required parameter, first \ + make it optional by changing its type to \ + nullable or adding a default value", + ), + ErrorNote::spec( + "https://spec.graphql.org/September2025/#sec--deprecated", + ), + ], + )); + } + let innermost_type_name = param.type_annotation().innermost_type_name(); let innermost_type = diff --git a/crates/libgraphql-core-v1/src/validators/input_object_type_validator.rs b/crates/libgraphql-core-v1/src/validators/input_object_type_validator.rs index e26e4aa..7c90c96 100644 --- a/crates/libgraphql-core-v1/src/validators/input_object_type_validator.rs +++ b/crates/libgraphql-core-v1/src/validators/input_object_type_validator.rs @@ -3,6 +3,7 @@ use crate::names::FieldName; use crate::names::TypeName; use crate::schema::TypeValidationError; use crate::schema::TypeValidationErrorKind; +use crate::types::find_deprecated_annotation; use crate::types::GraphQLType; use crate::types::InputField; use crate::types::InputObjectType; @@ -13,14 +14,16 @@ use std::collections::HashSet; /// Validates an input object type's field type references, /// input-type legality, circular non-nullable reference chains, -/// and `@oneOf` constraints. +/// `@oneOf` constraints, and `@deprecated` constraints. /// /// Per the GraphQL spec, all input object fields must reference /// valid input types (scalars, enums, or other input objects) and /// input object types must not form non-nullable circular /// references (which would make them impossible to construct). /// Additionally, every field of a `@oneOf` input object must have -/// a nullable type and must not declare a default value. +/// a nullable type and must not declare a default value, and +/// `@deprecated` must not be applied to required (non-null +/// without a default value) input fields. /// /// See [Input Objects](https://spec.graphql.org/September2025/#sec-Input-Objects). pub(crate) struct InputObjectTypeValidator<'a> { @@ -43,6 +46,7 @@ impl<'a> InputObjectTypeValidator<'a> { pub fn validate(mut self) -> Vec { self.validate_oneof_constraints(); + self.validate_deprecated_constraints(); let fields = self.type_.fields(); self.validate_fields_recursive( self.type_.name(), @@ -53,6 +57,49 @@ impl<'a> InputObjectTypeValidator<'a> { self.errors } + /// Enforces that `@deprecated` is not applied to any required + /// (non-null type without a default value) input field. To + /// deprecate a required input field, it must first be made + /// optional. + /// + /// See [@deprecated](https://spec.graphql.org/September2025/#sec--deprecated). + fn validate_deprecated_constraints(&mut self) { + for (field_name, field) in self.type_.fields() { + let is_required = !field.type_annotation().nullable() + && field.default_value().is_none(); + // `@deprecated` must not appear on a required input + // field. + // + // https://spec.graphql.org/September2025/#sec--deprecated + if is_required && field.deprecation_state().is_deprecated() { + // Point the error at the input field's + // `@deprecated` annotation when possible; + // otherwise fall back to the field itself. + let error_span = + find_deprecated_annotation(field.directives()) + .map(|annot| annot.span()) + .unwrap_or_else(|| field.span()); + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::DeprecatedRequiredInputField { + field_name: field_name.to_string(), + parent_type_name: self.type_.name().to_string(), + }, + error_span, + vec![ + ErrorNote::help( + "to deprecate a required input field, first \ + make it optional by changing its type to \ + nullable or adding a default value", + ), + ErrorNote::spec( + "https://spec.graphql.org/September2025/#sec--deprecated", + ), + ], + )); + } + } + } + /// Enforces the `@oneOf` input object constraints: every field /// must have a nullable type and must not declare a default /// value. diff --git a/crates/libgraphql-core-v1/src/validators/object_or_interface_type_validator.rs b/crates/libgraphql-core-v1/src/validators/object_or_interface_type_validator.rs index e1d8d49..0883ea7 100644 --- a/crates/libgraphql-core-v1/src/validators/object_or_interface_type_validator.rs +++ b/crates/libgraphql-core-v1/src/validators/object_or_interface_type_validator.rs @@ -2,6 +2,7 @@ use crate::error_note::ErrorNote; use crate::names::TypeName; use crate::schema::TypeValidationError; use crate::schema::TypeValidationErrorKind; +use crate::types::find_deprecated_annotation; use crate::types::GraphQLType; use crate::types::HasFieldsAndInterfaces; use crate::types::InterfaceType; @@ -31,15 +32,17 @@ use std::collections::HashSet; /// interface `I`, validates the field contract: every /// interface field must exist on the implementing type with /// matching parameters (equivalence) and a covariant return -/// type. Additional parameters must be optional. Uses a -/// separate dedup set so that an interface's field contract -/// is checked exactly once even if multiple declared -/// interfaces share a transitive ancestor. +/// type. Additional parameters must be optional, and a field +/// implementing a non-deprecated interface field must not be +/// deprecated. Uses a separate dedup set so that an +/// interface's field contract is checked exactly once even if +/// multiple declared interfaces share a transitive ancestor. /// /// 3. **Field type/param checks** — For ALL fields on the /// implementing type (including non-interface fields), -/// validates that return types are output types and parameter -/// types are input types. +/// validates that return types are output types, parameter +/// types are input types, and required parameters are not +/// marked `@deprecated`. /// /// Each phase uses its own local state, avoiding the /// shared-state bug where phase 1's transitive walk could @@ -190,6 +193,8 @@ impl<'a, T: HasFieldsAndInterfaces> ObjectOrInterfaceTypeValidator<'a, T> { /// - Parameter equivalence (same params, same types) /// - Additional params must be optional /// - Return type must be a covariant subtype + /// - A field implementing a non-deprecated interface field + /// must not be deprecated (step 2.6) /// /// Uses a separate `field_validated_interfaces` set to avoid /// checking the same interface's fields twice (e.g. when @@ -371,11 +376,43 @@ impl<'a, T: HasFieldsAndInterfaces> ObjectOrInterfaceTypeValidator<'a, T> { )); } - // TODO: IsValidImplementation step 2.f -- if the interface field - // is NOT deprecated, the implementing field must also NOT be - // deprecated. This check is deferred until DeprecationState is - // queryable from FieldDefinition. + // IsValidImplementation step 2.6: "If {field} is + // deprecated then {implementedField} must also be + // deprecated." I.e. a field implementing a + // non-deprecated interface field must not itself be + // marked `@deprecated`. + // // https://spec.graphql.org/September2025/#IsValidImplementation() + if type_field.deprecation_state().is_deprecated() + && !iface_field.deprecation_state().is_deprecated() { + // Point the error at the implementing field's + // `@deprecated` annotation when possible; + // otherwise fall back to the field itself. + let error_span = + find_deprecated_annotation(type_field.directives()) + .map(|annot| annot.span()) + .unwrap_or_else(|| type_field.span()); + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::DeprecatedFieldImplementingNonDeprecatedInterfaceField { + field_name: field_name.to_string(), + interface_name: iface_name.to_string(), + type_name: type_name.to_string(), + }, + error_span, + vec![ + ErrorNote::general_with_span( + format!( + "`{iface_name}.{field_name}` is \ + defined here without `@deprecated`", + ), + iface_field.span(), + ), + ErrorNote::spec( + "https://spec.graphql.org/September2025/#IsValidImplementation()", + ), + ], + )); + } } } } @@ -385,10 +422,13 @@ impl<'a, T: HasFieldsAndInterfaces> ObjectOrInterfaceTypeValidator<'a, T> { /// Independent of interface validation — validates that every /// field on the implementing type (including non-interface /// fields) uses valid output types for return values and - /// valid input types for parameters. + /// valid input types for parameters, and that `@deprecated` + /// is not applied to any required (non-null without a default + /// value) parameter. /// /// https://spec.graphql.org/September2025/#sel-JAHZhCFDBFABLBgB_pM /// https://spec.graphql.org/September2025/#sel-KAHZhCFDBHBDCAACEB6yD + /// https://spec.graphql.org/September2025/#sec--deprecated fn check_field_types(&mut self) { let type_name = self.type_.name(); let type_fields = self.type_.fields(); @@ -441,6 +481,42 @@ impl<'a, T: HasFieldsAndInterfaces> ObjectOrInterfaceTypeValidator<'a, T> { } for (param_name, param) in field.parameters() { + // `@deprecated` must not appear on a required + // (non-null type without a default value) + // parameter. To deprecate a required parameter, it + // must first be made optional. + // + // https://spec.graphql.org/September2025/#sec--deprecated + let is_required = !param.type_annotation().nullable() + && param.default_value().is_none(); + if is_required && param.deprecation_state().is_deprecated() { + // Point the error at the parameter's + // `@deprecated` annotation when possible; + // otherwise fall back to the parameter itself. + let error_span = + find_deprecated_annotation(param.directives()) + .map(|annot| annot.span()) + .unwrap_or_else(|| param.span()); + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::DeprecatedRequiredParameter { + field_name: field_name.to_string(), + parameter_name: param_name.to_string(), + type_name: type_name.to_string(), + }, + error_span, + vec![ + ErrorNote::help( + "to deprecate a required parameter, first \ + make it optional by changing its type to \ + nullable or adding a default value", + ), + ErrorNote::spec( + "https://spec.graphql.org/September2025/#sec--deprecated", + ), + ], + )); + } + let innermost_type_name = param.type_annotation().innermost_type_name(); let innermost_type = self.types_map.get(innermost_type_name); diff --git a/crates/libgraphql-core-v1/src/validators/tests/directive_definition_validator_tests.rs b/crates/libgraphql-core-v1/src/validators/tests/directive_definition_validator_tests.rs index d97e793..a3f4cd7 100644 --- a/crates/libgraphql-core-v1/src/validators/tests/directive_definition_validator_tests.rs +++ b/crates/libgraphql-core-v1/src/validators/tests/directive_definition_validator_tests.rs @@ -1,7 +1,10 @@ +use crate::directive_annotation::DirectiveAnnotation; +use crate::error_note::ErrorNoteKind; use crate::names::DirectiveName; use crate::names::FieldName; use crate::names::TypeName; use crate::schema::TypeValidationErrorKind; +use crate::span::SourceMapId; use crate::span::Span; use crate::types::DirectiveDefinition; use crate::types::DirectiveDefinitionKind; @@ -14,7 +17,9 @@ use crate::types::ScalarKind; use crate::types::ScalarType; use crate::types::TypeAnnotation; use crate::validators::validate_directive_definitions; +use crate::value::Value; use indexmap::IndexMap; +use libgraphql_parser::ByteSpan; fn string_scalar() -> GraphQLType { GraphQLType::Scalar(Box::new(ScalarType { @@ -290,3 +295,152 @@ fn directive_param_output_type_error_display_is_sensible() { has type `Result` which is not an input type", ); } + +fn deprecated_annotation(span: Span) -> DirectiveAnnotation { + DirectiveAnnotation { + arguments: IndexMap::new(), + name: DirectiveName::new("deprecated"), + span, + } +} + +fn make_param_with( + name: &str, + type_annot: TypeAnnotation, + default_value: Option, + directives: Vec, +) -> ParameterDefinition { + ParameterDefinition { + default_value, + description: None, + directives, + name: FieldName::new(name), + span: Span::dummy(), + type_annotation: type_annot, + } +} + +// Verifies that `@deprecated` on a required (non-null, no +// default value) parameter of a custom directive definition +// produces a DeprecatedRequiredDirectiveParameter error whose +// span points at the `@deprecated` annotation, with help + spec +// notes. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn deprecated_required_directive_parameter() { + let deprecated_span = Span::new( + ByteSpan::new(40, 51), + SourceMapId(1), + ); + let mut params = IndexMap::new(); + params.insert( + FieldName::new("oldArg"), + make_param_with( + "oldArg", + TypeAnnotation::named("String", /* nullable = */ false), + None, + vec![deprecated_annotation(deprecated_span)], + ), + ); + let mut directive_defs = IndexMap::new(); + directive_defs.insert( + DirectiveName::new("myDirective"), + DirectiveDefinition { + description: None, + is_repeatable: false, + kind: DirectiveDefinitionKind::Custom, + locations: vec![DirectiveLocationKind::FieldDefinition], + name: DirectiveName::new("myDirective"), + parameters: params, + span: Span::dummy(), + }, + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + + let errors = validate_directive_definitions( + &directive_defs, + &types_map, + ); + assert_eq!(errors.len(), 1, "unexpected errors: {errors:?}"); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::DeprecatedRequiredDirectiveParameter { + directive_name, + parameter_name, + } if directive_name == "myDirective" + && parameter_name == "oldArg" + )); + assert_eq!(errors[0].span(), deprecated_span); + assert!( + errors[0].notes().iter().any(|note| { + note.kind == ErrorNoteKind::Spec + && note.message.contains("sec--deprecated") + }), + "expected a spec note, got: {:?}", + errors[0].notes(), + ); + assert!( + errors[0].notes().iter().any(|note| { + note.kind == ErrorNoteKind::Help + }), + "expected a help note, got: {:?}", + errors[0].notes(), + ); +} + +// Verifies that `@deprecated` on an OPTIONAL parameter of a +// custom directive definition is valid: both a nullable +// parameter and a non-null parameter with a default value may +// be deprecated. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn deprecated_optional_directive_parameters_ok() { + let mut params = IndexMap::new(); + params.insert( + FieldName::new("nullableArg"), + make_param_with( + "nullableArg", + TypeAnnotation::named("String", /* nullable = */ true), + None, + vec![deprecated_annotation(Span::dummy())], + ), + ); + params.insert( + FieldName::new("defaultedArg"), + make_param_with( + "defaultedArg", + TypeAnnotation::named("String", /* nullable = */ false), + Some(Value::String("default".to_string())), + vec![deprecated_annotation(Span::dummy())], + ), + ); + let mut directive_defs = IndexMap::new(); + directive_defs.insert( + DirectiveName::new("myDirective"), + DirectiveDefinition { + description: None, + is_repeatable: false, + kind: DirectiveDefinitionKind::Custom, + locations: vec![DirectiveLocationKind::FieldDefinition], + name: DirectiveName::new("myDirective"), + parameters: params, + span: Span::dummy(), + }, + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + + let errors = validate_directive_definitions( + &directive_defs, + &types_map, + ); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} diff --git a/crates/libgraphql-core-v1/src/validators/tests/input_object_type_validator_tests.rs b/crates/libgraphql-core-v1/src/validators/tests/input_object_type_validator_tests.rs index 4fa8aa3..4be0fec 100644 --- a/crates/libgraphql-core-v1/src/validators/tests/input_object_type_validator_tests.rs +++ b/crates/libgraphql-core-v1/src/validators/tests/input_object_type_validator_tests.rs @@ -1,9 +1,11 @@ use crate::directive_annotation::DirectiveAnnotation; +use crate::error_note::ErrorNoteKind; use crate::names::DirectiveName; use crate::names::FieldName; use crate::names::TypeName; use crate::schema::TypeValidationError; use crate::schema::TypeValidationErrorKind; +use crate::span::SourceMapId; use crate::span::Span; use crate::types::FieldedTypeData; use crate::types::GraphQLType; @@ -18,6 +20,7 @@ use crate::types::UnionType; use crate::validators::InputObjectTypeValidator; use crate::value::Value; use indexmap::IndexMap; +use libgraphql_parser::ByteSpan; fn string_scalar() -> GraphQLType { GraphQLType::Scalar(Box::new(ScalarType { @@ -1286,3 +1289,143 @@ fn oneof_with_nullable_list_of_non_nullable_elements_is_valid() { "expected no errors, got: {errors:?}", ); } + +fn deprecated_annotation(span: Span) -> DirectiveAnnotation { + DirectiveAnnotation { + arguments: IndexMap::new(), + name: DirectiveName::new("deprecated"), + span, + } +} + +fn make_input_field_with( + name: &str, + parent: &str, + type_annot: TypeAnnotation, + default_value: Option, + directives: Vec, +) -> InputField { + InputField { + default_value, + description: None, + directives, + name: FieldName::new(name), + parent_type_name: TypeName::new(parent), + span: Span::dummy(), + type_annotation: type_annot, + } +} + +// Verifies that `@deprecated` on a required (non-null, no +// default value) input field produces a +// DeprecatedRequiredInputField error whose span points at the +// `@deprecated` annotation, with help + spec notes. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn deprecated_required_input_field() { + let deprecated_span = Span::new( + ByteSpan::new(30, 41), + SourceMapId(1), + ); + let mut fields = IndexMap::new(); + fields.insert( + FieldName::new("oldField"), + make_input_field_with( + "oldField", + "CreateUserInput", + TypeAnnotation::named("String", /* nullable = */ false), + None, + vec![deprecated_annotation(deprecated_span)], + ), + ); + let input_obj = InputObjectType { + description: None, + directives: vec![], + fields, + name: TypeName::new("CreateUserInput"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + + let errors = InputObjectTypeValidator::new( + &input_obj, + &types_map, + ).validate(); + assert_eq!(errors.len(), 1, "unexpected errors: {errors:?}"); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::DeprecatedRequiredInputField { + field_name, + parent_type_name, + } if field_name == "oldField" + && parent_type_name == "CreateUserInput" + )); + assert_eq!(errors[0].span(), deprecated_span); + assert!( + errors[0].notes().iter().any(|note| { + note.kind == ErrorNoteKind::Spec + && note.message.contains("sec--deprecated") + }), + "expected a spec note, got: {:?}", + errors[0].notes(), + ); + assert!( + errors[0].notes().iter().any(|note| { + note.kind == ErrorNoteKind::Help + }), + "expected a help note, got: {:?}", + errors[0].notes(), + ); +} + +// Verifies that `@deprecated` on an OPTIONAL input field is +// valid: both a nullable input field and a non-null input field +// with a default value may be deprecated. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn deprecated_optional_input_fields_ok() { + let mut fields = IndexMap::new(); + fields.insert( + FieldName::new("nullableField"), + make_input_field_with( + "nullableField", + "CreateUserInput", + TypeAnnotation::named("String", /* nullable = */ true), + None, + vec![deprecated_annotation(Span::dummy())], + ), + ); + fields.insert( + FieldName::new("defaultedField"), + make_input_field_with( + "defaultedField", + "CreateUserInput", + TypeAnnotation::named("String", /* nullable = */ false), + Some(Value::String("anonymous".to_string())), + vec![deprecated_annotation(Span::dummy())], + ), + ); + let input_obj = InputObjectType { + description: None, + directives: vec![], + fields, + name: TypeName::new("CreateUserInput"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + + let errors = InputObjectTypeValidator::new( + &input_obj, + &types_map, + ).validate(); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} diff --git a/crates/libgraphql-core-v1/src/validators/tests/object_or_interface_type_validator_tests.rs b/crates/libgraphql-core-v1/src/validators/tests/object_or_interface_type_validator_tests.rs index 9f677ba..9ea08c8 100644 --- a/crates/libgraphql-core-v1/src/validators/tests/object_or_interface_type_validator_tests.rs +++ b/crates/libgraphql-core-v1/src/validators/tests/object_or_interface_type_validator_tests.rs @@ -1,4 +1,7 @@ +use crate::directive_annotation::DirectiveAnnotation; +use crate::error_note::ErrorNoteKind; use crate::located::Located; +use crate::names::DirectiveName; use crate::names::FieldName; use crate::names::TypeName; use crate::schema::TypeValidationErrorKind; @@ -14,6 +17,7 @@ use crate::types::ScalarKind; use crate::types::ScalarType; use crate::types::TypeAnnotation; use crate::validators::ObjectOrInterfaceTypeValidator; +use crate::value::Value; use indexmap::IndexMap; use libgraphql_parser::ByteSpan; use std::collections::HashSet; @@ -2129,3 +2133,528 @@ fn undefined_field_type_with_did_you_mean_suggestion() { errors[0].notes(), ); } + +fn deprecated_annotation(span: Span) -> DirectiveAnnotation { + DirectiveAnnotation { + arguments: IndexMap::new(), + name: DirectiveName::new("deprecated"), + span, + } +} + +fn make_deprecated_field( + name: &str, + parent: &str, + type_annot: TypeAnnotation, + deprecated_span: Span, +) -> FieldDefinition { + FieldDefinition { + description: None, + directives: vec![deprecated_annotation(deprecated_span)], + name: FieldName::new(name), + parameters: IndexMap::new(), + parent_type_name: TypeName::new(parent), + span: Span::dummy(), + type_annotation: type_annot, + } +} + +fn make_param_with( + name: &str, + type_annot: TypeAnnotation, + default_value: Option, + directives: Vec, +) -> ParameterDefinition { + ParameterDefinition { + default_value, + description: None, + directives, + name: FieldName::new(name), + span: Span::dummy(), + type_annotation: type_annot, + } +} + +// Verifies IsValidImplementation step 2.6: "If {field} is +// deprecated then {implementedField} must also be deprecated." +// A field marked `@deprecated` on an implementing object whose +// corresponding interface field is NOT deprecated must produce a +// DeprecatedFieldImplementingNonDeprecatedInterfaceField error +// whose span points at the implementing field's `@deprecated` +// annotation, with a spec note attached. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn deprecated_field_implementing_non_deprecated_interface_field() { + let mut iface_fields = IndexMap::new(); + iface_fields.insert( + FieldName::new("name"), + make_field( + "name", + "Node", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let iface = make_interface("Node", iface_fields, vec![]); + + let deprecated_span = Span::new( + ByteSpan::new(50, 61), + SourceMapId(1), + ); + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("name"), + make_deprecated_field( + "name", + "User", + TypeAnnotation::named("String", /* nullable = */ true), + deprecated_span, + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("Node")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(iface)), + ); + types_map.insert( + TypeName::new("User"), + GraphQLType::Object(Box::new(obj.clone())), + ); + + let errors = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ).validate(); + assert_eq!(errors.len(), 1, "unexpected errors: {errors:?}"); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::DeprecatedFieldImplementingNonDeprecatedInterfaceField { + field_name, + interface_name, + type_name, + } if field_name == "name" + && interface_name == "Node" + && type_name == "User" + )); + + // The error should point at the `@deprecated` annotation on + // the implementing field. + assert_eq!(errors[0].span(), deprecated_span); + + // A spec note must be attached. + assert!( + errors[0].notes().iter().any(|note| { + note.kind == ErrorNoteKind::Spec + && note.message.contains("IsValidImplementation") + }), + "expected a spec note, got: {:?}", + errors[0].notes(), + ); +} + +// Verifies IsValidImplementation step 2.6 (positive case): when +// both the interface field and the implementing field are marked +// `@deprecated`, no error is produced. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn deprecated_field_implementing_deprecated_interface_field_ok() { + let mut iface_fields = IndexMap::new(); + iface_fields.insert( + FieldName::new("name"), + make_deprecated_field( + "name", + "Node", + TypeAnnotation::named("String", /* nullable = */ true), + Span::dummy(), + ), + ); + let iface = make_interface("Node", iface_fields, vec![]); + + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("name"), + make_deprecated_field( + "name", + "User", + TypeAnnotation::named("String", /* nullable = */ true), + Span::dummy(), + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("Node")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(iface)), + ); + types_map.insert( + TypeName::new("User"), + GraphQLType::Object(Box::new(obj.clone())), + ); + + let errors = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ).validate(); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} + +// Verifies IsValidImplementation step 2.6 only constrains +// deprecation in one direction: a NON-deprecated implementing +// field whose interface field IS deprecated is valid ("If +// {field} is deprecated then {implementedField} must also be +// deprecated" — the converse is not required). +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn non_deprecated_field_implementing_deprecated_interface_field_ok() { + let mut iface_fields = IndexMap::new(); + iface_fields.insert( + FieldName::new("name"), + make_deprecated_field( + "name", + "Node", + TypeAnnotation::named("String", /* nullable = */ true), + Span::dummy(), + ), + ); + let iface = make_interface("Node", iface_fields, vec![]); + + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("name"), + make_field( + "name", + "User", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("Node")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(iface)), + ); + types_map.insert( + TypeName::new("User"), + GraphQLType::Object(Box::new(obj.clone())), + ); + + let errors = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ).validate(); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} + +// Verifies that step 2.6 applies to interfaces implementing +// interfaces: an interface whose field is `@deprecated` while +// the same field on its implemented (parent) interface is not +// deprecated produces an error. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn deprecated_interface_field_implementing_non_deprecated_field() { + let mut base_fields = IndexMap::new(); + base_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Base", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let base_iface = make_interface("Base", base_fields, vec![]); + + let mut mid_fields = IndexMap::new(); + mid_fields.insert( + FieldName::new("id"), + make_deprecated_field( + "id", + "Mid", + TypeAnnotation::named("String", /* nullable = */ false), + Span::dummy(), + ), + ); + let mid_iface = make_interface( + "Mid", + mid_fields, + vec![located_type_name("Base")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Base"), + GraphQLType::Interface(Box::new(base_iface)), + ); + types_map.insert( + TypeName::new("Mid"), + GraphQLType::Interface(Box::new(mid_iface.clone())), + ); + + let errors = ObjectOrInterfaceTypeValidator::new( + &mid_iface, + &types_map, + ).validate(); + assert_eq!(errors.len(), 1, "unexpected errors: {errors:?}"); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::DeprecatedFieldImplementingNonDeprecatedInterfaceField { + field_name, + interface_name, + type_name, + } if field_name == "id" + && interface_name == "Base" + && type_name == "Mid" + )); +} + +// Verifies that step 2.6 is enforced against every +// directly-declared interface in a transitive chain: an object +// implementing both `Mid` and `Base` (where `Mid implements +// Base` and neither deprecates field `id`) with a `@deprecated` +// `id` field produces one error per interface contract. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn deprecated_field_transitive_interface_contracts() { + let mut base_fields = IndexMap::new(); + base_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Base", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let base_iface = make_interface("Base", base_fields, vec![]); + + let mut mid_fields = IndexMap::new(); + mid_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Mid", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let mid_iface = make_interface( + "Mid", + mid_fields, + vec![located_type_name("Base")], + ); + + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("id"), + make_deprecated_field( + "id", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + Span::dummy(), + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![ + located_type_name("Mid"), + located_type_name("Base"), + ], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Base"), + GraphQLType::Interface(Box::new(base_iface)), + ); + types_map.insert( + TypeName::new("Mid"), + GraphQLType::Interface(Box::new(mid_iface)), + ); + types_map.insert( + TypeName::new("User"), + GraphQLType::Object(Box::new(obj.clone())), + ); + + let errors = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ).validate(); + + let mut flagged_interfaces = errors + .iter() + .filter_map(|err| match err.kind() { + TypeValidationErrorKind::DeprecatedFieldImplementingNonDeprecatedInterfaceField { + interface_name, + .. + } => Some(interface_name.as_str()), + _ => None, + }) + .collect::>(); + flagged_interfaces.sort_unstable(); + assert_eq!( + flagged_interfaces, + vec!["Base", "Mid"], + "expected one 2.6 error per interface contract, \ + got: {errors:?}", + ); +} + +// Verifies that `@deprecated` on a required (non-null, no +// default value) field parameter produces a +// DeprecatedRequiredParameter error whose span points at the +// `@deprecated` annotation, with help + spec notes. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn deprecated_required_parameter() { + let deprecated_span = Span::new( + ByteSpan::new(20, 31), + SourceMapId(1), + ); + let mut params = IndexMap::new(); + params.insert( + FieldName::new("oldArg"), + make_param_with( + "oldArg", + TypeAnnotation::named("Int", /* nullable = */ false), + None, + vec![deprecated_annotation(deprecated_span)], + ), + ); + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("search"), + make_field_with_params( + "search", + "Query", + TypeAnnotation::named("String", /* nullable = */ true), + params, + ), + ); + let obj = make_object("Query", obj_fields, vec![]); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert(TypeName::new("Int"), int_scalar()); + types_map.insert( + TypeName::new("Query"), + GraphQLType::Object(Box::new(obj.clone())), + ); + + let errors = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ).validate(); + assert_eq!(errors.len(), 1, "unexpected errors: {errors:?}"); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::DeprecatedRequiredParameter { + field_name, + parameter_name, + type_name, + } if field_name == "search" + && parameter_name == "oldArg" + && type_name == "Query" + )); + assert_eq!(errors[0].span(), deprecated_span); + assert!( + errors[0].notes().iter().any(|note| { + note.kind == ErrorNoteKind::Spec + && note.message.contains("sec--deprecated") + }), + "expected a spec note, got: {:?}", + errors[0].notes(), + ); + assert!( + errors[0].notes().iter().any(|note| { + note.kind == ErrorNoteKind::Help + }), + "expected a help note, got: {:?}", + errors[0].notes(), + ); +} + +// Verifies that `@deprecated` on an OPTIONAL parameter is valid: +// both a nullable parameter and a non-null parameter with a +// default value may be deprecated. +// https://spec.graphql.org/September2025/#sec--deprecated +// Written by Claude Code, reviewed by a human. +#[test] +fn deprecated_optional_parameters_ok() { + let mut params = IndexMap::new(); + params.insert( + FieldName::new("nullableArg"), + make_param_with( + "nullableArg", + TypeAnnotation::named("Int", /* nullable = */ true), + None, + vec![deprecated_annotation(Span::dummy())], + ), + ); + params.insert( + FieldName::new("defaultedArg"), + make_param_with( + "defaultedArg", + TypeAnnotation::named("Int", /* nullable = */ false), + Some(Value::Int(42)), + vec![deprecated_annotation(Span::dummy())], + ), + ); + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("search"), + make_field_with_params( + "search", + "Query", + TypeAnnotation::named("String", /* nullable = */ true), + params, + ), + ); + let obj = make_object("Query", obj_fields, vec![]); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert(TypeName::new("Int"), int_scalar()); + types_map.insert( + TypeName::new("Query"), + GraphQLType::Object(Box::new(obj.clone())), + ); + + let errors = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ).validate(); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} diff --git a/libgraphql-core-v1-plan.md b/libgraphql-core-v1-plan.md index 6791e0c..9f5251a 100644 --- a/libgraphql-core-v1-plan.md +++ b/libgraphql-core-v1-plan.md @@ -516,7 +516,7 @@ Organized for human review — each commit is independently reviewable: 15. **Validators** — 4 validators (object/interface, union, input object, directive; the planned type-ref validator was removed — its checks are distributed across the per-type validators) 16. **SchemaBuilder::build()** — orchestration + Schema struct + typed query API 16.5. **Plan audit & touch-up** — this document repaired against shipped code -16.6. **Schema hardening** (5 PRs: a–e) — `@oneOf`, type extensions, IsValidImplementation 2.f, build-error spec notes, hygiene +16.6. **Schema hardening** (5 PRs: a–e) — `@oneOf`, type extensions, IsValidImplementation 2.6, build-error spec notes, hygiene 17. **Schema tests** — comprehensive schema building tests + snapshot harness (schema half) 18. **Operation types** — `Operation<'schema>`, `SelectionSet<'schema>`, `FieldSelection<'schema>`, fragments, etc. (AD17/AD18/AD19) 19. **Operation builders** (4 PRs: 19a SelectionSetBuilder, 19b FragmentRegistryBuilder, 19c OperationBuilder + typed builders, 19d ExecutableDocumentBuilder) @@ -3902,13 +3902,44 @@ to the previous type"; v1 has NO non-repeatable-application validation anywhere (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 +**16.6c — IsValidImplementation step 2.6 (deprecated-field consistency):** +- [x] Make `DeprecationState` queryable from `FieldDefinition` +- [x] Enforce: interface field not deprecated → implementing field must not be deprecated (https://spec.graphql.org/September2025/#IsValidImplementation()) -- [ ] Tests (positive + negative); remove the TODO at +- [x] Tests (positive + negative); remove the TODO at `object_or_interface_type_validator.rs:374` -- [ ] Commit: `[libgraphql-core-v1] Validate deprecated-field consistency (2.f)` +- [x] Commit: `[libgraphql-core-v1] Validate deprecated-field consistency (2.6)` + +**Completion Notes (16.6c):** `DeprecationState` is COMPUTED on demand, not stored: +new `deprecation_state()` accessors on `FieldDefinition`, `ParameterDefinition`, and +`InputField` delegate to `DeprecationState::from_directives()` (the enum's +`Option<&'a str>` reason borrows from the annotation, so eager storage would +self-reference; computing also means the 16.6b extension-merge paths need no extra +plumbing — merged items carry their `DirectiveAnnotation`s and validation runs over +the merged type). `reason` extraction: explicit string → that string; omitted → +`DeprecationState::DEFAULT_REASON` ("No longer supported", now shared with the +builtin `@deprecated` seed in `schema_builder.rs`); explicit `null`/non-string +(invalid per `String!`, unvalidated until 16.6f) → `None`. Spec rule direction +verified against the September2025 tag (Section 3, IsValidImplementation step 2.6): +"If {field} is deprecated then {implementedField} must also be deprecated" — i.e. +the implementing field may be `@deprecated` ONLY IF the interface field is; +interface-deprecated + implementer-not is legal. Implemented in +`ObjectOrInterfaceTypeValidator` phase 2 (per directly-declared interface contract, +so transitive chains are covered via the phase-1 requirement that transitive ifaces +be directly declared); error +`DeprecatedFieldImplementingNonDeprecatedInterfaceField` spans the implementing +field's `@deprecated` annotation (fallback: the field). §3.13.3 (`@deprecated` must +not appear on required args/input fields) lives at build()-time in the +VALIDATORS (not absorb-time) so extension-contributed params/fields are covered: +`ObjectOrInterfaceTypeValidator` phase 3 (`DeprecatedRequiredParameter`), +`InputObjectTypeValidator` (`DeprecatedRequiredInputField`), and +`validate_directive_definitions` (`DeprecatedRequiredDirectiveParameter` — §3.13.3's +blanket "required arguments" wording covers directive args too; builtin defs still +skipped). All three carry help ("first make it optional...") + spec notes. 28 tests +added (accessor semantics incl. default/null reason; 2.6 error/both-deprecated-ok/ +converse-ok/iface-implements-iface/transitive-two-contracts; required vs +nullable-or-defaulted × param/input-field/directive-param; e2e build_from_str for +each; 3 extension-path regressions). **16.6d — Spec notes on build-level errors:** Task 13's "every validation error includes a `Spec` note" only landed for the 4 type @@ -4970,7 +5001,7 @@ no "deferred past 1.0" bucket. (Checklist state below verified against code at T - [x] Interface implementation: field presence, param equivalence, return covariance - [x] Interface implementation: additional params must be optional - [x] Interface implementation: transitive (recursive) -- [ ] **(Task 16.6c)** Interface implementation: deprecated field consistency (IsValidImplementation step 2.f — if interface field is not deprecated, implementing field must not be deprecated) +- [x] Interface implementation: deprecated field consistency (IsValidImplementation step 2.6 — implementing field may be deprecated only if the interface field is deprecated) — Task 16.6c - [x] Union members must be Object types - [x] Input field types must be input types (not Object/Interface/Union — v0 bug fixed in v1) - [x] Output field types must be output types @@ -4984,8 +5015,9 @@ no "deferred past 1.0" bucket. (Checklist state below verified against code at T - [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) +- [x] `@deprecated` must not be applied to required arguments or + required input fields (§3.13.3; also enforced for directive definition + arguments) — Task 16.6c - [ ] **(Task 16.6f)** Root operation types must be distinct (§3.3.1 — same Object type may not serve two root operations) - [ ] **(Task 16.6f)** Directive definitions must not reference themselves directly