From 289505ee0076a6a10765d6e5eeca1aa1b09dd388 Mon Sep 17 00:00:00 2001 From: James Bellenger Date: Tue, 17 Feb 2026 08:57:33 -0800 Subject: [PATCH] add inhabited oneof validation --- .../validation/OneOfInputObjectRules.java | 44 +++++ .../validation/SchemaValidationErrorType.java | 1 + .../OneOfInputObjectRulesTest.groovy | 154 ++++++++++++++++++ 3 files changed, 199 insertions(+) diff --git a/src/main/java/graphql/schema/validation/OneOfInputObjectRules.java b/src/main/java/graphql/schema/validation/OneOfInputObjectRules.java index 87e39fc321..b41a7e570b 100644 --- a/src/main/java/graphql/schema/validation/OneOfInputObjectRules.java +++ b/src/main/java/graphql/schema/validation/OneOfInputObjectRules.java @@ -4,11 +4,16 @@ import graphql.schema.GraphQLInputObjectField; import graphql.schema.GraphQLInputObjectType; import graphql.schema.GraphQLSchemaElement; +import graphql.schema.GraphQLType; import graphql.schema.GraphQLTypeUtil; import graphql.schema.GraphQLTypeVisitorStub; +import graphql.schema.GraphQLUnmodifiedType; import graphql.util.TraversalControl; import graphql.util.TraverserContext; +import java.util.LinkedHashSet; +import java.util.Set; + import static java.lang.String.format; /* @@ -19,6 +24,45 @@ @ExperimentalApi public class OneOfInputObjectRules extends GraphQLTypeVisitorStub { + @Override + public TraversalControl visitGraphQLInputObjectType(GraphQLInputObjectType inputObjectType, TraverserContext context) { + if (!inputObjectType.isOneOf()) { + return TraversalControl.CONTINUE; + } + SchemaValidationErrorCollector errorCollector = context.getVarFromParents(SchemaValidationErrorCollector.class); + if (!canBeProvidedAFiniteValue(inputObjectType, new LinkedHashSet<>())) { + String message = format("OneOf Input Object %s must be inhabited but all fields recursively reference only other OneOf Input Objects forming an unresolvable cycle.", inputObjectType.getName()); + errorCollector.addError(new SchemaValidationError(SchemaValidationErrorType.OneOfNotInhabited, message)); + } + return TraversalControl.CONTINUE; + } + + private boolean canBeProvidedAFiniteValue(GraphQLInputObjectType oneOfInputObject, Set visited) { + if (visited.contains(oneOfInputObject)) { + return false; + } + Set nextVisited = new LinkedHashSet<>(visited); + nextVisited.add(oneOfInputObject); + for (GraphQLInputObjectField field : oneOfInputObject.getFieldDefinitions()) { + GraphQLType fieldType = field.getType(); + if (GraphQLTypeUtil.isList(fieldType)) { + return true; + } + GraphQLUnmodifiedType namedFieldType = GraphQLTypeUtil.unwrapAll(fieldType); + if (!(namedFieldType instanceof GraphQLInputObjectType)) { + return true; + } + GraphQLInputObjectType inputFieldType = (GraphQLInputObjectType) namedFieldType; + if (!inputFieldType.isOneOf()) { + return true; + } + if (canBeProvidedAFiniteValue(inputFieldType, nextVisited)) { + return true; + } + } + return false; + } + @Override public TraversalControl visitGraphQLInputObjectField(GraphQLInputObjectField inputObjectField, TraverserContext context) { GraphQLInputObjectType inputObjectType = (GraphQLInputObjectType) context.getParentNode(); diff --git a/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java b/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java index b8392b18d7..b1caecc8e4 100644 --- a/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java +++ b/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java @@ -22,6 +22,7 @@ public enum SchemaValidationErrorType implements SchemaValidationErrorClassifica InputTypeUsedInOutputTypeContext, OneOfDefaultValueOnField, OneOfNonNullableField, + OneOfNotInhabited, RequiredInputFieldCannotBeDeprecated, RequiredFieldArgumentCannotBeDeprecated, RequiredDirectiveArgumentCannotBeDeprecated diff --git a/src/test/groovy/graphql/schema/validation/OneOfInputObjectRulesTest.groovy b/src/test/groovy/graphql/schema/validation/OneOfInputObjectRulesTest.groovy index 813e54672b..f91dca44e2 100644 --- a/src/test/groovy/graphql/schema/validation/OneOfInputObjectRulesTest.groovy +++ b/src/test/groovy/graphql/schema/validation/OneOfInputObjectRulesTest.groovy @@ -33,4 +33,158 @@ class OneOfInputObjectRulesTest extends Specification { schemaProblem.errors[1].description == "OneOf input field OneOfInputType.badDefaulted cannot have a default value." schemaProblem.errors[1].classification == SchemaValidationErrorType.OneOfDefaultValueOnField } + + def "oneOf with scalar fields is inhabited"() { + def sdl = """ + type Query { f(arg: A): String } + input A @oneOf { a: String, b: Int } + """ + + when: + def registry = new SchemaParser().parse(sdl) + new SchemaGenerator().makeExecutableSchema(registry, TestUtil.getMockRuntimeWiring()) + + then: + noExceptionThrown() + } + + def "oneOf with enum field is inhabited"() { + def sdl = """ + type Query { f(arg: A): String } + enum Color { RED GREEN BLUE } + input A @oneOf { a: Color } + """ + + when: + def registry = new SchemaParser().parse(sdl) + new SchemaGenerator().makeExecutableSchema(registry, TestUtil.getMockRuntimeWiring()) + + then: + noExceptionThrown() + } + + def "oneOf with list field is inhabited"() { + def sdl = """ + type Query { f(arg: A): String } + input A @oneOf { a: [A] } + """ + + when: + def registry = new SchemaParser().parse(sdl) + new SchemaGenerator().makeExecutableSchema(registry, TestUtil.getMockRuntimeWiring()) + + then: + noExceptionThrown() + } + + def "oneOf referencing non-oneOf input is inhabited"() { + def sdl = """ + type Query { f(arg: A): String } + input A @oneOf { a: RegularInput } + input RegularInput { x: String } + """ + + when: + def registry = new SchemaParser().parse(sdl) + new SchemaGenerator().makeExecutableSchema(registry, TestUtil.getMockRuntimeWiring()) + + then: + noExceptionThrown() + } + + def "oneOf with escape field is inhabited"() { + def sdl = """ + type Query { f(arg: A): String } + input A @oneOf { b: B, escape: String } + input B @oneOf { a: A } + """ + + when: + def registry = new SchemaParser().parse(sdl) + new SchemaGenerator().makeExecutableSchema(registry, TestUtil.getMockRuntimeWiring()) + + then: + noExceptionThrown() + } + + def "mutually referencing oneOf types with scalar escape is inhabited"() { + def sdl = """ + type Query { f(arg: A): String } + input A @oneOf { b: B } + input B @oneOf { a: A, escape: Int } + """ + + when: + def registry = new SchemaParser().parse(sdl) + new SchemaGenerator().makeExecutableSchema(registry, TestUtil.getMockRuntimeWiring()) + + then: + noExceptionThrown() + } + + def "oneOf referencing non-oneOf with back-reference is inhabited"() { + def sdl = """ + type Query { f(arg: A): String } + input A @oneOf { b: RegularInput } + input RegularInput { back: A } + """ + + when: + def registry = new SchemaParser().parse(sdl) + new SchemaGenerator().makeExecutableSchema(registry, TestUtil.getMockRuntimeWiring()) + + then: + noExceptionThrown() + } + + def "multiple fields with chained oneOf escape is inhabited"() { + def sdl = """ + type Query { f(arg: A): String } + input A @oneOf { b: B, c: C } + input B @oneOf { a: A } + input C @oneOf { a: A, escape: String } + """ + + when: + def registry = new SchemaParser().parse(sdl) + new SchemaGenerator().makeExecutableSchema(registry, TestUtil.getMockRuntimeWiring()) + + then: + noExceptionThrown() + } + + def "single oneOf self-reference cycle is not inhabited"() { + def sdl = """ + type Query { f(arg: A): String } + input A @oneOf { self: A } + """ + + when: + def registry = new SchemaParser().parse(sdl) + new SchemaGenerator().makeExecutableSchema(registry, TestUtil.getMockRuntimeWiring()) + + then: + def schemaProblem = thrown(InvalidSchemaException) + schemaProblem.errors.size() == 1 + schemaProblem.errors[0].description == "OneOf Input Object A must be inhabited but all fields recursively reference only other OneOf Input Objects forming an unresolvable cycle." + schemaProblem.errors[0].classification == SchemaValidationErrorType.OneOfNotInhabited + } + + def "multiple oneOf types forming cycle are not inhabited"() { + def sdl = """ + type Query { f(arg: A): String } + input A @oneOf { b: B } + input B @oneOf { c: C } + input C @oneOf { a: A } + """ + + when: + def registry = new SchemaParser().parse(sdl) + new SchemaGenerator().makeExecutableSchema(registry, TestUtil.getMockRuntimeWiring()) + + then: + def schemaProblem = thrown(InvalidSchemaException) + schemaProblem.errors.size() == 3 + schemaProblem.errors.every { it.classification == SchemaValidationErrorType.OneOfNotInhabited } + } }