diff --git a/lib/src/main/java/graphql/nadel/validation/NadelHydrationValidation.kt b/lib/src/main/java/graphql/nadel/validation/NadelHydrationValidation.kt index 8463e4bb3..79aa90969 100644 --- a/lib/src/main/java/graphql/nadel/validation/NadelHydrationValidation.kt +++ b/lib/src/main/java/graphql/nadel/validation/NadelHydrationValidation.kt @@ -80,6 +80,7 @@ internal class NadelHydrationValidation( } val indexHydrationErrors = limitUseOfIndexHydration(parent, overallField, hydrations) + val sourceFieldErrors = limitSourceField(parent, overallField, hydrations) if (hasMoreThanOneHydration) { val (batched, notBatched) = hydrations.partition(::isBatched) @@ -88,7 +89,47 @@ internal class NadelHydrationValidation( } } - return indexHydrationErrors + errors + return indexHydrationErrors + sourceFieldErrors + errors + } + + private fun limitSourceField( + parent: NadelServiceSchemaElement, + overallField: GraphQLFieldDefinition, + hydrations: List, + ): List { + if (hydrations.size > 1) { + val hasListSourceInputField = hydrations + .any { hydration -> + val parentType = parent.underlying as GraphQLFieldsContainer + hydration + .arguments + .asSequence() + .mapNotNull { argument -> + argument.remoteArgumentSource.pathToField + } + .any { path -> + parentType.getFieldAt(path)?.type?.unwrapNonNull()?.isList == true + } + } + + if (hasListSourceInputField) { + val sourceFields = hydrations + .flatMapTo(LinkedHashSet()) { hydration -> + hydration.arguments + .mapNotNull { argument -> + argument.remoteArgumentSource.pathToField + } + } + + if (sourceFields.size > 1) { + return listOf( + NadelSchemaValidationError.MultipleHydrationSourceInputFields(parent, overallField), + ) + } + } + } + + return emptyList() } private fun limitUseOfIndexHydration( @@ -96,6 +137,7 @@ internal class NadelHydrationValidation( overallField: GraphQLFieldDefinition, hydrations: List, ): List { + // todo: or maybe just don't allow polymorphic index hydration val (indexCount, nonIndexCount) = hydrations.partitionCount { it.isObjectMatchByIndex } if (indexCount > 0 && nonIndexCount > 0) { return listOf( diff --git a/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationError.kt b/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationError.kt index d51332418..8f425e74d 100644 --- a/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationError.kt +++ b/lib/src/main/java/graphql/nadel/validation/NadelSchemaValidationError.kt @@ -400,6 +400,20 @@ sealed interface NadelSchemaValidationError { override val subject = overallField } + data class MultipleHydrationSourceInputFields( + val parentType: NadelServiceSchemaElement, + val overallField: GraphQLFieldDefinition, + ) : NadelSchemaValidationError { + val service: Service get() = parentType.service + + override val message = run { + val coords = makeFieldCoordinates(parentType.overall.name, overallField.name) + "Field $coords uses multiple \$source fields" + } + + override val subject = overallField + } + data class StaticArgIsNotAssignable( val parentType: NadelServiceSchemaElement, val overallField: GraphQLFieldDefinition, diff --git a/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationValidationTest2.kt b/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationValidationTest2.kt index 820278682..fa32fb850 100644 --- a/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationValidationTest2.kt +++ b/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationValidationTest2.kt @@ -81,4 +81,264 @@ class NadelHydrationValidationTest2 { assertTrue(errors.single() is NadelSchemaValidationError.MixedIndexHydration) assertTrue(errors.single().subject.name == "creator") } + + @Test + fun `prohibit multiple source input fields if they are list types`() { + val fixture = NadelValidationTestFixture( + overallSchema = mapOf( + "activity" to /* language=GraphQL*/ """ + type Query { + myActivity: [Activity] + } + union ActivityContent = User | Issue + type Activity { + id: ID! + data: [ActivityContent] + @hydrated( + service: "users" + field: "usersByIds" + arguments: [ + {name: "ids", value: "$source.userIds"} + ] + ) + @hydrated( + service: "issues" + field: "issuesByIds" + arguments: [ + {name: "ids", value: "$source.issueIds"} + ] + ) + } + """.trimIndent(), + "users" to /* language=GraphQL*/ """ + type Query { + usersByIds(ids: [ID]!): [User] + } + type User { + id: ID! + name: String! + } + """.trimIndent(), + "issues" to /* language=GraphQL*/ """ + type Query { + issuesByIds(ids: [ID]!): [Issue] + } + type Issue { + id: ID! + key: String + } + """.trimIndent(), + ), + underlyingSchema = mapOf( + "activity" to /* language=GraphQL*/ """ + type Query { + myActivity: [Activity] + } + type Activity { + id: ID! + userIds: [ID] + issueIds: [ID] + } + """.trimIndent(), + "users" to /* language=GraphQL*/ """ + type Query { + usersByIds(ids: [ID]!): [User] + } + type User { + id: ID! + name: String! + } + type Account { + id: ID! + } + """.trimIndent(), + "issues" to /* language=GraphQL*/ """ + type Query { + issuesByIds(ids: [ID]!): [Issue] + } + type Issue { + id: ID! + key: String + } + """.trimIndent(), + ), + ) + + val errors = validate(fixture) + assertTrue(errors.map { it.message }.isNotEmpty()) + assertTrue(errors.single() is NadelSchemaValidationError.MultipleHydrationSourceInputFields) + assertTrue(errors.single().subject.name == "data") + } + + @Test + fun `permit multiple source fields if source input field is not list type`() { + val fixture = NadelValidationTestFixture( + overallSchema = mapOf( + "activity" to /* language=GraphQL*/ """ + type Query { + myActivity: [Activity] + } + union ActivityContent = User | Issue + type Activity { + id: ID! + data: ActivityContent + @hydrated( + service: "users" + field: "usersByIds" + arguments: [ + {name: "ids", value: "$source.userId"} + ] + ) + @hydrated( + service: "issues" + field: "issuesByIds" + arguments: [ + {name: "ids", value: "$source.issueId"} + ] + ) + } + """.trimIndent(), + "users" to /* language=GraphQL*/ """ + type Query { + usersByIds(ids: [ID]!): [User] + } + type User { + id: ID! + name: String! + } + """.trimIndent(), + "issues" to /* language=GraphQL*/ """ + type Query { + issuesByIds(ids: [ID]!): [Issue] + } + type Issue { + id: ID! + key: String + } + """.trimIndent(), + ), + underlyingSchema = mapOf( + "activity" to /* language=GraphQL*/ """ + type Query { + myActivity: [Activity] + } + type Activity { + id: ID! + userId: ID + issueId: ID + } + """.trimIndent(), + "users" to /* language=GraphQL*/ """ + type Query { + usersByIds(ids: [ID]!): [User] + } + type User { + id: ID! + name: String! + } + type Account { + id: ID! + } + """.trimIndent(), + "issues" to /* language=GraphQL*/ """ + type Query { + issuesByIds(ids: [ID]!): [Issue] + } + type Issue { + id: ID! + key: String + } + """.trimIndent(), + ), + ) + + val errors = validate(fixture) + assertTrue(errors.map { it.message }.isEmpty()) + } + + @Test + fun `permit multiple source fields non batched hydration`() { + val fixture = NadelValidationTestFixture( + overallSchema = mapOf( + "activity" to /* language=GraphQL*/ """ + type Query { + myActivity: [Activity] + } + union ActivityContent = User | Issue + type Activity { + id: ID! + data: ActivityContent + @hydrated( + service: "users" + field: "userById" + arguments: [ + {name: "id", value: "$source.userId"} + ] + ) + @hydrated( + service: "issues" + field: "issueById" + arguments: [ + {name: "id", value: "$source.issueId"} + ] + ) + } + """.trimIndent(), + "users" to /* language=GraphQL*/ """ + type Query { + userById(id: ID): User + } + type User { + id: ID! + name: String! + } + """.trimIndent(), + "issues" to /* language=GraphQL*/ """ + type Query { + issueById(id: ID): Issue + } + type Issue { + id: ID! + key: String + } + """.trimIndent(), + ), + underlyingSchema = mapOf( + "activity" to /* language=GraphQL*/ """ + type Query { + myActivity: [Activity] + } + type Activity { + id: ID! + userId: ID + issueId: ID + } + """.trimIndent(), + "users" to /* language=GraphQL*/ """ + type Query { + userById(id: ID): User + } + type User { + id: ID! + name: String! + } + type Account { + id: ID! + } + """.trimIndent(), + "issues" to /* language=GraphQL*/ """ + type Query { + issueById(id: ID): Issue + } + type Issue { + id: ID! + key: String + } + """.trimIndent(), + ), + ) + + val errors = validate(fixture) + assertTrue(errors.map { it.message }.isEmpty()) + } }