From 16c1062ed99e9ed683029f47f0f2b259267d69f8 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:39:32 +0300 Subject: [PATCH 1/4] feat: support the oneOf directive It means validation of operations and values being sent with operations. --- execution/engine/engine_config_test.go | 6 + .../engine/testdata/full_introspection.json | 8 + .../full_introspection_with_deprecated.json | 8 + .../full_introspection_with_typenames.json | 9 + v2/pkg/asttransform/baseschema.go | 6 + v2/pkg/asttransform/fixtures/complete.golden | 6 + .../fixtures/custom_query_name.golden | 6 + .../fixtures/mutation_only.golden | 6 + .../fixtures/schema_missing.golden | 6 + v2/pkg/asttransform/fixtures/simple.golden | 6 + .../fixtures/subscription_only.golden | 6 + .../fixtures/subscription_renamed.golden | 6 + .../with_mutation_subscription.golden | 6 + v2/pkg/astvalidation/operation_rule_values.go | 73 ++++++ .../operation_validation_test.go | 120 ++++++++++ .../fixtures/schema_introspection.golden | 10 + ...on_with_custom_root_operation_types.golden | 10 + .../fixtures/federated_schema.golden | 6 + v2/pkg/operationreport/externalerror.go | 37 +++ .../variablesvalidation.go | 51 ++++ .../variablesvalidation_test.go | 219 ++++++++++++++++++ 21 files changed, 611 insertions(+) diff --git a/execution/engine/engine_config_test.go b/execution/engine/engine_config_test.go index d824273f4d..16365d6648 100644 --- a/execution/engine/engine_config_test.go +++ b/execution/engine/engine_config_test.go @@ -399,6 +399,12 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/execution/engine/testdata/full_introspection.json b/execution/engine/testdata/full_introspection.json index b61c53265c..e0a8d47728 100644 --- a/execution/engine/testdata/full_introspection.json +++ b/execution/engine/testdata/full_introspection.json @@ -735,6 +735,14 @@ "defaultValue": null } ] + }, + { + "name": "oneOf", + "description": "The @oneOf built-in directive is used within the type system definition language\nto indicate an Input Object is a OneOf Input Object.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [] } ] } diff --git a/execution/engine/testdata/full_introspection_with_deprecated.json b/execution/engine/testdata/full_introspection_with_deprecated.json index ebfbecfe7b..1f4a9b67f4 100644 --- a/execution/engine/testdata/full_introspection_with_deprecated.json +++ b/execution/engine/testdata/full_introspection_with_deprecated.json @@ -769,6 +769,14 @@ "defaultValue": null } ] + }, + { + "name": "oneOf", + "description": "The @oneOf built-in directive is used within the type system definition language\nto indicate an Input Object is a OneOf Input Object.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [] } ] } diff --git a/execution/engine/testdata/full_introspection_with_typenames.json b/execution/engine/testdata/full_introspection_with_typenames.json index 6ed6f65361..c357c3fcac 100644 --- a/execution/engine/testdata/full_introspection_with_typenames.json +++ b/execution/engine/testdata/full_introspection_with_typenames.json @@ -831,6 +831,15 @@ "defaultValue": null } ] + }, + { + "__typename": "__Directive", + "name": "oneOf", + "description": "The @oneOf built-in directive is used within the type system definition language\nto indicate an Input Object is a OneOf Input Object.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [] } ] } diff --git a/v2/pkg/asttransform/baseschema.go b/v2/pkg/asttransform/baseschema.go index 45a6ebc94f..3d9809663a 100644 --- a/v2/pkg/asttransform/baseschema.go +++ b/v2/pkg/asttransform/baseschema.go @@ -168,6 +168,12 @@ directive @deprecated( directive @specifiedBy(url: String!) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/complete.golden b/v2/pkg/asttransform/fixtures/complete.golden index 27488b6fd9..1aeaa2f6ca 100644 --- a/v2/pkg/asttransform/fixtures/complete.golden +++ b/v2/pkg/asttransform/fixtures/complete.golden @@ -57,6 +57,12 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/custom_query_name.golden b/v2/pkg/asttransform/fixtures/custom_query_name.golden index 3bcc2af17f..d3b1f7a089 100644 --- a/v2/pkg/asttransform/fixtures/custom_query_name.golden +++ b/v2/pkg/asttransform/fixtures/custom_query_name.golden @@ -57,6 +57,12 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/mutation_only.golden b/v2/pkg/asttransform/fixtures/mutation_only.golden index 66da6be74e..3a3fd29a36 100644 --- a/v2/pkg/asttransform/fixtures/mutation_only.golden +++ b/v2/pkg/asttransform/fixtures/mutation_only.golden @@ -49,6 +49,12 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/schema_missing.golden b/v2/pkg/asttransform/fixtures/schema_missing.golden index 27488b6fd9..1aeaa2f6ca 100644 --- a/v2/pkg/asttransform/fixtures/schema_missing.golden +++ b/v2/pkg/asttransform/fixtures/schema_missing.golden @@ -57,6 +57,12 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/simple.golden b/v2/pkg/asttransform/fixtures/simple.golden index 27488b6fd9..1aeaa2f6ca 100644 --- a/v2/pkg/asttransform/fixtures/simple.golden +++ b/v2/pkg/asttransform/fixtures/simple.golden @@ -57,6 +57,12 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/subscription_only.golden b/v2/pkg/asttransform/fixtures/subscription_only.golden index e8e857300f..b02bb51775 100644 --- a/v2/pkg/asttransform/fixtures/subscription_only.golden +++ b/v2/pkg/asttransform/fixtures/subscription_only.golden @@ -48,6 +48,12 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/subscription_renamed.golden b/v2/pkg/asttransform/fixtures/subscription_renamed.golden index e78cb9d419..4affab4b7d 100644 --- a/v2/pkg/asttransform/fixtures/subscription_renamed.golden +++ b/v2/pkg/asttransform/fixtures/subscription_renamed.golden @@ -48,6 +48,12 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden b/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden index 229b2423c5..32f75878c6 100644 --- a/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden +++ b/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden @@ -68,6 +68,12 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/astvalidation/operation_rule_values.go b/v2/pkg/astvalidation/operation_rule_values.go index 5030117759..ed8840d8e4 100644 --- a/v2/pkg/astvalidation/operation_rule_values.go +++ b/v2/pkg/astvalidation/operation_rule_values.go @@ -430,6 +430,11 @@ func (v *valuesVisitor) valueSatisfiesInputObjectTypeDefinition(value ast.Value, return false } + // Validate @oneOf constraint if present + if v.objectValueViolatesOneOf(value, inputObjectTypeDefinition) { + return false + } + return true } @@ -464,6 +469,74 @@ func (v *valuesVisitor) objectValueHasDuplicateFields(objectValue int) bool { return hasDuplicates } +// objectValueViolatesOneOf checks if an input object value violates the @oneOf directive constraint. +func (v *valuesVisitor) objectValueViolatesOneOf(objectValue ast.Value, defRef int) bool { + def := v.definition.InputObjectTypeDefinitions[defRef] + // Check if the input object type has @oneOf directive + if !def.HasDirectives { + return false + } + hasOneOfDirective := def.Directives.HasDirectiveByName(v.definition, "oneOf") + if !hasOneOfDirective { + return false + } + + fieldRefs := v.operation.ObjectValues[objectValue.Ref].Refs + if len(fieldRefs) != 1 { + objName := v.definition.InputObjectTypeDefinitionNameBytes(defRef) + v.Report.AddExternalError(operationreport.ErrOneOfInputObjectFieldCount(objName, len(fieldRefs), objectValue.Position)) + return true + } + + type nullableVar struct { + fieldRef int + fieldValue ast.Value + variableDefinitionRef int + } + var nullableVars []nullableVar + + for _, fieldRef := range fieldRefs { + fieldValue := v.operation.ObjectFieldValue(fieldRef) + + if fieldValue.Kind == ast.ValueKindNull { + objName := v.definition.InputObjectTypeDefinitionNameBytes(defRef) + fieldName := v.operation.ObjectFieldNameBytes(fieldRef) + v.Report.AddExternalError(operationreport.ErrOneOfInputObjectNullValue(objName, fieldName, fieldValue.Position)) + return true + } + + if fieldValue.Kind == ast.ValueKindVariable { + // For variables, check if the variable type is nullable + variableDefinitionRef, variableTypeRef, _, ok := v.operationVariableType(fieldValue.Ref) + if !ok { + continue + } + + // Collect nullable variables + if v.operation.Types[variableTypeRef].TypeKind != ast.TypeKindNonNull { + nullableVars = append(nullableVars, nullableVar{ + fieldRef, + fieldValue, + variableDefinitionRef}) + } + } + } + + // If exactly one field, but it's a nullable variable, report that error + if len(nullableVars) > 0 { + violation := nullableVars[0] + objName := v.definition.InputObjectTypeDefinitionNameBytes(defRef) + fieldName := v.operation.ObjectFieldNameBytes(violation.fieldRef) + variableName := v.operation.VariableValueNameBytes(violation.fieldValue.Ref) + v.Report.AddExternalError(operationreport.ErrOneOfInputObjectNullableVariable( + objName, fieldName, variableName, violation.fieldValue.Position, + v.operation.VariableDefinitions[violation.variableDefinitionRef].VariableValue.Position)) + return true + } + + return false +} + func (v *valuesVisitor) objectFieldDefined(objectField, inputObjectTypeDefinition int) bool { name := v.operation.ObjectFieldNameBytes(objectField) for _, i := range v.definition.InputObjectTypeDefinitions[inputObjectTypeDefinition].InputFieldsDefinition.Refs { diff --git a/v2/pkg/astvalidation/operation_validation_test.go b/v2/pkg/astvalidation/operation_validation_test.go index 57e056f3e0..705434dbfa 100644 --- a/v2/pkg/astvalidation/operation_validation_test.go +++ b/v2/pkg/astvalidation/operation_validation_test.go @@ -3104,6 +3104,71 @@ type Query { }`, Values(), Valid) }) + + t.Run("oneOf", func(t *testing.T) { + t.Run("oneOf with default value", func(t *testing.T) { + run(t, ` + mutation addPet($pet: PetInput! = { cat: { name: "black" } }) { + addPet(pet: $pet) { + name + } + }`, Values(), Valid) + }) + t.Run("oneOf with no fields", func(t *testing.T) { + run(t, ` + mutation oneOfWithNoFields { + addPet(pet: {}) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" must have exactly one field provided, but 0 fields were provided`)) + }) + t.Run("oneOf with null field", func(t *testing.T) { + run(t, ` + mutation oneOfWithNoFields { + addPet(pet: { cat: null }) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" field "cat" value must be non-null`)) + }) + t.Run("oneOf with null field", func(t *testing.T) { + run(t, ` + mutation oneOfWithNoFields { + addPet(pet: { cat: null, dog: null }) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`)) + }) + t.Run("oneOf with one null field", func(t *testing.T) { + run(t, ` + mutation { + addPet(pet: { cat: { name: "black" }, dog: null }) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`)) + }) + t.Run("oneOf with two fields", func(t *testing.T) { + run(t, ` + mutation oneOfWithTwoFields($dog: DogInput) { + addPet(pet: { cat: { name: "black" }, dog: $dog }) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`)) + }) + t.Run("list of oneOf with nullable variable", func(t *testing.T) { + run(t, ` + mutation listOfOneOfWithNullableVariable($dog: DogInput) { + addPets(pets: [{ dog: $dog }]) { + name + } + }`, Values(), Invalid, + withValidationErrors(`OneOf input object "PetInput" field "dog" cannot use nullable variable "$dog". Variables used in oneOf fields must be non-nullable`)) + }) + }) }) t.Run("5.6.2 Input Object Field Names", func(t *testing.T) { t.Run("147", func(t *testing.T) { @@ -4011,6 +4076,36 @@ type Query { )) }) + t.Run("variables for OneOf fields must be non-nullable", func(t *testing.T) { + t.Run("non-nullable var", func(t *testing.T) { + run(t, ` + mutation addCat($cat: CatInput!) { + addPet(pet: { cat: $cat }) { + name + } + } + `, Values(), Valid) + }) + t.Run("non-nullable var with default", func(t *testing.T) { + run(t, ` + mutation addCatWithDefault($cat: CatInput! = { name: "black" }) { + addPet(pet: { cat: $cat }) { + name + } + } + `, Values(), Valid) + }) + t.Run("nullable cat", func(t *testing.T) { + run(t, ` + mutation addNullableCat($cat: CatInput) { + addPet(pet: { cat: $cat }) { + name + } + } + `, Values(), Invalid, withValidationErrors(`OneOf input object "PetInput" field "cat" cannot use nullable variable "$cat". Variables used in oneOf fields must be non-nullable`)) + }) + }) + t.Run("nested variable with a list type validation", func(t *testing.T) { definition := ` scalar String @@ -4946,6 +5041,25 @@ type Subscription { type Mutation { mutateDog: Dog + addPet(pet: PetInput!): Pet + addPets(pets: [PetInput!]!): [Pet] +} + +input CatInput { + name: String! + nickname: String + meowVolume: Int +} + +input DogInput { + name: String! + nickname: String + barkVolume: Int +} + +input PetInput @oneOf { + cat: CatInput + dog: DogInput } input ComplexInput { name: String, owner: String, optionalListOfOptionalStrings: [String]} @@ -5138,6 +5252,12 @@ directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden index 0064f2d6bf..e9da6904d1 100644 --- a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden +++ b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden @@ -350,6 +350,16 @@ ], "isRepeatable": false, "__typename": "__Directive" + }, + { + "name": "oneOf", + "description": "The @oneOf built-in directive is used within the type system definition language\nto indicate an Input Object is a OneOf Input Object.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [], + "isRepeatable": false, + "__typename": "__Directive" } ], "__typename": "__Schema" diff --git a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden index 0e8d299c2c..4326d42a76 100644 --- a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden +++ b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden @@ -498,6 +498,16 @@ ], "isRepeatable": false, "__typename": "__Directive" + }, + { + "name": "oneOf", + "description": "The @oneOf built-in directive is used within the type system definition language\nto indicate an Input Object is a OneOf Input Object.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [], + "isRepeatable": false, + "__typename": "__Directive" } ], "__typename": "__Schema" diff --git a/v2/pkg/federation/fixtures/federated_schema.golden b/v2/pkg/federation/fixtures/federated_schema.golden index 5a3119c899..717579d6fd 100644 --- a/v2/pkg/federation/fixtures/federated_schema.golden +++ b/v2/pkg/federation/fixtures/federated_schema.golden @@ -97,6 +97,12 @@ directive @specifiedBy( url: String! ) on SCALAR +""" +The @oneOf built-in directive is used within the type system definition language +to indicate an Input Object is a OneOf Input Object. +""" +directive @oneOf on INPUT_OBJECT + """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL's execution behavior diff --git a/v2/pkg/operationreport/externalerror.go b/v2/pkg/operationreport/externalerror.go index 8de9805f5c..834290cfc3 100644 --- a/v2/pkg/operationreport/externalerror.go +++ b/v2/pkg/operationreport/externalerror.go @@ -25,6 +25,9 @@ const ( MissingRequiredFieldOfInputObjectErrMsg = `Field "%s.%s" of required type "%s" was not provided.` UnknownFieldOfInputObjectErrMsg = `Field "%s" is not defined by type "%s".` DuplicatedFieldInputObjectErrMsg = `There can be only one input field named "%s".` + OneOfInputObjectFieldCountErrMsg = `OneOf input object "%s" must have exactly one field provided, but %d fields were provided.` + OneOfInputObjectNullValueErrMsg = `OneOf input object "%s" field "%s" value must be non-null.` + OneOfInputObjectNullableVariableErrMsg = `OneOf input object "%s" field "%s" cannot use nullable variable "$%s". Variables used in oneOf fields must be non-nullable.` ValueIsNotAnInputObjectTypeErrMsg = `Expected value of type "%s", found %s.` ) @@ -215,6 +218,40 @@ func ErrDuplicatedFieldInputObject(fieldName ast.ByteSlice, first, duplicated po return err } +func ErrOneOfInputObjectFieldCount(objName ast.ByteSlice, fieldsProvided int, position position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(OneOfInputObjectFieldCountErrMsg, objName, fieldsProvided) + err.Locations = LocationsFromPosition(position) + + return err +} + +func ErrOneOfInputObjectNullValue(objName, fieldName ast.ByteSlice, fieldPosition position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(OneOfInputObjectNullValueErrMsg, objName, fieldName) + err.Locations = []Location{ + { + Line: fieldPosition.LineStart, + Column: fieldPosition.CharStart, + }, + } + + return err +} +func ErrOneOfInputObjectNullableVariable(objName, fieldName, variableName ast.ByteSlice, fieldPosition, variablePosition position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(OneOfInputObjectNullableVariableErrMsg, objName, fieldName, variableName) + err.Locations = []Location{ + { + Line: fieldPosition.LineStart, + Column: fieldPosition.CharStart, + }, + { + Line: variablePosition.LineStart, + Column: variablePosition.CharStart, + }, + } + + return err +} + func ErrArgumentNotDefinedOnField(argName, typeName, fieldName ast.ByteSlice, position position.Position) (err ExternalError) { err.Message = fmt.Sprintf(UnknownArgumentOnFieldErrMsg, argName, typeName, fieldName) err.Locations = LocationsFromPosition(position) diff --git a/v2/pkg/variablesvalidation/variablesvalidation.go b/v2/pkg/variablesvalidation/variablesvalidation.go index 70bb6033ba..dde098ffd4 100644 --- a/v2/pkg/variablesvalidation/variablesvalidation.go +++ b/v2/pkg/variablesvalidation/variablesvalidation.go @@ -372,6 +372,53 @@ func (v *variablesVisitor) traverseFieldDefinitionType(fieldTypeDefinitionNodeKi v.traverseNamedTypeNode(jsonValue, v.definition.ResolveTypeNameBytes(typeRef)) } +func (v *variablesVisitor) violatesOneOfConstraint(inputObjectDefRef int, jsonValue *astjson.Value, typeName []byte) bool { + def := v.definition.InputObjectTypeDefinitions[inputObjectDefRef] + + // Check if the input object type has @oneOf directive + if !def.HasDirectives { + return false + } + hasOneOfDirective := def.Directives.HasDirectiveByName(v.definition, "oneOf") + if !hasOneOfDirective { + return false + } + + // Count all fields in the JSON object + obj := jsonValue.GetObject() + totalFieldCount := obj.Len() + + // Prioritize the count error + if totalFieldCount != 1 { + variableContent := string(jsonValue.MarshalTo(nil)) + v.err = v.newInvalidVariableError( + fmt.Sprintf(`%s; OneOf input object "%s" must have exactly one field provided, but %d fields were provided.`, + v.invalidValueMessage(string(v.currentVariableName), variableContent), + string(typeName), totalFieldCount)) + return true + } + + // Try to find at least one null field. + var firstNullField []byte + obj.Visit(func(key []byte, val *astjson.Value) { + if firstNullField == nil && val.Type() == astjson.TypeNull { + firstNullField = key + } + }) + + // Check if we have exactly one field, and it's non-null + if firstNullField == nil { + return false + } + + variableContent := string(jsonValue.MarshalTo(nil)) + v.err = v.newInvalidVariableError( + fmt.Sprintf(`%s; OneOf input object "%s" field "%s" value must be non-null.`, + v.invalidValueMessage(string(v.currentVariableName), variableContent), + string(typeName), string(firstNullField))) + return true +} + func (v *variablesVisitor) traverseNamedTypeNode(jsonValue *astjson.Value, typeName []byte) { if v.err != nil { return @@ -399,6 +446,10 @@ func (v *variablesVisitor) traverseNamedTypeNode(jsonValue *astjson.Value, typeN v.traverseFieldDefinitionType(fieldTypeDefinitionNode.Kind, fieldName, objectFieldValue, fieldTypeRef, inputFieldRef) v.popPath() } + if v.violatesOneOfConstraint(fieldTypeDefinitionNode.Ref, jsonValue, typeName) { + return // Error already reported by violatesOneOfConstraint + } + // validate that all input fields present in object are defined in the input object definition obj := jsonValue.GetObject() keys := make([][]byte, obj.Len()) diff --git a/v2/pkg/variablesvalidation/variablesvalidation_test.go b/v2/pkg/variablesvalidation/variablesvalidation_test.go index 754a2475ad..d0c9224e8d 100644 --- a/v2/pkg/variablesvalidation/variablesvalidation_test.go +++ b/v2/pkg/variablesvalidation/variablesvalidation_test.go @@ -1491,6 +1491,225 @@ func TestVariablesValidation(t *testing.T) { err := runTest(t, tc) require.NoError(t, err) }) + + t.Run("oneOf input objects", func(t *testing.T) { + catDogSchema := ` + input PetInput @oneOf { + cat: String, dog: String + } + type Query { + pet(input: PetInput!): String + }` + t.Run("valid cases", func(t *testing.T) { + t.Run("exactly one field provided", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": "Fluffy"}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + + t.Run("exactly one field provided - different field", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"dog": "Rex"}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + + t.Run("exactly one field with complex type", func(t *testing.T) { + tc := testCase{ + schema: `input CatInput { name: String! } input PetInput @oneOf { cat: CatInput, dog: String } type Query { pet(input: PetInput!): String }`, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": {"name": "Fluffy"}}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + }) + + t.Run("invalid cases", func(t *testing.T) { + t.Run("no fields provided", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 0 fields were provided`) + }) + + t.Run("multiple fields provided", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": "Fluffy", "dog": "Rex"}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + t.Run("multiple fields with one null", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": "Fluffy", "dog": null}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value; OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + t.Run("one field provided but null", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"dog": null}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" field "dog" value must be non-null`) + }) + + t.Run("all fields null", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": null, "dog": null}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value; OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + }) + + t.Run("nested oneOf input objects", func(t *testing.T) { + t.Run("valid nested oneOf", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + input SearchInput @oneOf { byName: String, byPet: PetInput } + type Query { search(input: SearchInput!): String } + `, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"byPet": {"cat": "Fluffy"}}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + + t.Run("invalid nested oneOf - inner violation", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + input SearchInput @oneOf { byName: String, byPet: PetInput } + type Query { search(input: SearchInput!): String } + `, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"byPet": {"cat": "Fluffy", "dog": "Rex"}}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + + t.Run("invalid nested oneOf - outer violation", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + input SearchInput @oneOf { byName: String, byPet: PetInput } + type Query { search(input: SearchInput!): String } + `, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"byName": "Fluffy", "byPet": {"cat": "Fluffy"}}}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "SearchInput" must have exactly one field provided, but 2 fields were provided`) + }) + }) + + t.Run("oneOf with lists", func(t *testing.T) { + t.Run("valid list of oneOf", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + type Query { pets(input: [PetInput!]!): String }`, + operation: `query($input: [PetInput!]!) { pets(input: $input) }`, + variables: `{"input": [{"cat": "Fluffy"}, {"dog": "Rex"}]}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + t.Run("invalid list of oneOf - one element violates", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + type Query { pets(input: [PetInput!]!): String }`, + operation: `query($input: [PetInput!]!) { pets(input: $input) }`, + variables: `{"input": [{"cat": "Fluffy"}, {"cat": "Whiskers", "dog": "Rex"}]}`, + } + err := runTest(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + t.Run("oneOf field is list", func(t *testing.T) { + tc := testCase{ + schema: ` + input SearchInput @oneOf { names: [String!], ids: [ID!] } + type Query { search(input: SearchInput!): String }`, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"names": ["Fluffy", "Rex"]}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + t.Run("oneOf field is an empty list", func(t *testing.T) { + tc := testCase{ + schema: ` + input SearchInput @oneOf { names: [String!], ids: [ID!] } + type Query { search(input: SearchInput!): String }`, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"names": []}}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) + }) + + t.Run("oneOf with variables content enabled", func(t *testing.T) { + t.Run("multiple fields error with content", func(t *testing.T) { + tc := testCase{ + schema: `input PetInput @oneOf { cat: String, dog: String } type Query { pet(input: PetInput!): String }`, + operation: `query($input: PetInput!) { pet(input: $input) }`, + variables: `{"input": {"cat": "Fluffy", "dog": "Rex"}}`, + } + err := runTestWithVariablesContentEnabled(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value {"cat":"Fluffy","dog":"Rex"}`) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + }) + t.Run("nested oneOf error shows the value", func(t *testing.T) { + tc := testCase{ + schema: ` + input PetInput @oneOf { cat: String, dog: String } + input SearchInput { pets: [PetInput!] } + type Query { search(input: SearchInput!): String } + `, + operation: `query($input: SearchInput!) { search(input: $input) }`, + variables: `{"input": {"pets": [{"cat": "Fluffy"}, {"cat": null}]}}`, + } + err := runTestWithVariablesContentEnabled(t, tc) + require.Error(t, err) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value {"cat":null}`) + assert.Contains(t, err.Error(), `OneOf input object "PetInput" field "cat" value must be non-null`) + }) + }) + }) } type testCase struct { From c00c15a7f1d4937545a4ef2f57c8c6ccc974d6ab Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:23:19 +0300 Subject: [PATCH 2/4] refactor the code --- execution/engine/engine_config_test.go | 5 ++- v2/pkg/asttransform/baseschema.go | 5 ++- v2/pkg/asttransform/fixtures/complete.golden | 5 ++- .../fixtures/custom_query_name.golden | 5 ++- .../fixtures/mutation_only.golden | 5 ++- .../fixtures/schema_missing.golden | 5 ++- v2/pkg/asttransform/fixtures/simple.golden | 5 ++- .../fixtures/subscription_only.golden | 5 ++- .../fixtures/subscription_renamed.golden | 5 ++- .../with_mutation_subscription.golden | 5 ++- v2/pkg/astvalidation/operation_rule_values.go | 30 ++++---------- .../operation_validation_test.go | 21 +++++++--- .../fixtures/schema_introspection.golden | 2 +- ...on_with_custom_root_operation_types.golden | 2 +- .../fixtures/federated_schema.golden | 5 ++- .../variablesvalidation.go | 39 ++++++++++++------- .../variablesvalidation_test.go | 5 +-- 17 files changed, 84 insertions(+), 70 deletions(-) diff --git a/execution/engine/engine_config_test.go b/execution/engine/engine_config_test.go index 16365d6648..db6427d70b 100644 --- a/execution/engine/engine_config_test.go +++ b/execution/engine/engine_config_test.go @@ -400,8 +400,9 @@ directive @specifiedBy( ) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/asttransform/baseschema.go b/v2/pkg/asttransform/baseschema.go index 3d9809663a..48a0a3dd9a 100644 --- a/v2/pkg/asttransform/baseschema.go +++ b/v2/pkg/asttransform/baseschema.go @@ -169,8 +169,9 @@ directive @deprecated( directive @specifiedBy(url: String!) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/asttransform/fixtures/complete.golden b/v2/pkg/asttransform/fixtures/complete.golden index 1aeaa2f6ca..fa69f656e6 100644 --- a/v2/pkg/asttransform/fixtures/complete.golden +++ b/v2/pkg/asttransform/fixtures/complete.golden @@ -58,8 +58,9 @@ directive @specifiedBy( ) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/asttransform/fixtures/custom_query_name.golden b/v2/pkg/asttransform/fixtures/custom_query_name.golden index d3b1f7a089..b1b8ff8c13 100644 --- a/v2/pkg/asttransform/fixtures/custom_query_name.golden +++ b/v2/pkg/asttransform/fixtures/custom_query_name.golden @@ -58,8 +58,9 @@ directive @specifiedBy( ) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/asttransform/fixtures/mutation_only.golden b/v2/pkg/asttransform/fixtures/mutation_only.golden index 3a3fd29a36..bfe7dab3d3 100644 --- a/v2/pkg/asttransform/fixtures/mutation_only.golden +++ b/v2/pkg/asttransform/fixtures/mutation_only.golden @@ -50,8 +50,9 @@ directive @specifiedBy( ) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/asttransform/fixtures/schema_missing.golden b/v2/pkg/asttransform/fixtures/schema_missing.golden index 1aeaa2f6ca..fa69f656e6 100644 --- a/v2/pkg/asttransform/fixtures/schema_missing.golden +++ b/v2/pkg/asttransform/fixtures/schema_missing.golden @@ -58,8 +58,9 @@ directive @specifiedBy( ) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/asttransform/fixtures/simple.golden b/v2/pkg/asttransform/fixtures/simple.golden index 1aeaa2f6ca..fa69f656e6 100644 --- a/v2/pkg/asttransform/fixtures/simple.golden +++ b/v2/pkg/asttransform/fixtures/simple.golden @@ -58,8 +58,9 @@ directive @specifiedBy( ) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/asttransform/fixtures/subscription_only.golden b/v2/pkg/asttransform/fixtures/subscription_only.golden index b02bb51775..923037d7ff 100644 --- a/v2/pkg/asttransform/fixtures/subscription_only.golden +++ b/v2/pkg/asttransform/fixtures/subscription_only.golden @@ -49,8 +49,9 @@ directive @specifiedBy( ) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/asttransform/fixtures/subscription_renamed.golden b/v2/pkg/asttransform/fixtures/subscription_renamed.golden index 4affab4b7d..21a6637642 100644 --- a/v2/pkg/asttransform/fixtures/subscription_renamed.golden +++ b/v2/pkg/asttransform/fixtures/subscription_renamed.golden @@ -49,8 +49,9 @@ directive @specifiedBy( ) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden b/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden index 32f75878c6..709ad78ac1 100644 --- a/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden +++ b/v2/pkg/asttransform/fixtures/with_mutation_subscription.golden @@ -69,8 +69,9 @@ directive @specifiedBy( ) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/astvalidation/operation_rule_values.go b/v2/pkg/astvalidation/operation_rule_values.go index ed8840d8e4..c21924432b 100644 --- a/v2/pkg/astvalidation/operation_rule_values.go +++ b/v2/pkg/astvalidation/operation_rule_values.go @@ -488,13 +488,6 @@ func (v *valuesVisitor) objectValueViolatesOneOf(objectValue ast.Value, defRef i return true } - type nullableVar struct { - fieldRef int - fieldValue ast.Value - variableDefinitionRef int - } - var nullableVars []nullableVar - for _, fieldRef := range fieldRefs { fieldValue := v.operation.ObjectFieldValue(fieldRef) @@ -514,26 +507,17 @@ func (v *valuesVisitor) objectValueViolatesOneOf(objectValue ast.Value, defRef i // Collect nullable variables if v.operation.Types[variableTypeRef].TypeKind != ast.TypeKindNonNull { - nullableVars = append(nullableVars, nullableVar{ - fieldRef, - fieldValue, - variableDefinitionRef}) + objName := v.definition.InputObjectTypeDefinitionNameBytes(defRef) + fieldName := v.operation.ObjectFieldNameBytes(fieldRef) + variableName := v.operation.VariableValueNameBytes(fieldValue.Ref) + v.Report.AddExternalError(operationreport.ErrOneOfInputObjectNullableVariable( + objName, fieldName, variableName, fieldValue.Position, + v.operation.VariableDefinitions[variableDefinitionRef].VariableValue.Position)) + return true } } } - // If exactly one field, but it's a nullable variable, report that error - if len(nullableVars) > 0 { - violation := nullableVars[0] - objName := v.definition.InputObjectTypeDefinitionNameBytes(defRef) - fieldName := v.operation.ObjectFieldNameBytes(violation.fieldRef) - variableName := v.operation.VariableValueNameBytes(violation.fieldValue.Ref) - v.Report.AddExternalError(operationreport.ErrOneOfInputObjectNullableVariable( - objName, fieldName, variableName, violation.fieldValue.Position, - v.operation.VariableDefinitions[violation.variableDefinitionRef].VariableValue.Position)) - return true - } - return false } diff --git a/v2/pkg/astvalidation/operation_validation_test.go b/v2/pkg/astvalidation/operation_validation_test.go index 705434dbfa..1f35d84c57 100644 --- a/v2/pkg/astvalidation/operation_validation_test.go +++ b/v2/pkg/astvalidation/operation_validation_test.go @@ -3114,9 +3114,17 @@ type Query { } }`, Values(), Valid) }) + t.Run("list of oneOf with non-null variable", func(t *testing.T) { + run(t, ` + mutation addPet($dog: DogInput!) { + addPets(pets: [{ dog: $dog }]) { + name + } + }`, Values(), Valid) + }) t.Run("oneOf with no fields", func(t *testing.T) { run(t, ` - mutation oneOfWithNoFields { + mutation { addPet(pet: {}) { name } @@ -3125,16 +3133,16 @@ type Query { }) t.Run("oneOf with null field", func(t *testing.T) { run(t, ` - mutation oneOfWithNoFields { + mutation { addPet(pet: { cat: null }) { name } }`, Values(), Invalid, withValidationErrors(`OneOf input object "PetInput" field "cat" value must be non-null`)) }) - t.Run("oneOf with null field", func(t *testing.T) { + t.Run("oneOf with two null fields", func(t *testing.T) { run(t, ` - mutation oneOfWithNoFields { + mutation { addPet(pet: { cat: null, dog: null }) { name } @@ -5253,8 +5261,9 @@ directive @deprecated( ) on FIELD_DEFINITION | ENUM_VALUE """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden index e9da6904d1..f6ab072284 100644 --- a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden +++ b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden @@ -353,7 +353,7 @@ }, { "name": "oneOf", - "description": "The @oneOf built-in directive is used within the type system definition language\nto indicate an Input Object is a OneOf Input Object.", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", "locations": [ "INPUT_OBJECT" ], diff --git a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden index 4326d42a76..6b73ac8dc5 100644 --- a/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden +++ b/v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden @@ -501,7 +501,7 @@ }, { "name": "oneOf", - "description": "The @oneOf built-in directive is used within the type system definition language\nto indicate an Input Object is a OneOf Input Object.", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", "locations": [ "INPUT_OBJECT" ], diff --git a/v2/pkg/federation/fixtures/federated_schema.golden b/v2/pkg/federation/fixtures/federated_schema.golden index 717579d6fd..40ac93d20d 100644 --- a/v2/pkg/federation/fixtures/federated_schema.golden +++ b/v2/pkg/federation/fixtures/federated_schema.golden @@ -98,8 +98,9 @@ directive @specifiedBy( ) on SCALAR """ -The @oneOf built-in directive is used within the type system definition language -to indicate an Input Object is a OneOf Input Object. +The @oneOf built-in directive marks an input object as a OneOf Input Object. +Exactly one field must be provided and its value must be non-null at runtime. +All fields defined within a @oneOf input must be nullable in the schema. """ directive @oneOf on INPUT_OBJECT diff --git a/v2/pkg/variablesvalidation/variablesvalidation.go b/v2/pkg/variablesvalidation/variablesvalidation.go index dde098ffd4..52fa799e3d 100644 --- a/v2/pkg/variablesvalidation/variablesvalidation.go +++ b/v2/pkg/variablesvalidation/variablesvalidation.go @@ -391,31 +391,43 @@ func (v *variablesVisitor) violatesOneOfConstraint(inputObjectDefRef int, jsonVa // Prioritize the count error if totalFieldCount != 1 { variableContent := string(jsonValue.MarshalTo(nil)) + var path string + if len(v.path) > 1 { + path = fmt.Sprintf(` at "%s"`, v.renderPath()) + } v.err = v.newInvalidVariableError( - fmt.Sprintf(`%s; OneOf input object "%s" must have exactly one field provided, but %d fields were provided.`, + fmt.Sprintf(`%s%s; OneOf input object "%s" must have exactly one field provided, but %d fields were provided.`, v.invalidValueMessage(string(v.currentVariableName), variableContent), - string(typeName), totalFieldCount)) + path, + string(typeName), + totalFieldCount)) return true } - // Try to find at least one null field. - var firstNullField []byte + // Check if the single field has a null value + var nullFieldName []byte obj.Visit(func(key []byte, val *astjson.Value) { - if firstNullField == nil && val.Type() == astjson.TypeNull { - firstNullField = key + if val.Type() == astjson.TypeNull { + nullFieldName = key } }) - // Check if we have exactly one field, and it's non-null - if firstNullField == nil { + if nullFieldName == nil { + // We have exactly one field, and it's non-null return false } variableContent := string(jsonValue.MarshalTo(nil)) + var path string + if len(v.path) > 1 { + path = fmt.Sprintf(` at "%s"`, v.renderPath()) + } v.err = v.newInvalidVariableError( - fmt.Sprintf(`%s; OneOf input object "%s" field "%s" value must be non-null.`, + fmt.Sprintf(`%s%s; OneOf input object "%s" field "%s" value must be non-null.`, v.invalidValueMessage(string(v.currentVariableName), variableContent), - string(typeName), string(firstNullField))) + path, + string(typeName), + string(nullFieldName))) return true } @@ -446,10 +458,6 @@ func (v *variablesVisitor) traverseNamedTypeNode(jsonValue *astjson.Value, typeN v.traverseFieldDefinitionType(fieldTypeDefinitionNode.Kind, fieldName, objectFieldValue, fieldTypeRef, inputFieldRef) v.popPath() } - if v.violatesOneOfConstraint(fieldTypeDefinitionNode.Ref, jsonValue, typeName) { - return // Error already reported by violatesOneOfConstraint - } - // validate that all input fields present in object are defined in the input object definition obj := jsonValue.GetObject() keys := make([][]byte, obj.Len()) @@ -466,6 +474,9 @@ func (v *variablesVisitor) traverseNamedTypeNode(jsonValue *astjson.Value, typeN return } } + if v.violatesOneOfConstraint(fieldTypeDefinitionNode.Ref, jsonValue, typeName) { + return // Error already reported + } case ast.NodeKindScalarTypeDefinition: switch unsafebytes.BytesToString(typeName) { case "String": diff --git a/v2/pkg/variablesvalidation/variablesvalidation_test.go b/v2/pkg/variablesvalidation/variablesvalidation_test.go index d0c9224e8d..0ac5122f5e 100644 --- a/v2/pkg/variablesvalidation/variablesvalidation_test.go +++ b/v2/pkg/variablesvalidation/variablesvalidation_test.go @@ -1614,7 +1614,7 @@ func TestVariablesValidation(t *testing.T) { } err := runTest(t, tc) require.Error(t, err) - assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value at "input.byPet"; OneOf input object "PetInput" must have exactly one field provided, but 2 fields were provided`) }) t.Run("invalid nested oneOf - outer violation", func(t *testing.T) { @@ -1705,8 +1705,7 @@ func TestVariablesValidation(t *testing.T) { } err := runTestWithVariablesContentEnabled(t, tc) require.Error(t, err) - assert.Contains(t, err.Error(), `Variable "$input" got invalid value {"cat":null}`) - assert.Contains(t, err.Error(), `OneOf input object "PetInput" field "cat" value must be non-null`) + assert.Contains(t, err.Error(), `Variable "$input" got invalid value {"cat":null} at "input.pets.[1]"; OneOf input object "PetInput" field "cat" value must be non-null`) }) }) }) From 838953a88d4b88c63c016d2ce12668da7d17c136 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:26:23 +0300 Subject: [PATCH 3/4] fix execution golden tests --- execution/engine/testdata/full_introspection.json | 2 +- .../engine/testdata/full_introspection_with_deprecated.json | 2 +- .../engine/testdata/full_introspection_with_typenames.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/execution/engine/testdata/full_introspection.json b/execution/engine/testdata/full_introspection.json index e0a8d47728..8473834888 100644 --- a/execution/engine/testdata/full_introspection.json +++ b/execution/engine/testdata/full_introspection.json @@ -738,7 +738,7 @@ }, { "name": "oneOf", - "description": "The @oneOf built-in directive is used within the type system definition language\nto indicate an Input Object is a OneOf Input Object.", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", "locations": [ "INPUT_OBJECT" ], diff --git a/execution/engine/testdata/full_introspection_with_deprecated.json b/execution/engine/testdata/full_introspection_with_deprecated.json index 1f4a9b67f4..74f8fa552f 100644 --- a/execution/engine/testdata/full_introspection_with_deprecated.json +++ b/execution/engine/testdata/full_introspection_with_deprecated.json @@ -772,7 +772,7 @@ }, { "name": "oneOf", - "description": "The @oneOf built-in directive is used within the type system definition language\nto indicate an Input Object is a OneOf Input Object.", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", "locations": [ "INPUT_OBJECT" ], diff --git a/execution/engine/testdata/full_introspection_with_typenames.json b/execution/engine/testdata/full_introspection_with_typenames.json index c357c3fcac..2eaf5e37e9 100644 --- a/execution/engine/testdata/full_introspection_with_typenames.json +++ b/execution/engine/testdata/full_introspection_with_typenames.json @@ -835,7 +835,7 @@ { "__typename": "__Directive", "name": "oneOf", - "description": "The @oneOf built-in directive is used within the type system definition language\nto indicate an Input Object is a OneOf Input Object.", + "description": "The @oneOf built-in directive marks an input object as a OneOf Input Object.\nExactly one field must be provided and its value must be non-null at runtime.\nAll fields defined within a @oneOf input must be nullable in the schema.", "locations": [ "INPUT_OBJECT" ], From 70d719d4105ad9ae44111787d14408003cfeb25f Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:49:30 +0300 Subject: [PATCH 4/4] add a test --- v2/pkg/variablesvalidation/variablesvalidation.go | 1 - .../variablesvalidation/variablesvalidation_test.go | 12 +++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/v2/pkg/variablesvalidation/variablesvalidation.go b/v2/pkg/variablesvalidation/variablesvalidation.go index 52fa799e3d..11c6b4e49d 100644 --- a/v2/pkg/variablesvalidation/variablesvalidation.go +++ b/v2/pkg/variablesvalidation/variablesvalidation.go @@ -384,7 +384,6 @@ func (v *variablesVisitor) violatesOneOfConstraint(inputObjectDefRef int, jsonVa return false } - // Count all fields in the JSON object obj := jsonValue.GetObject() totalFieldCount := obj.Len() diff --git a/v2/pkg/variablesvalidation/variablesvalidation_test.go b/v2/pkg/variablesvalidation/variablesvalidation_test.go index 0ac5122f5e..2e707c3f51 100644 --- a/v2/pkg/variablesvalidation/variablesvalidation_test.go +++ b/v2/pkg/variablesvalidation/variablesvalidation_test.go @@ -1510,7 +1510,6 @@ func TestVariablesValidation(t *testing.T) { err := runTest(t, tc) require.NoError(t, err) }) - t.Run("exactly one field provided - different field", func(t *testing.T) { tc := testCase{ schema: catDogSchema, @@ -1520,7 +1519,15 @@ func TestVariablesValidation(t *testing.T) { err := runTest(t, tc) require.NoError(t, err) }) - + t.Run("exactly one field provided - via nested var", func(t *testing.T) { + tc := testCase{ + schema: catDogSchema, + operation: `query($s: String) { pet(input: {cat: $s} ) }`, + variables: `{"s": "Rex"}`, + } + err := runTest(t, tc) + require.NoError(t, err) + }) t.Run("exactly one field with complex type", func(t *testing.T) { tc := testCase{ schema: `input CatInput { name: String! } input PetInput @oneOf { cat: CatInput, dog: String } type Query { pet(input: PetInput!): String }`, @@ -1543,7 +1550,6 @@ func TestVariablesValidation(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), `OneOf input object "PetInput" must have exactly one field provided, but 0 fields were provided`) }) - t.Run("multiple fields provided", func(t *testing.T) { tc := testCase{ schema: catDogSchema,