From 57b5fcec133d972c749a482a64d714170407eef4 Mon Sep 17 00:00:00 2001 From: Franklin Wang Date: Mon, 18 Nov 2024 19:13:21 +1100 Subject: [PATCH] Add validation for virtual types --- .../hydration/NadelHydrationDefinition.kt | 2 +- .../NadelExecutionBlueprintFactory.kt | 4 +- .../NadelVirtualTypeBlueprintFactory.kt | 2 +- .../nadel/validation/NadelFieldValidation.kt | 59 +-- .../NadelSchemaHydrationValidationError.kt | 2 +- .../validation/NadelSchemaValidationError.kt | 3 +- .../NadelSchemaValidationInterimResult.kt | 2 +- .../validation/NadelServiceSchemaElement.kt | 9 + .../nadel/validation/NadelTypeValidation.kt | 98 ++-- .../validation/NadelVirtualTypeValidation.kt | 268 +++++++++++ .../NadelVirtualTypeValidationError.kt | 81 ++++ .../NadelHydrationConditionValidation.kt | 2 +- .../hydration/NadelHydrationValidation.kt | 59 ++- .../NadelHydrationVirtualTypeValidation.kt | 120 +++++ .../validation/util/NadelGetReachableTypes.kt | 451 ++++++++++-------- .../nadel/schema/NadelDirectivesTest.kt | 2 +- ...adelHydrationArgumentTypeValidationTest.kt | 6 +- .../NadelHydrationValidationTest.kt | 10 +- ...NadelPolymorphicHydrationValidationTest.kt | 2 +- 19 files changed, 861 insertions(+), 321 deletions(-) create mode 100644 lib/src/main/java/graphql/nadel/validation/NadelVirtualTypeValidation.kt create mode 100644 lib/src/main/java/graphql/nadel/validation/NadelVirtualTypeValidationError.kt create mode 100644 lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationVirtualTypeValidation.kt diff --git a/lib/src/main/java/graphql/nadel/definition/hydration/NadelHydrationDefinition.kt b/lib/src/main/java/graphql/nadel/definition/hydration/NadelHydrationDefinition.kt index 40da9c550..269f429be 100644 --- a/lib/src/main/java/graphql/nadel/definition/hydration/NadelHydrationDefinition.kt +++ b/lib/src/main/java/graphql/nadel/definition/hydration/NadelHydrationDefinition.kt @@ -57,7 +57,7 @@ class NadelHydrationDefinition( ) } - val backingField: List + val pathToBackingField: List get() = appliedDirective.getArgument(Keyword.field).getValue().split(".") val identifiedBy: String? diff --git a/lib/src/main/java/graphql/nadel/engine/blueprint/NadelExecutionBlueprintFactory.kt b/lib/src/main/java/graphql/nadel/engine/blueprint/NadelExecutionBlueprintFactory.kt index cfcb7adca..353b1a9b5 100644 --- a/lib/src/main/java/graphql/nadel/engine/blueprint/NadelExecutionBlueprintFactory.kt +++ b/lib/src/main/java/graphql/nadel/engine/blueprint/NadelExecutionBlueprintFactory.kt @@ -224,7 +224,7 @@ private class Factory( virtualFieldDef: GraphQLFieldDefinition, hydration: NadelHydrationDefinition, ): NadelFieldInstruction { - val pathToBackingField = hydration.backingField + val pathToBackingField = hydration.pathToBackingField val backingFieldContainer = engineSchema.queryType.getFieldContainerFor(pathToBackingField)!! val backingFieldDef = engineSchema.queryType.getFieldAt(pathToBackingField)!! val hydrationBackingService = coordinatesToService[makeFieldCoordinates(backingFieldContainer, backingFieldDef)]!! @@ -412,7 +412,7 @@ private class Factory( location = location, virtualFieldDef = virtualFieldDef, backingService = backingService, - queryPathToBackingField = NadelQueryPath(hydration.backingField), + queryPathToBackingField = NadelQueryPath(hydration.pathToBackingField), backingFieldArguments = hydrationArgs, timeout = hydration.timeout, batchSize = batchSize, diff --git a/lib/src/main/java/graphql/nadel/engine/blueprint/NadelVirtualTypeBlueprintFactory.kt b/lib/src/main/java/graphql/nadel/engine/blueprint/NadelVirtualTypeBlueprintFactory.kt index 34cfb884d..819c46a49 100644 --- a/lib/src/main/java/graphql/nadel/engine/blueprint/NadelVirtualTypeBlueprintFactory.kt +++ b/lib/src/main/java/graphql/nadel/engine/blueprint/NadelVirtualTypeBlueprintFactory.kt @@ -50,7 +50,7 @@ internal class NadelVirtualTypeBlueprintFactory { virtualFieldDef: GraphQLFieldDefinition, ): List { val hydration = virtualFieldDef.getHydrationDefinitions().first() - val backingFieldDef = engineSchema.queryType.getFieldAt(hydration.backingField)!! + val backingFieldDef = engineSchema.queryType.getFieldAt(hydration.pathToBackingField)!! val backingType = backingFieldDef.type.unwrapAll() as? GraphQLObjectType ?: return emptyList() val virtualType = virtualFieldDef.type.unwrapAll() as? GraphQLObjectType diff --git a/lib/src/main/java/graphql/nadel/validation/NadelFieldValidation.kt b/lib/src/main/java/graphql/nadel/validation/NadelFieldValidation.kt index 24ab753f2..1a9b16c5a 100644 --- a/lib/src/main/java/graphql/nadel/validation/NadelFieldValidation.kt +++ b/lib/src/main/java/graphql/nadel/validation/NadelFieldValidation.kt @@ -1,9 +1,11 @@ package graphql.nadel.validation import graphql.nadel.definition.hydration.isHydrated +import graphql.nadel.definition.renamed.getRenamedOrNull import graphql.nadel.definition.renamed.isRenamed import graphql.nadel.engine.util.strictAssociateBy import graphql.nadel.engine.util.unwrapAll +import graphql.nadel.validation.NadelSchemaValidationError.IncompatibleArgumentInputType import graphql.nadel.validation.NadelSchemaValidationError.IncompatibleFieldOutputType import graphql.nadel.validation.NadelSchemaValidationError.MissingArgumentOnUnderlying import graphql.nadel.validation.NadelSchemaValidationError.MissingUnderlyingField @@ -11,16 +13,16 @@ import graphql.nadel.validation.NadelTypeWrappingValidation.Rule.LHS_MUST_BE_LOO import graphql.nadel.validation.hydration.NadelHydrationValidation import graphql.nadel.validation.util.NadelCombinedTypeUtil.getFieldsThatServiceContributed import graphql.nadel.validation.util.NadelSchemaUtil.getUnderlyingName +import graphql.schema.GraphQLArgument import graphql.schema.GraphQLFieldDefinition import graphql.schema.GraphQLNamedSchemaElement import graphql.schema.GraphQLOutputType internal class NadelFieldValidation( - private val typeValidation: NadelTypeValidation, + private val hydrationValidation: NadelHydrationValidation, ) { private val renameValidation = NadelRenameValidation(this) private val inputValidation = NadelInputValidation() - private val hydrationValidation = NadelHydrationValidation(typeValidation) private val partitionValidation = NadelPartitionValidation() private val typeWrappingValidation = NadelTypeWrappingValidation() @@ -96,24 +98,22 @@ internal class NadelFieldValidation( if (underlyingArg == null) { MissingArgumentOnUnderlying(parent, overallField, underlyingField, overallArg) } else { - val unwrappedTypeIssues = typeValidation - .validate( - NadelServiceSchemaElement.from( - service = parent.service, - overall = overallArg.type.unwrapAll(), - underlying = underlyingArg.type.unwrapAll(), + if (isUnwrappedArgTypeSame(overallArg, underlyingArg)) { + inputValidation + .validate( + parent = parent, + overallField = overallField, + overallInputArgument = overallArg, + underlyingInputArgument = underlyingArg ) - ) - - val inputTypeIssues = inputValidation - .validate( - parent = parent, + } else { + IncompatibleArgumentInputType( + parentType = parent, overallField = overallField, - overallInputArgument = overallArg, - underlyingInputArgument = underlyingArg + overallInputArg = overallArg, + underlyingInputArg = underlyingArg, ) - - results(unwrappedTypeIssues, inputTypeIssues) + } } } .toResult() @@ -124,6 +124,16 @@ internal class NadelFieldValidation( return results(argumentIssues, outputTypeIssues, partitionDirectiveIssues) } + private fun isUnwrappedArgTypeSame( + overallArg: GraphQLArgument, + underlyingArg: GraphQLArgument, + ): Boolean { + val overallArgTypeUnwrapped = overallArg.type.unwrapAll() + val underlyingArgTypeUnwrapped = underlyingArg.type.unwrapAll() + val expectedUnderlyingName = overallArgTypeUnwrapped.getRenamedOrNull()?.from ?: overallArgTypeUnwrapped.name + return expectedUnderlyingName == underlyingArgTypeUnwrapped.name + } + context(NadelValidationContext) private fun validateOutputType( parent: NadelServiceSchemaElement.FieldsContainer, @@ -133,22 +143,15 @@ internal class NadelFieldValidation( val overallType = overallField.type.unwrapAll() val underlyingType = underlyingField.type.unwrapAll() - val typeServiceSchemaElement = NadelServiceSchemaElement.from( - service = parent.service, - overall = overallType, - underlying = underlyingType, - ) - - // This checks whether the type is actually valid content wise - val outputTypeResult = typeValidation.validate(typeServiceSchemaElement) - .onError { return it } + if ((overallType.getRenamedOrNull()?.from ?: overallType.name) != underlyingType.name) { + return IncompatibleFieldOutputType(parent, overallField, underlyingField) + } // This checks whether the output type e.g. name or List or NonNull wrappings are valid return if (isOutputTypeValid(overallType = overallField.type, underlyingType = underlyingField.type)) { - outputTypeResult + ok() } else { results( - outputTypeResult, IncompatibleFieldOutputType(parent, overallField, underlyingField), ) } diff --git a/lib/src/main/java/graphql/nadel/validation/NadelSchemaHydrationValidationError.kt b/lib/src/main/java/graphql/nadel/validation/NadelSchemaHydrationValidationError.kt index b8a5ec3b6..1704c21e2 100644 --- a/lib/src/main/java/graphql/nadel/validation/NadelSchemaHydrationValidationError.kt +++ b/lib/src/main/java/graphql/nadel/validation/NadelSchemaHydrationValidationError.kt @@ -24,7 +24,7 @@ private fun getHydrationErrorMessage( ): String { val parentTypeName = parentType.overall.name val fieldName = virtualField.name - val backingField = hydration.backingField.joinToString(separator = ".") + val backingField = hydration.pathToBackingField.joinToString(separator = ".") return "Field $parentTypeName.$fieldName tried to hydrate from Query.$backingField but $reason" } diff --git a/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationError.kt b/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationError.kt index 7b9207fed..fdc8b8eeb 100644 --- a/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationError.kt +++ b/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationError.kt @@ -4,7 +4,6 @@ import graphql.ErrorClassification import graphql.GraphQLError import graphql.GraphqlErrorBuilder import graphql.nadel.Service -import graphql.nadel.definition.hydration.NadelBatchObjectIdentifiedByDefinition import graphql.nadel.definition.hydration.NadelHydrationArgumentDefinition import graphql.nadel.definition.hydration.NadelHydrationDefinition import graphql.nadel.definition.renamed.NadelRenamedDefinition @@ -237,7 +236,7 @@ sealed interface NadelSchemaValidationError : NadelSchemaValidationResult { override val message = run { val vf = makeFieldCoordinates(parentType.overall.name, overallField.name) - val bf = hydration.backingField.joinToString(separator = ".") + val bf = hydration.pathToBackingField.joinToString(separator = ".") val an = hydrationArgument.name "Field $vf tried to hydrate Query.$bf but gave invalid static value for argument $an" } diff --git a/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationInterimResult.kt b/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationInterimResult.kt index 0263e75a6..96e5f287a 100644 --- a/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationInterimResult.kt +++ b/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationInterimResult.kt @@ -10,7 +10,7 @@ internal sealed interface NadelValidationInterimResult { ) : NadelValidationInterimResult { companion object { context(NadelValidationContext) - internal fun T.asInterimSuccess(): Success { + internal fun T.asInterimSuccess(): Success { return Success(this) } } diff --git a/lib/src/main/java/graphql/nadel/validation/NadelServiceSchemaElement.kt b/lib/src/main/java/graphql/nadel/validation/NadelServiceSchemaElement.kt index 54180caa2..40cac7a3c 100644 --- a/lib/src/main/java/graphql/nadel/validation/NadelServiceSchemaElement.kt +++ b/lib/src/main/java/graphql/nadel/validation/NadelServiceSchemaElement.kt @@ -70,6 +70,15 @@ sealed class NadelServiceSchemaElement { override val underlying: GraphQLNamedSchemaElement, ) : NadelServiceSchemaElement() + data class VirtualType( + override val service: Service, + override val overall: GraphQLNamedType, + /** + * In the case of virtual types, this is the backing type. + */ + override val underlying: GraphQLNamedType, + ) : Type() + companion object { fun from( service: Service, diff --git a/lib/src/main/java/graphql/nadel/validation/NadelTypeValidation.kt b/lib/src/main/java/graphql/nadel/validation/NadelTypeValidation.kt index 891ba83ca..91b7c68b3 100644 --- a/lib/src/main/java/graphql/nadel/validation/NadelTypeValidation.kt +++ b/lib/src/main/java/graphql/nadel/validation/NadelTypeValidation.kt @@ -1,10 +1,7 @@ package graphql.nadel.validation -import graphql.Scalars.GraphQLID -import graphql.Scalars.GraphQLString import graphql.language.UnionTypeDefinition import graphql.nadel.Service -import graphql.nadel.definition.virtualType.isVirtualType import graphql.nadel.engine.blueprint.NadelTypeRenameInstruction import graphql.nadel.engine.util.AnyNamedNode import graphql.nadel.engine.util.isExtensionDef @@ -12,24 +9,24 @@ import graphql.nadel.engine.util.operationTypes import graphql.nadel.validation.NadelSchemaValidationError.DuplicatedUnderlyingType import graphql.nadel.validation.NadelSchemaValidationError.IncompatibleType import graphql.nadel.validation.NadelSchemaValidationError.MissingUnderlyingType +import graphql.nadel.validation.hydration.NadelHydrationValidation import graphql.nadel.validation.util.NadelBuiltInTypes.allNadelBuiltInTypeNames +import graphql.nadel.validation.util.NadelReferencedType import graphql.nadel.validation.util.NadelSchemaUtil.getUnderlyingType -import graphql.nadel.validation.util.getReachableTypeNames -import graphql.schema.GraphQLDirectiveContainer -import graphql.schema.GraphQLImplementingType -import graphql.schema.GraphQLInterfaceType -import graphql.schema.GraphQLNamedOutputType +import graphql.nadel.validation.util.getReferencedTypeNames import graphql.schema.GraphQLNamedType import graphql.schema.GraphQLObjectType import graphql.schema.GraphQLUnionType internal class NadelTypeValidation { - private val fieldValidation = NadelFieldValidation(this) + private val hydrationValidation = NadelHydrationValidation() + private val fieldValidation = NadelFieldValidation(hydrationValidation) private val inputValidation = NadelInputValidation() private val unionValidation = NadelUnionValidation(this) private val enumValidation = NadelEnumValidation() private val interfaceValidation = NadelInterfaceValidation() private val namespaceValidation = NadelNamespaceValidation() + private val virtualTypeValidation = NadelVirtualTypeValidation(hydrationValidation) context(NadelValidationContext) fun validate( @@ -115,6 +112,9 @@ internal class NadelTypeValidation { is NadelServiceSchemaElement.Incompatible -> { IncompatibleType(schemaElement) } + is NadelServiceSchemaElement.VirtualType -> { + virtualTypeValidation.validate(schemaElement) + } } return results( @@ -146,67 +146,48 @@ internal class NadelTypeValidation { ) } - /** - * Answers whether `rhs` assignable to `lhs`? - * - * i.e. does the following compile - * - * ``` - * vol output: lhs = rhs - * ``` - * - * Note: this assumes both types are from the same schema. This does NOT - * deal with differences between overall and underlying schema. - */ - context(NadelValidationContext) - fun isAssignableTo(lhs: GraphQLNamedOutputType, rhs: GraphQLNamedOutputType): Boolean { - if (lhs.name == rhs.name) { - return true - } - if (lhs.name == GraphQLID.name && rhs.name == GraphQLString.name) { - return true - } - if (lhs is GraphQLInterfaceType && rhs is GraphQLImplementingType) { - return rhs.interfaces.contains(lhs) - } - return false - } - context(NadelValidationContext) private fun getServiceTypes( service: Service, ): Pair, NadelSchemaValidationResult> { val errors = mutableListOf() val hydrationUnions = getHydrationUnions(service) - val namesUsed = getTypeNamesUsed(service, externalTypes = hydrationUnions) + val referencedTypes = getReferencedTypes(service, externalTypes = hydrationUnions) fun addMissingUnderlyingTypeError(overallType: GraphQLNamedType) { errors.add(MissingUnderlyingType(service, overallType)) } - return namesUsed - .map { - engineSchema.typeMap[it]!! - } + return referencedTypes .filterNot { it.name in allNadelBuiltInTypeNames } - .mapNotNull { overallType -> - val underlyingType = getUnderlyingType(overallType, service) - - if (underlyingType == null) { - if ((overallType as? GraphQLDirectiveContainer)?.isVirtualType() == true) { - // Do nothing - } else { - addMissingUnderlyingTypeError(overallType) + .mapNotNull { referencedType -> + when (referencedType) { + is NadelReferencedType.OrdinaryType -> { + val overallType = engineSchema.typeMap[referencedType.name]!! + val underlyingType = getUnderlyingType(overallType, service) + + if (underlyingType == null) { + addMissingUnderlyingTypeError(overallType) + null + } else { + NadelServiceSchemaElement.from( + service = service, + overall = overallType, + underlying = underlyingType, + ) + } + } + is NadelReferencedType.VirtualType -> { + val virtualType = engineSchema.typeMap[referencedType.name]!! + val backingType = engineSchema.typeMap[referencedType.backingType]!! + NadelServiceSchemaElement.VirtualType( + service = service, + overall = virtualType, + underlying = backingType, + ) } - null - } else { - NadelServiceSchemaElement.from( - service = service, - overall = overallType, - underlying = underlyingType, - ) } } .toList() @@ -239,7 +220,10 @@ internal class NadelTypeValidation { } context(NadelValidationContext) - private fun getTypeNamesUsed(service: Service, externalTypes: List): Set { + private fun getReferencedTypes( + service: Service, + externalTypes: List, + ): Set { // There is no shared service to validate. // These shared types are USED in other services. When they are used, the validation // will validate that the service has a compatible underlying type. @@ -281,7 +265,7 @@ internal class NadelTypeValidation { } // If it can be reached by using your service, you must own it to return it! - return getReachableTypeNames(service, definitionNames + matchingImplementsNames) + return getReferencedTypeNames(service, definitionNames + matchingImplementsNames) } context(NadelValidationContext) diff --git a/lib/src/main/java/graphql/nadel/validation/NadelVirtualTypeValidation.kt b/lib/src/main/java/graphql/nadel/validation/NadelVirtualTypeValidation.kt new file mode 100644 index 000000000..2095a0a61 --- /dev/null +++ b/lib/src/main/java/graphql/nadel/validation/NadelVirtualTypeValidation.kt @@ -0,0 +1,268 @@ +package graphql.nadel.validation + +import graphql.nadel.Service +import graphql.nadel.definition.hydration.isHydrated +import graphql.nadel.definition.renamed.isRenamed +import graphql.nadel.definition.virtualType.isVirtualType +import graphql.nadel.engine.util.unwrapAll +import graphql.nadel.validation.NadelTypeWrappingValidation.Rule.LHS_MUST_BE_STRICTER_OR_SAME +import graphql.nadel.validation.hydration.NadelHydrationValidation +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLObjectType + +private class NadelVirtualTypeValidationContext { + private val visited: MutableSet> = mutableSetOf() + + /** + * @return true to visit the element, false to abort + */ + fun visit(element: NadelServiceSchemaElement.VirtualType): Boolean { + return visited.add(element.overall.name to element.underlying.name) + } +} + +internal class NadelVirtualTypeValidation( + private val hydrationValidation: NadelHydrationValidation, +) { + private val typeWrappingValidation = NadelTypeWrappingValidation() + + context(NadelValidationContext) + fun validate( + schemaElement: NadelServiceSchemaElement.VirtualType, + ): NadelSchemaValidationResult { + with(NadelVirtualTypeValidationContext()) { + return validate(schemaElement) + } + } + + context(NadelValidationContext, NadelVirtualTypeValidationContext) + fun validate( + schemaElement: NadelServiceSchemaElement.VirtualType, + ): NadelSchemaValidationResult { + if (!visit(schemaElement)) { + return ok() + } + + if (schemaElement.overall is GraphQLObjectType && schemaElement.underlying is GraphQLObjectType) { + return validateType( + service = schemaElement.service, + virtualType = schemaElement.overall, + backingType = schemaElement.underlying, + ) + } + + return NadelInvalidVirtualTypeError(schemaElement) + } + + context(NadelValidationContext, NadelVirtualTypeValidationContext) + private fun validateType( + service: Service, + virtualType: GraphQLObjectType, + backingType: GraphQLObjectType, + ): NadelSchemaValidationResult { + return results( + validateFields(service, virtualType, backingType), + validateInterfaces(service, virtualType, backingType), + ) + } + + /** + * Renamed fields forbidden. + * + * Hydration fields must be valid. + */ + context(NadelValidationContext, NadelVirtualTypeValidationContext) + private fun validateFields( + service: Service, + virtualType: GraphQLObjectType, + backingType: GraphQLObjectType, + ): NadelSchemaValidationResult { + return virtualType.fields.map { virtualField -> + if (virtualField.isRenamed()) { + NadelVirtualTypeRenameFieldError( + type = NadelServiceSchemaElement.VirtualType( + service = service, + overall = virtualType, + underlying = backingType, + ), + virtualField = virtualField, + ) + } else if (virtualField.isHydrated()) { + hydrationValidation.validate( + parent = NadelServiceSchemaElement.Object( + service = service, + overall = virtualType, + backingType, + ), + overallField = virtualField, + ) + } else { + val backingField = backingType.getField(virtualField.name) + return if (backingField == null) { + NadelVirtualTypeUnexpectedFieldError( + type = NadelServiceSchemaElement.VirtualType( + service = service, + overall = virtualType, + underlying = backingType, + ), + virtualField = virtualField, + ) + } else { + validateVirtualField( + parent = NadelServiceSchemaElement.VirtualType( + service = service, + overall = virtualType, + underlying = backingType, + ), + virtualField = virtualField, + backingField = backingField, + ) + } + } + }.toResult() + } + + context(NadelValidationContext, NadelVirtualTypeValidationContext) + private fun validateVirtualField( + parent: NadelServiceSchemaElement.VirtualType, + virtualField: GraphQLFieldDefinition, + backingField: GraphQLFieldDefinition, + ): NadelSchemaValidationResult { + return results( + validateFieldArguments( + parent = parent, + virtualField = virtualField, + backingField = backingField, + ), + validateFieldOutputType( + parent = parent, + virtualField = virtualField, + backingField = backingField, + ), + ) + } + + /** + * Output type must be another valid virtual type, otherwise exactly the same. + */ + context(NadelValidationContext) + private fun validateFieldOutputType( + parent: NadelServiceSchemaElement.VirtualType, + virtualField: GraphQLFieldDefinition, + backingField: GraphQLFieldDefinition, + ): NadelSchemaValidationResult { + val virtualFieldOutputType = virtualField.type.unwrapAll() + val backingFieldOutputType = backingField.type.unwrapAll() + + if (virtualFieldOutputType.isVirtualType()) { + return validate( + NadelServiceSchemaElement.VirtualType( + service = parent.service, + overall = virtualFieldOutputType, + underlying = backingFieldOutputType, + ), + ) + } + + return if (virtualFieldOutputType.name == backingFieldOutputType.name) { + ok() + } else { + NadelVirtualTypeIncompatibleFieldOutputTypeError( + parent = parent, + virtualField = virtualField, + backingField = backingField, + ) + } + } + + /** + * Arguments can be omitted, but otherwise exactly the same. + */ + context(NadelValidationContext) + private fun validateFieldArguments( + parent: NadelServiceSchemaElement.VirtualType, + virtualField: GraphQLFieldDefinition, + backingField: GraphQLFieldDefinition, + ): NadelSchemaValidationResult { + return virtualField.arguments + .asSequence() + .map { virtualFieldArgument -> + val backingFieldArgument = backingField.getArgument(virtualFieldArgument.name) + if (backingFieldArgument == null) { + NadelVirtualTypeUnexpectedFieldArgumentError( + type = parent, + virtualField = virtualField, + backingField = backingField, + virtualFieldArgument = virtualFieldArgument, + ) + } else { + validateVirtualFieldArgument( + parent, + virtualField, + backingField, + virtualFieldArgument, + backingFieldArgument + ) + } + } + .toResult() + } + + /** + * Argument must be exactly the same. + */ + context(NadelValidationContext) + private fun validateVirtualFieldArgument( + parent: NadelServiceSchemaElement.VirtualType, + virtualField: GraphQLFieldDefinition, + backingField: GraphQLFieldDefinition, + virtualFieldArgument: GraphQLArgument, + backingFieldArgument: GraphQLArgument, + ): NadelSchemaValidationResult { + if (virtualFieldArgument.type.unwrapAll().name == backingFieldArgument.type.unwrapAll().name) { + val isTypeWrappingValid = typeWrappingValidation.isTypeWrappingValid( + lhs = virtualFieldArgument.type, + rhs = backingFieldArgument.type, + rule = LHS_MUST_BE_STRICTER_OR_SAME, + ) + + if (isTypeWrappingValid) { + return ok() + } + } + + return NadelVirtualTypeIncompatibleFieldArgumentError( + type = parent, + virtualField = virtualField, + backingField = backingField, + virtualFieldArgument = virtualFieldArgument, + backingFieldArgument = backingFieldArgument, + ) + } + + /** + * Interfaces can be omitted, but otherwise exactly the same. + */ + context(NadelValidationContext) + private fun validateInterfaces( + service: Service, + virtualType: GraphQLObjectType, + backingType: GraphQLObjectType, + ): NadelSchemaValidationResult { + return virtualType.interfaces.asSequence().map { virtualTypeInterface -> + if (backingType.interfaces.contains(virtualTypeInterface)) { + ok() + } else { + NadelVirtualTypeUnexpectedInterfaceError( + type = NadelServiceSchemaElement.VirtualType( + service = service, + overall = virtualType, + underlying = backingType, + ), + virtualFieldInterface = virtualTypeInterface, + ) + } + }.toResult() + } +} diff --git a/lib/src/main/java/graphql/nadel/validation/NadelVirtualTypeValidationError.kt b/lib/src/main/java/graphql/nadel/validation/NadelVirtualTypeValidationError.kt new file mode 100644 index 000000000..c93b6f2de --- /dev/null +++ b/lib/src/main/java/graphql/nadel/validation/NadelVirtualTypeValidationError.kt @@ -0,0 +1,81 @@ +package graphql.nadel.validation + +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLNamedOutputType +import graphql.schema.GraphQLNamedSchemaElement + +data class NadelInvalidVirtualTypeError( + val type: NadelServiceSchemaElement.VirtualType, +) : NadelSchemaValidationError { + override val message: String = "Virtual type must be an object type" + + override val subject: GraphQLNamedSchemaElement + get() = type.overall +} + +data class NadelVirtualTypeUnexpectedFieldError( + val type: NadelServiceSchemaElement.VirtualType, + val virtualField: GraphQLFieldDefinition, +) : NadelSchemaValidationError { + override val message: String = "Virtual type declares field that does not exist in backing type" + + override val subject: GraphQLNamedSchemaElement + get() = type.overall +} + +data class NadelVirtualTypeUnexpectedFieldArgumentError( + val type: NadelServiceSchemaElement.VirtualType, + val virtualField: GraphQLFieldDefinition, + val backingField: GraphQLFieldDefinition, + val virtualFieldArgument: GraphQLArgument, +) : NadelSchemaValidationError { + override val message: String = "Virtual type declares field that does not exist in backing type" + + override val subject: GraphQLNamedSchemaElement + get() = type.overall +} + +data class NadelVirtualTypeIncompatibleFieldArgumentError( + val type: NadelServiceSchemaElement.VirtualType, + val virtualField: GraphQLFieldDefinition, + val backingField: GraphQLFieldDefinition, + val virtualFieldArgument: GraphQLArgument, + val backingFieldArgument: GraphQLArgument, +) : NadelSchemaValidationError { + override val message: String = "Virtual type declares field that does not exist in backing type" + + override val subject: GraphQLNamedSchemaElement + get() = type.overall +} + +data class NadelVirtualTypeRenameFieldError( + val type: NadelServiceSchemaElement.VirtualType, + val virtualField: GraphQLFieldDefinition, +) : NadelSchemaValidationError { + override val message: String = "Virtual type declares @renamed field" + + override val subject: GraphQLNamedSchemaElement + get() = type.overall +} + +data class NadelVirtualTypeUnexpectedInterfaceError( + val type: NadelServiceSchemaElement.VirtualType, + val virtualFieldInterface: GraphQLNamedOutputType, +) : NadelSchemaValidationError { + override val message: String = "Virtual type implements interface that does not exist in backing type" + + override val subject: GraphQLNamedSchemaElement + get() = type.overall +} + +data class NadelVirtualTypeIncompatibleFieldOutputTypeError( + val parent: NadelServiceSchemaElement.VirtualType, + val virtualField: GraphQLFieldDefinition, + val backingField: GraphQLFieldDefinition, +) : NadelSchemaValidationError { + override val message: String = "Virtual field output type does not match backing field's output type" + + override val subject: GraphQLNamedSchemaElement + get() = parent.overall +} diff --git a/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationConditionValidation.kt b/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationConditionValidation.kt index 2bfd3b539..37b5aa985 100644 --- a/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationConditionValidation.kt +++ b/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationConditionValidation.kt @@ -138,7 +138,7 @@ internal class NadelHydrationConditionValidation { private fun isConditionFieldSameAsBatchId( pathToConditionField: List, ): Boolean { - val backingField = engineSchema.queryType.getFieldAt(hydration.backingField)!! + val backingField = engineSchema.queryType.getFieldAt(hydration.pathToBackingField)!! // Not batch hydration if (!backingField.type.unwrapNonNull().isList) { diff --git a/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationValidation.kt b/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationValidation.kt index 4d78d5478..d3b2d9f18 100644 --- a/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationValidation.kt +++ b/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationValidation.kt @@ -1,5 +1,7 @@ package graphql.nadel.validation.hydration +import graphql.Scalars.GraphQLID +import graphql.Scalars.GraphQLString import graphql.nadel.Service import graphql.nadel.definition.hydration.NadelBatchObjectIdentifiedByDefinition import graphql.nadel.definition.hydration.NadelHydrationArgumentDefinition @@ -41,7 +43,6 @@ import graphql.nadel.validation.NadelPolymorphicHydrationMustOutputUnionError import graphql.nadel.validation.NadelSchemaValidationError.CannotRenameHydratedField import graphql.nadel.validation.NadelSchemaValidationResult import graphql.nadel.validation.NadelServiceSchemaElement -import graphql.nadel.validation.NadelTypeValidation import graphql.nadel.validation.NadelValidatedFieldResult import graphql.nadel.validation.NadelValidationContext import graphql.nadel.validation.NadelValidationInterimResult @@ -54,6 +55,7 @@ import graphql.nadel.validation.toResult import graphql.schema.GraphQLDirectiveContainer import graphql.schema.GraphQLFieldDefinition import graphql.schema.GraphQLFieldsContainer +import graphql.schema.GraphQLImplementingType import graphql.schema.GraphQLInterfaceType import graphql.schema.GraphQLNamedOutputType import graphql.schema.GraphQLObjectType @@ -69,12 +71,12 @@ internal data class NadelHydrationValidationContext( val backingField: GraphQLFieldDefinition, ) -internal class NadelHydrationValidation( - private val typeValidation: NadelTypeValidation, - private val argumentValidation: NadelHydrationArgumentValidation = NadelHydrationArgumentValidation(), - private val conditionValidation: NadelHydrationConditionValidation = NadelHydrationConditionValidation(), - private val sourceFieldValidation: NadelHydrationSourceFieldValidation = NadelHydrationSourceFieldValidation(), -) { +internal class NadelHydrationValidation { + private val argumentValidation = NadelHydrationArgumentValidation() + private val conditionValidation = NadelHydrationConditionValidation() + private val sourceFieldValidation = NadelHydrationSourceFieldValidation() + private val virtualTypeValidation = NadelHydrationVirtualTypeValidation() + context(NadelValidationContext) fun validate( parent: NadelServiceSchemaElement.FieldsContainer, @@ -190,6 +192,8 @@ internal class NadelHydrationValidation( .onError { return it } val sourceFields = sourceFieldValidation.getSourceFields(arguments, hydrationCondition) .onError { return it } + val virtualTypeContext = virtualTypeValidation.getVirtualTypeContext() + .onError { return it } return NadelValidatedFieldResult( service = parent.service, @@ -197,15 +201,14 @@ internal class NadelHydrationValidation( location = makeFieldCoordinates(parent.overall.name, virtualField.name), virtualFieldDef = virtualField, backingService = backingService, - queryPathToBackingField = NadelQueryPath(hydrationDefinition.backingField), + queryPathToBackingField = NadelQueryPath(hydrationDefinition.pathToBackingField), backingFieldArguments = arguments, timeout = hydrationDefinition.timeout, sourceFields = sourceFields, backingFieldDef = backingField, backingFieldContainer = backingFieldContainer, condition = hydrationCondition, - virtualTypeContext = null, - // todo: code properly + virtualTypeContext = virtualTypeContext, hydrationStrategy = hydrationStrategy, ), ) @@ -274,7 +277,7 @@ internal class NadelHydrationValidation( location = makeFieldCoordinates(parent.overall.name, virtualField.name), virtualFieldDef = virtualField, backingService = backingService, - queryPathToBackingField = NadelQueryPath(hydrationDefinition.backingField), + queryPathToBackingField = NadelQueryPath(hydrationDefinition.pathToBackingField), backingFieldArguments = arguments, timeout = hydrationDefinition.timeout, sourceFields = sourceFields, @@ -293,7 +296,7 @@ internal class NadelHydrationValidation( virtualField: GraphQLFieldDefinition, hydration: NadelHydrationDefinition, ): NadelValidationInterimResult { - return engineSchema.queryType.getFieldContainerFor(hydration.backingField)?.asInterimSuccess() + return engineSchema.queryType.getFieldContainerFor(hydration.pathToBackingField)?.asInterimSuccess() ?: return NadelHydrationReferencesNonExistentBackingFieldError( parentType = parent, virtualField = virtualField, @@ -307,7 +310,7 @@ internal class NadelHydrationValidation( virtualField: GraphQLFieldDefinition, hydration: NadelHydrationDefinition, ): NadelValidationInterimResult { - return engineSchema.queryType.getFieldAt(hydration.backingField)?.asInterimSuccess() + return engineSchema.queryType.getFieldAt(hydration.pathToBackingField)?.asInterimSuccess() ?: return NadelHydrationReferencesNonExistentBackingFieldError( parentType = parent, virtualField = virtualField, @@ -526,7 +529,7 @@ internal class NadelHydrationValidation( context(NadelValidationContext) private fun isBatchHydration(hydrationDefinition: NadelHydrationDefinition): Boolean { - val backingFieldDef = engineSchema.queryType.getFieldAt(hydrationDefinition.backingField) + val backingFieldDef = engineSchema.queryType.getFieldAt(hydrationDefinition.pathToBackingField) ?: return false // Error handled elsewhere val hasBatchedArgument = hydrationDefinition.arguments @@ -571,7 +574,7 @@ internal class NadelHydrationValidation( // Find incompatible output types .filter { backingOutputType -> acceptableOutputTypes.none { acceptableOutputType -> - typeValidation.isAssignableTo(lhs = acceptableOutputType, rhs = backingOutputType) + isAssignableTo(lhs = acceptableOutputType, rhs = backingOutputType) } } .map { backingOutputType -> @@ -591,4 +594,30 @@ internal class NadelHydrationValidation( return ok() } + + /** + * Answers whether `rhs` assignable to `lhs`? + * + * i.e. does the following compile + * + * ``` + * vol output: lhs = rhs + * ``` + * + * Note: this assumes both types are from the same schema. This does NOT + * deal with differences between overall and underlying schema. + */ + context(NadelValidationContext) + private fun isAssignableTo(lhs: GraphQLNamedOutputType, rhs: GraphQLNamedOutputType): Boolean { + if (lhs.name == rhs.name) { + return true + } + if (lhs.name == GraphQLID.name && rhs.name == GraphQLString.name) { + return true + } + if (lhs is GraphQLInterfaceType && rhs is GraphQLImplementingType) { + return rhs.interfaces.contains(lhs) + } + return false + } } diff --git a/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationVirtualTypeValidation.kt b/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationVirtualTypeValidation.kt new file mode 100644 index 000000000..9dad73b55 --- /dev/null +++ b/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationVirtualTypeValidation.kt @@ -0,0 +1,120 @@ +package graphql.nadel.validation.hydration + +import graphql.nadel.definition.hydration.isHydrated +import graphql.nadel.definition.virtualType.isVirtualType +import graphql.nadel.engine.blueprint.NadelVirtualTypeContext +import graphql.nadel.engine.util.unwrapAll +import graphql.nadel.validation.NadelValidationContext +import graphql.nadel.validation.NadelValidationInterimResult +import graphql.nadel.validation.NadelValidationInterimResult.Success.Companion.asInterimSuccess +import graphql.nadel.validation.onError +import graphql.nadel.validation.onErrorCast +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLFieldsContainer +import graphql.schema.GraphQLNamedType + +private data class NadelVirtualTypeMapping( + val virtualType: GraphQLNamedType, + val backingType: GraphQLNamedType, +) + +private class NadelHydrationVirtualTypeValidationContext { + private val visited = mutableSetOf>() + + /** + * @return true to continue + */ + fun visit(mapping: NadelVirtualTypeMapping): Boolean { + val reference = mapping.virtualType.name to mapping.backingType.name + return visited.add(reference) + } +} + +/** + * This only generates a [NadelVirtualTypeContext] assuming a valid virtual type. + * + * A virtual types are validated in [graphql.nadel.validation.NadelVirtualTypeValidation] + */ +internal class NadelHydrationVirtualTypeValidation { + private val emptyMapping = emptyList() + + context(NadelValidationContext, NadelHydrationValidationContext) + fun getVirtualTypeContext(): NadelValidationInterimResult { + // Do nothing if it's not a virtual type + if (!virtualField.type.unwrapAll().isVirtualType()) { + return null.asInterimSuccess() + } + + val context = NadelHydrationVirtualTypeValidationContext() + val typeMappings = with(context) { + getVirtualTypeMapping( + virtualField = virtualField, + backingField = backingField, + ).onErrorCast { return it } + } + + return NadelVirtualTypeContext( + virtualFieldContainer = parent.overall, + virtualField = virtualField, + virtualTypeToBackingType = typeMappings.associate { + it.virtualType.name to it.backingType.name + }, + backingTypeToVirtualType = typeMappings.associate { + it.backingType.name to it.virtualType.name + }, + ).asInterimSuccess() + } + + context(NadelValidationContext, NadelHydrationVirtualTypeValidationContext) + private fun getVirtualTypeMapping( + virtualField: GraphQLFieldDefinition, + backingField: GraphQLFieldDefinition, + ): NadelValidationInterimResult> { + val virtualFieldOutputType = virtualField.type.unwrapAll() + val backingFieldOutputType = backingField.type.unwrapAll() + + val mapping = NadelVirtualTypeMapping( + virtualType = virtualFieldOutputType, + backingType = backingFieldOutputType, + ) + + if (!visit(mapping)) { + return emptyMapping.asInterimSuccess() + } + + val childMappings = getVirtualTypeMappings( + virtualFieldOutputType, + backingFieldOutputType, + ).onError { return it } + + return (listOf(mapping) + childMappings).asInterimSuccess() + } + + context(NadelValidationContext, NadelHydrationVirtualTypeValidationContext) + private fun getVirtualTypeMappings( + virtualObjectType: GraphQLNamedType, + backingObjectType: GraphQLNamedType, + ): NadelValidationInterimResult> { + if (virtualObjectType is GraphQLFieldsContainer && backingObjectType is GraphQLFieldsContainer) { + return virtualObjectType.fields + .flatMap { virtualField -> + if (virtualField.isHydrated()) { + emptyMapping + } else { + val backingField = backingObjectType.getField(virtualField.name) + if (backingField == null) { + emptyMapping + } else { + getVirtualTypeMapping( + virtualField = virtualField, + backingField = backingField, + ).onError { return it } + } + } + } + .asInterimSuccess() + } + + return emptyMapping.asInterimSuccess() + } +} diff --git a/lib/src/main/java/graphql/nadel/validation/util/NadelGetReachableTypes.kt b/lib/src/main/java/graphql/nadel/validation/util/NadelGetReachableTypes.kt index e64f7c4e0..81b2d5124 100644 --- a/lib/src/main/java/graphql/nadel/validation/util/NadelGetReachableTypes.kt +++ b/lib/src/main/java/graphql/nadel/validation/util/NadelGetReachableTypes.kt @@ -2,9 +2,11 @@ package graphql.nadel.validation.util import graphql.language.UnionTypeDefinition import graphql.nadel.Service +import graphql.nadel.definition.hydration.getHydrationDefinitions import graphql.nadel.definition.hydration.isHydrated import graphql.nadel.definition.virtualType.isVirtualType import graphql.nadel.engine.blueprint.NadelFastSchemaTraverser +import graphql.nadel.engine.util.getFieldAt import graphql.nadel.engine.util.makeFieldCoordinates import graphql.nadel.engine.util.unwrapAll import graphql.nadel.validation.NadelValidationContext @@ -35,246 +37,291 @@ import graphql.schema.GraphQLTypeReference import graphql.schema.GraphQLUnionType import graphql.schema.GraphQLUnmodifiedType +internal sealed class NadelReferencedType { + abstract val name: String + + data class OrdinaryType( + override val name: String, + ) : NadelReferencedType() + + data class VirtualType( + override val name: String, + val backingType: String, + ) : NadelReferencedType() +} + context(NadelValidationContext) -internal fun getReachableTypeNames( +internal fun getReferencedTypeNames( service: Service, definitionNames: List, -): Set { - // We need a mutable Set so we can keep track of what types we've visited to avoid StackOverflows - val reachableTypeNames = mutableSetOf() - - // We keep these so we can add breakpoints to see where a type came from - fun add(name: String) = reachableTypeNames.add(name) - fun addAll(elements: Collection) = reachableTypeNames.addAll(elements) - - val traverser = object : NadelFastSchemaTraverser.Visitor { - override fun visitGraphQLArgument( - parent: GraphQLNamedSchemaElement?, - node: GraphQLArgument, - ): Boolean { - add(node.type.unwrapAll().name) - return true - } +): Set { + val referencedTypes = mutableSetOf() - override fun visitGraphQLUnionType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLUnionType, - ): Boolean { - visitTypeGuard(node) { return false } - add(node.name) - return true - } + val traverser = NadelReferencedTypeVisitor(service) { reference -> + referencedTypes.add(reference) + } - override fun visitGraphQLInterfaceType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLInterfaceType, - ): Boolean { - visitTypeGuard(node) { return false } - add(node.name) - return true - } + NadelFastSchemaTraverser().traverse( + schema = engineSchema, + roots = definitionNames, + visitor = traverser, + ) - override fun visitGraphQLEnumType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLEnumType, - ): Boolean { - visitTypeGuard(node) { return false } - add(node.name) - return true - } + return referencedTypes +} - override fun visitGraphQLEnumValueDefinition( - parent: GraphQLNamedSchemaElement?, - node: GraphQLEnumValueDefinition, - ): Boolean { - return true - } +context(NadelValidationContext) +private class NadelReferencedTypeVisitor( + private val service: Service, + private val onTypeReferenced: (NadelReferencedType) -> Unit, +) : NadelFastSchemaTraverser.Visitor { + fun onTypeReferenced(name: String) { + onTypeReferenced(NadelReferencedType.OrdinaryType(name)) + } - override fun visitGraphQLFieldDefinition( - parent: GraphQLNamedSchemaElement?, - node: GraphQLFieldDefinition, - ): Boolean { - val parentNode = parent as GraphQLFieldsContainer - - // Don't look at fields contributed by other services - if (parentNode.name in combinedTypeNames) { - if (service.name != fieldContributor[makeFieldCoordinates(parentNode.name, node.name)]!!.name) { - return false - } - } + fun onVirtualTypeReferenced(virtualType: String, backingType: String) { + onTypeReferenced(NadelReferencedType.VirtualType(virtualType, backingType)) + } - return !node.isHydrated() - } + override fun visitGraphQLArgument( + parent: GraphQLNamedSchemaElement?, + node: GraphQLArgument, + ): Boolean { + onTypeReferenced(node.type.unwrapAll().name) + return true + } - override fun visitGraphQLInputObjectField( - parent: GraphQLNamedSchemaElement?, - node: GraphQLInputObjectField, - ): Boolean { - add(node.type.unwrapAll().name) - return true - } + override fun visitGraphQLUnionType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLUnionType, + ): Boolean { + visitTypeGuard(node) { return false } + onTypeReferenced(node.name) + return true + } - override fun visitGraphQLInputObjectType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLInputObjectType, - ): Boolean { - visitTypeGuard(node) { return false } - add(node.name) - return true - } + override fun visitGraphQLInterfaceType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLInterfaceType, + ): Boolean { + visitTypeGuard(node) { return false } + onTypeReferenced(node.name) + return true + } - override fun visitGraphQLList( - parent: GraphQLNamedSchemaElement?, - node: GraphQLList, - ): Boolean { - add(node.unwrapAll().name) - return true - } + override fun visitGraphQLEnumType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLEnumType, + ): Boolean { + visitTypeGuard(node) { return false } + onTypeReferenced(node.name) + return true + } - override fun visitGraphQLNonNull( - parent: GraphQLNamedSchemaElement?, - node: GraphQLNonNull, - ): Boolean { - add(node.unwrapAll().name) - return true - } + override fun visitGraphQLEnumValueDefinition( + parent: GraphQLNamedSchemaElement?, + node: GraphQLEnumValueDefinition, + ): Boolean { + return true + } - override fun visitGraphQLObjectType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLObjectType, - ): Boolean { - visitTypeGuard(node) { return false } + // todo: does this validate type extensions properly? + override fun visitGraphQLFieldDefinition( + parent: GraphQLNamedSchemaElement?, + node: GraphQLFieldDefinition, + ): Boolean { + val parentNode = parent as GraphQLFieldsContainer - // Don't look at union members defined by external services - if (parent is GraphQLUnionType && isUnionMemberExempt(service, parent, node)) { + // Don't look at fields contributed by other services + if (parentNode.name in combinedTypeNames) { + if (service.name != fieldContributor[makeFieldCoordinates(parentNode.name, node.name)]!!.name) { return false } - - add(node.name) - return true } - override fun visitGraphQLScalarType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLScalarType, - ): Boolean { - visitTypeGuard(node) { return false } - add(node.name) - return true + return if (node.isHydrated()) { + visitHydratedFieldDefinition(node) + // Never continue traversing on a hydrated field, we have special handling for that in visitHydratedFieldDefinition + false + } else { + true } + } - override fun visitGraphQLTypeReference( - parent: GraphQLNamedSchemaElement?, - node: GraphQLTypeReference, - ): Boolean { - add(node.name) - return true + private fun visitHydratedFieldDefinition(field: GraphQLFieldDefinition) { + val outputType = field.type.unwrapAll() + if (outputType.isVirtualType()) { + for (hydration in field.getHydrationDefinitions()) { + val backingField = engineSchema.queryType.getFieldAt(hydration.pathToBackingField) + ?: continue // Error will be handled elsewhere down the line + onVirtualTypeReferenced( + virtualType = outputType.name, + backingType = backingField.type.unwrapAll().name, + ) + } } + } - override fun visitGraphQLModifiedType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLModifiedType, - ): Boolean { - add(node.unwrapAll().name) - return true - } + override fun visitGraphQLInputObjectField( + parent: GraphQLNamedSchemaElement?, + node: GraphQLInputObjectField, + ): Boolean { + onTypeReferenced(node.type.unwrapAll().name) + return true + } - override fun visitGraphQLCompositeType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLCompositeType, - ): Boolean { - add(node.name) - return true - } + override fun visitGraphQLInputObjectType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLInputObjectType, + ): Boolean { + visitTypeGuard(node) { return false } + onTypeReferenced(node.name) + return true + } - override fun visitGraphQLDirective( - parent: GraphQLNamedSchemaElement?, - node: GraphQLDirective, - ): Boolean { - // We don't care about directives, not a Nadel runtime concern - // GraphQL Java will do validation on them for us + override fun visitGraphQLList( + parent: GraphQLNamedSchemaElement?, + node: GraphQLList, + ): Boolean { + onTypeReferenced(node.unwrapAll().name) + return true + } + + override fun visitGraphQLNonNull( + parent: GraphQLNamedSchemaElement?, + node: GraphQLNonNull, + ): Boolean { + onTypeReferenced(node.unwrapAll().name) + return true + } + + override fun visitGraphQLObjectType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLObjectType, + ): Boolean { + visitTypeGuard(node) { return false } + + // Don't look at union members defined by external services + if (parent is GraphQLUnionType && isUnionMemberExempt(service, parent, node)) { return false } - override fun visitGraphQLDirectiveContainer( - parent: GraphQLNamedSchemaElement?, - node: GraphQLDirectiveContainer, - ): Boolean { - add(node.name) - return true - } + onTypeReferenced(node.name) + return true + } - override fun visitGraphQLFieldsContainer( - parent: GraphQLNamedSchemaElement?, - node: GraphQLFieldsContainer, - ): Boolean { - add(node.name) - return true - } + override fun visitGraphQLScalarType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLScalarType, + ): Boolean { + visitTypeGuard(node) { return false } + onTypeReferenced(node.name) + return true + } - override fun visitGraphQLInputFieldsContainer( - parent: GraphQLNamedSchemaElement?, - node: GraphQLInputFieldsContainer, - ): Boolean { - add(node.name) - return true - } + override fun visitGraphQLTypeReference( + parent: GraphQLNamedSchemaElement?, + node: GraphQLTypeReference, + ): Boolean { + onTypeReferenced(node.name) + return true + } - override fun visitGraphQLNullableType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLNullableType, - ): Boolean { - add(node.unwrapAll().name) - return true - } + override fun visitGraphQLModifiedType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLModifiedType, + ): Boolean { + onTypeReferenced(node.unwrapAll().name) + return true + } - override fun visitGraphQLOutputType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLOutputType, - ): Boolean { - add(node.unwrapAll().name) - return true - } + override fun visitGraphQLCompositeType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLCompositeType, + ): Boolean { + onTypeReferenced(node.name) + return true + } - override fun visitGraphQLUnmodifiedType( - parent: GraphQLNamedSchemaElement?, - node: GraphQLUnmodifiedType, - ): Boolean { - add(node.name) - return true - } + override fun visitGraphQLDirective( + parent: GraphQLNamedSchemaElement?, + node: GraphQLDirective, + ): Boolean { + // We don't care about directives, not a Nadel runtime concern + // GraphQL Java will do validation on them for us + return false + } - override fun visitGraphQLAppliedDirective( - parent: GraphQLNamedSchemaElement?, - node: GraphQLAppliedDirective, - ): Boolean { - // Don't look into applied directives. Could be a shared directive. - // As long as the schema compiled then we don't care. - return false - } + override fun visitGraphQLDirectiveContainer( + parent: GraphQLNamedSchemaElement?, + node: GraphQLDirectiveContainer, + ): Boolean { + onTypeReferenced(node.name) + return true + } - /** - * Call to ensure the given [type] is not traversed if it shouldn't be. - * - * The [onExit] lambda is not intended to return, so it is typed to [Nothing] - * i.e. use [onExit] to actually exit the outer function to escape the lambda - */ - private inline fun visitTypeGuard(type: GraphQLNamedType, onExit: () -> Nothing) { - if (type is GraphQLDirectiveContainer) { - if (type.isVirtualType()) { - onExit() - } - } - } + override fun visitGraphQLFieldsContainer( + parent: GraphQLNamedSchemaElement?, + node: GraphQLFieldsContainer, + ): Boolean { + onTypeReferenced(node.name) + return true } - NadelFastSchemaTraverser().traverse( - schema = engineSchema, - roots = definitionNames, - visitor = traverser, - ) + override fun visitGraphQLInputFieldsContainer( + parent: GraphQLNamedSchemaElement?, + node: GraphQLInputFieldsContainer, + ): Boolean { + onTypeReferenced(node.name) + return true + } + + override fun visitGraphQLNullableType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLNullableType, + ): Boolean { + onTypeReferenced(node.unwrapAll().name) + return true + } - return reachableTypeNames + override fun visitGraphQLOutputType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLOutputType, + ): Boolean { + onTypeReferenced(node.unwrapAll().name) + return true + } + + override fun visitGraphQLUnmodifiedType( + parent: GraphQLNamedSchemaElement?, + node: GraphQLUnmodifiedType, + ): Boolean { + onTypeReferenced(node.name) + return true + } + + override fun visitGraphQLAppliedDirective( + parent: GraphQLNamedSchemaElement?, + node: GraphQLAppliedDirective, + ): Boolean { + // Don't look into applied directives. Could be a shared directive. + // As long as the schema compiled then we don't care. + return false + } + + /** + * Call to ensure the given [type] is not traversed if it shouldn't be. + * + * The [onExit] lambda is not intended to return, so it is typed to [Nothing] + * i.e. use [onExit] to actually exit the outer function to escape the lambda + */ + private inline fun visitTypeGuard(type: GraphQLNamedType, onExit: () -> Nothing) { + if (type is GraphQLDirectiveContainer) { + if (type.isVirtualType()) { + onExit() + } + } + } } internal fun isUnionMemberExempt( diff --git a/lib/src/test/kotlin/graphql/nadel/schema/NadelDirectivesTest.kt b/lib/src/test/kotlin/graphql/nadel/schema/NadelDirectivesTest.kt index 6f119337a..06e41533f 100644 --- a/lib/src/test/kotlin/graphql/nadel/schema/NadelDirectivesTest.kt +++ b/lib/src/test/kotlin/graphql/nadel/schema/NadelDirectivesTest.kt @@ -79,7 +79,7 @@ class NadelDirectivesTest : DescribeSpec({ val hydration = field.getHydrationDefinitions().single() // then - assert(hydration.backingField == listOf("jira", "issueById")) + assert(hydration.pathToBackingField == listOf("jira", "issueById")) assert(hydration.batchSize == 50) assert(hydration.timeout == 100) assert(hydration.arguments.size == 2) diff --git a/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationArgumentTypeValidationTest.kt b/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationArgumentTypeValidationTest.kt index 014f5f361..0615b06f1 100644 --- a/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationArgumentTypeValidationTest.kt +++ b/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationArgumentTypeValidationTest.kt @@ -84,7 +84,7 @@ class NadelHydrationArgumentTypeValidationTest { val error = errors.singleOfType() assertTrue(error.parentType.overall.name == "JiraIssue") assertTrue(error.virtualField.name == "related") - assertTrue(error.hydration.backingField == listOf("issueById")) + assertTrue(error.hydration.pathToBackingField == listOf("issueById")) assertTrue(error.hydrationArgument.name == "search") assertTrue(GraphQLTypeUtil.simplePrint(error.suppliedType) == sourceType.filter(Char::isLetter)) assertTrue(GraphQLTypeUtil.simplePrint(error.requiredType) == requiredType.filter(Char::isLetter)) @@ -422,7 +422,7 @@ class NadelHydrationArgumentTypeValidationTest { val error = errors.singleOfType() assertTrue(error.parentType.overall.name == "JiraIssue") assertTrue(error.virtualField.name == "related") - assertTrue(error.hydration.backingField == listOf("issueById")) + assertTrue(error.hydration.pathToBackingField == listOf("issueById")) assertTrue(error.missingBackingArgument.name == "id") } @@ -635,7 +635,7 @@ class NadelHydrationArgumentTypeValidationTest { val error = errors.singleOfType() assertTrue(error.parentType.overall.name == "JiraIssue") assertTrue(error.virtualField.name == "related") - assertTrue(error.hydration.backingField == listOf("issueById")) + assertTrue(error.hydration.pathToBackingField == listOf("issueById")) assertTrue(error.hydrationArgument.name == "ids") assertTrue(GraphQLTypeUtil.simplePrint(error.suppliedType) == sourceType.filter(Char::isLetter)) assertTrue(GraphQLTypeUtil.simplePrint(error.requiredType) == requiredType.filter(Char::isLetter)) diff --git a/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationValidationTest.kt b/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationValidationTest.kt index e2d9d1b1c..d7fb2a372 100644 --- a/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationValidationTest.kt +++ b/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationValidationTest.kt @@ -522,7 +522,7 @@ class NadelHydrationValidationTest { assert(error.parentType.overall.name == "Issue") assert(error.parentType.underlying.name == "Issue") assert(error.virtualField.name == "creator") - assert(error.hydration.backingField == listOf("userById")) + assert(error.hydration.pathToBackingField == listOf("userById")) assert(error.virtualField == error.subject) } @@ -582,7 +582,7 @@ class NadelHydrationValidationTest { assert(error.parentType.overall.name == "Issue") assert(error.parentType.underlying.name == "Issue") assert(error.virtualField.name == "creator") - assert(error.hydration.backingField == listOf("user")) + assert(error.hydration.pathToBackingField == listOf("user")) assert(error.argument.pathToField == listOf("creatorId")) } @@ -647,7 +647,7 @@ class NadelHydrationValidationTest { assert(error.parentType.overall.name == "Issue") assert(error.parentType.underlying.name == "Issue") assert(error.virtualField.name == "creator") - assert(error.hydration.backingField == listOf("user")) + assert(error.hydration.pathToBackingField == listOf("user")) assert(error.argument.argumentName == "secrets") } @@ -712,7 +712,7 @@ class NadelHydrationValidationTest { assert(error.parentType.overall.name == "Issue") assert(error.parentType.underlying.name == "Issue") assert(error.virtualField.name == "creator") - assert(error.hydration.backingField == listOf("user")) + assert(error.hydration.pathToBackingField == listOf("user")) assert(error.argument == "someArg") } @@ -778,7 +778,7 @@ class NadelHydrationValidationTest { assert(error.parentType.overall.name == "Issue") assert(error.parentType.underlying.name == "Issue") assert(error.virtualField.name == "creator") - assert(error.hydration.backingField == listOf("user")) + assert(error.hydration.pathToBackingField == listOf("user")) assert(error.duplicates.map { it.name }.toSet() == setOf("id")) } diff --git a/lib/src/test/kotlin/graphql/nadel/validation/NadelPolymorphicHydrationValidationTest.kt b/lib/src/test/kotlin/graphql/nadel/validation/NadelPolymorphicHydrationValidationTest.kt index 5c5785020..81391b82f 100644 --- a/lib/src/test/kotlin/graphql/nadel/validation/NadelPolymorphicHydrationValidationTest.kt +++ b/lib/src/test/kotlin/graphql/nadel/validation/NadelPolymorphicHydrationValidationTest.kt @@ -181,7 +181,7 @@ class NadelPolymorphicHydrationValidationTest : DescribeSpec({ assert(error.service.name == "issues") assert(error.parentType.overall.name == "Issue") assert(error.virtualField.name == "creator") - assert(error.hydration.backingField == listOf("internalUser")) + assert(error.hydration.pathToBackingField == listOf("internalUser")) } it("fails if a mix of batched and non-batched hydrations is used") {