diff --git a/lib/src/main/java/graphql/nadel/engine/blueprint/hydration/NadelHydrationCondition.kt b/lib/src/main/java/graphql/nadel/engine/blueprint/hydration/NadelHydrationCondition.kt index b7f91f177..0c6f0b084 100644 --- a/lib/src/main/java/graphql/nadel/engine/blueprint/hydration/NadelHydrationCondition.kt +++ b/lib/src/main/java/graphql/nadel/engine/blueprint/hydration/NadelHydrationCondition.kt @@ -34,6 +34,15 @@ sealed class NadelHydrationCondition { } } + data class BooleanResultEquals( + override val fieldPath: NadelQueryPath, + val value: Boolean, + ) : NadelHydrationCondition() { + override fun evaluate(fieldValue: Any?): Boolean { + return fieldValue is Boolean && fieldValue == value + } + } + data class StringResultMatches( override val fieldPath: NadelQueryPath, val regex: Regex, diff --git a/lib/src/main/java/graphql/nadel/validation/NadelSchemaHydrationValidationError.kt b/lib/src/main/java/graphql/nadel/validation/NadelSchemaHydrationValidationError.kt index 3e8d82933..44c640fb4 100644 --- a/lib/src/main/java/graphql/nadel/validation/NadelSchemaHydrationValidationError.kt +++ b/lib/src/main/java/graphql/nadel/validation/NadelSchemaHydrationValidationError.kt @@ -553,13 +553,14 @@ data class NadelHydrationResultConditionUnsupportedFieldTypeError( val str = Scalars.GraphQLString.name val int = Scalars.GraphQLInt.name val id = Scalars.GraphQLID.name + val boolean = Scalars.GraphQLBoolean.name val parentTypeName = parentType.overall.name getHydrationErrorMessage( parentType, virtualField, hydration, - reason = "condition field $parentTypeName.$conditionField must be of type $str, $int, $id or enum but is $conditionFieldType", + reason = "condition field $parentTypeName.$conditionField must be of type $str, $int, $id, $boolean or enum but is $conditionFieldType", ) } 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 602c4bee3..3c662aa54 100644 --- a/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationConditionValidation.kt +++ b/lib/src/main/java/graphql/nadel/validation/hydration/NadelHydrationConditionValidation.kt @@ -1,5 +1,6 @@ package graphql.nadel.validation.hydration +import graphql.Scalars.GraphQLBoolean import graphql.Scalars.GraphQLID import graphql.Scalars.GraphQLInt import graphql.Scalars.GraphQLString @@ -57,6 +58,10 @@ private sealed class NadelConditionFieldType { override val graphQLType = GraphQLID } + object BooleanType : NadelConditionFieldType() { + override val graphQLType = GraphQLBoolean + } + data class EnumType( val enumType: GraphQLEnumType, ) : NadelConditionFieldType() { @@ -134,6 +139,7 @@ internal class NadelHydrationConditionValidation { GraphQLString -> NadelConditionFieldType.StringType GraphQLInt -> NadelConditionFieldType.IntType GraphQLID -> NadelConditionFieldType.IdType + GraphQLBoolean -> NadelConditionFieldType.BooleanType is GraphQLEnumType -> NadelConditionFieldType.EnumType(conditionFieldOutputType) else -> null } ?: return NadelHydrationResultConditionUnsupportedFieldTypeError( @@ -304,6 +310,11 @@ internal class NadelHydrationConditionValidation { fieldPath = NadelQueryPath(resultCondition.pathToSourceField), value = expectedValue.toLong(), ).asInterimSuccess() + } else if (expectedValue is Boolean && conditionFieldType == NadelConditionFieldType.BooleanType) { + return NadelHydrationCondition.BooleanResultEquals( + fieldPath = NadelQueryPath(resultCondition.pathToSourceField), + value = expectedValue, + ).asInterimSuccess() } else { return NadelHydrationConditionIncompatibleValueError( parentType = parent, diff --git a/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationWhenConditionValidationTest.kt b/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationWhenConditionValidationTest.kt index fafb74150..63b67938c 100644 --- a/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationWhenConditionValidationTest.kt +++ b/lib/src/test/kotlin/graphql/nadel/validation/NadelHydrationWhenConditionValidationTest.kt @@ -9,79 +9,249 @@ private const val source = "$" + "source" private const val argument = "$" + "argument" class NadelHydrationWhenConditionValidationTest { + private fun booleanConditionFixture( + conditionFieldType: String, + underlyingConditionFieldType: String, + predicate: String, + ): NadelValidationTestFixture { + return NadelValidationTestFixture( + overallSchema = mapOf( + "issues" to """ + type Query { + issue: JiraIssue + } + type JiraIssue @renamed(from: "Issue") { + id: ID! + } + """.trimIndent(), + "users" to """ + type Query { + users(id: [ID!]!): [User] + } + type User { + id: ID! + name: String! + } + extend type JiraIssue { + shouldHydrate: $conditionFieldType + collaborators: [User] @hydrated( + service: "users" + field: "users" + arguments: [ + {name: "id", value: "$source.collaboratorIds"} + ] + when: { + result: { + sourceField: "shouldHydrate" + predicate: { $predicate } + } + } + ) + } + """.trimIndent(), + ), + underlyingSchema = mapOf( + "issues" to """ + type Query { + issue: Issue + } + type Issue { + id: ID! + collaboratorIds: [ID!] + shouldHydrate: $underlyingConditionFieldType + } + """.trimIndent(), + "users" to """ + type Query { + users(id: [ID!]!): [User] + } + type User { + id: ID! + name: String! + } + """.trimIndent(), + ), + ) + } + private fun enumConditionFixture( - conditionFieldType: String = "IssueType", - underlyingConditionFieldType: String = "IssueType!", - predicate: String = "equals: \"BUG\"", + conditionFieldType: String, + underlyingConditionFieldType: String, + predicate: String, ): NadelValidationTestFixture { return NadelValidationTestFixture( overallSchema = mapOf( "issues" to """ - type Query { - issue: JiraIssue - } - type JiraIssue @renamed(from: "Issue") { - id: ID! - } - enum IssueType { - BUG - STORY - } - """.trimIndent(), + type Query { + issue: JiraIssue + } + type JiraIssue @renamed(from: "Issue") { + id: ID! + } + enum IssueType { + BUG + STORY + } + """.trimIndent(), "users" to """ - type Query { - users(id: [ID!]!): [User] - } - type User { - id: ID! - name: String! - } - extend type JiraIssue { - type: $conditionFieldType - collaborators: [User] @hydrated( - service: "users" - field: "users" - arguments: [ - {name: "id", value: "$source.collaboratorIds"} - ] - when: { - result: { - sourceField: "type" - predicate: { $predicate } - } + type Query { + users(id: [ID!]!): [User] + } + type User { + id: ID! + name: String! + } + extend type JiraIssue { + type: $conditionFieldType + collaborators: [User] @hydrated( + service: "users" + field: "users" + arguments: [ + {name: "id", value: "$source.collaboratorIds"} + ] + when: { + result: { + sourceField: "type" + predicate: { $predicate } } - ) - } - """.trimIndent(), + } + ) + } + """.trimIndent(), ), underlyingSchema = mapOf( "issues" to """ - type Query { - issue: Issue - } - enum IssueType { - BUG - STORY - } - type Issue { - id: ID! - collaboratorIds: [ID!] - type: $underlyingConditionFieldType - } - """.trimIndent(), + type Query { + issue: Issue + } + enum IssueType { + BUG + STORY + } + type Issue { + id: ID! + collaboratorIds: [ID!] + type: $underlyingConditionFieldType + } + """.trimIndent(), "users" to """ - type Query { - users(id: [ID!]!): [User] - } - type User { - id: ID! - name: String! - } - """.trimIndent(), + type Query { + users(id: [ID!]!): [User] + } + type User { + id: ID! + name: String! + } + """.trimIndent(), ), ) } + @Test + fun `boolean condition field is acceptable for true equals predicate`() { + val fixture = booleanConditionFixture( + conditionFieldType = "Boolean", + underlyingConditionFieldType = "Boolean!", + predicate = "equals: true", + ) + + // When + val errors = validate(fixture) + + // Then + assertTrue(errors.map { it.message }.isEmpty()) + } + + @Test + fun `boolean condition field is acceptable for false equals predicate`() { + val fixture = booleanConditionFixture( + conditionFieldType = "Boolean", + underlyingConditionFieldType = "Boolean!", + predicate = "equals: false", + ) + + // When + val errors = validate(fixture) + + // Then + assertTrue(errors.map { it.message }.isEmpty()) + } + + @Test + fun `non null boolean condition field is acceptable for equals predicate`() { + val fixture = booleanConditionFixture( + conditionFieldType = "Boolean!", + underlyingConditionFieldType = "Boolean!", + predicate = "equals: true", + ) + + // When + val errors = validate(fixture) + + // Then + assertTrue(errors.map { it.message }.isEmpty()) + } + + @Test + fun `list boolean condition field is not acceptable for equals predicate`() { + val fixture = booleanConditionFixture( + conditionFieldType = "[Boolean]", + underlyingConditionFieldType = "[Boolean]", + predicate = "equals: true", + ) + + // When + val errors = validate(fixture) + + // Then + errors.assertSingleOfType() + } + + @Test + fun `boolean condition field rejects string equals value`() { + val fixture = booleanConditionFixture( + conditionFieldType = "Boolean", + underlyingConditionFieldType = "Boolean!", + predicate = "equals: \"true\"", + ) + + // When + val errors = validate(fixture) + + // Then + errors.assertSingleOfType() + } + + @Test + fun `boolean condition field rejects matches predicate`() { + val fixture = booleanConditionFixture( + conditionFieldType = "Boolean", + underlyingConditionFieldType = "Boolean!", + predicate = "matches: \"true\"", + ) + + // When + val errors = validate(fixture) + + // Then + errors.assertSingleOfType() + } + + @Test + fun `boolean condition field rejects startsWith predicate`() { + val fixture = booleanConditionFixture( + conditionFieldType = "Boolean", + underlyingConditionFieldType = "Boolean!", + predicate = "startsWith: \"tr\"", + ) + + // When + val errors = validate(fixture) + + // Then + errors.assertSingleOfType() + } + @Test fun `list type field is acceptable if its the source field`() { val fixture = NadelValidationTestFixture( @@ -152,7 +322,11 @@ class NadelHydrationWhenConditionValidationTest { @Test fun `enum condition field is acceptable for equals predicate`() { - val fixture = enumConditionFixture() + val fixture = enumConditionFixture( + conditionFieldType = "IssueType", + underlyingConditionFieldType = "IssueType!", + predicate = "equals: \"BUG\"", + ) // When val errors = validate(fixture) @@ -163,7 +337,11 @@ class NadelHydrationWhenConditionValidationTest { @Test fun `non null enum condition field is acceptable for equals predicate`() { - val fixture = enumConditionFixture(conditionFieldType = "IssueType!") + val fixture = enumConditionFixture( + conditionFieldType = "IssueType!", + underlyingConditionFieldType = "IssueType!", + predicate = "equals: \"BUG\"", + ) // When val errors = validate(fixture) @@ -177,6 +355,7 @@ class NadelHydrationWhenConditionValidationTest { val fixture = enumConditionFixture( conditionFieldType = "[IssueType]", underlyingConditionFieldType = "[IssueType]", + predicate = "equals: \"BUG\"", ) // When @@ -188,7 +367,11 @@ class NadelHydrationWhenConditionValidationTest { @Test fun `enum condition field rejects invalid equals value`() { - val fixture = enumConditionFixture(predicate = "equals: \"TASK\"") + val fixture = enumConditionFixture( + conditionFieldType = "IssueType", + underlyingConditionFieldType = "IssueType!", + predicate = "equals: \"TASK\"", + ) // When val errors = validate(fixture) @@ -200,7 +383,11 @@ class NadelHydrationWhenConditionValidationTest { @Test fun `enum condition field equals comparison is case sensitive`() { - val fixture = enumConditionFixture(predicate = "equals: \"bug\"") + val fixture = enumConditionFixture( + conditionFieldType = "IssueType", + underlyingConditionFieldType = "IssueType!", + predicate = "equals: \"bug\"", + ) // When val errors = validate(fixture) @@ -212,7 +399,11 @@ class NadelHydrationWhenConditionValidationTest { @Test fun `enum condition field rejects empty string equals value`() { - val fixture = enumConditionFixture(predicate = "equals: \"\"") + val fixture = enumConditionFixture( + conditionFieldType = "IssueType", + underlyingConditionFieldType = "IssueType!", + predicate = "equals: \"\"", + ) // When val errors = validate(fixture) @@ -224,7 +415,11 @@ class NadelHydrationWhenConditionValidationTest { @Test fun `enum condition field rejects matches predicate`() { - val fixture = enumConditionFixture(predicate = "matches: \"BUG\"") + val fixture = enumConditionFixture( + conditionFieldType = "IssueType", + underlyingConditionFieldType = "IssueType!", + predicate = "matches: \"BUG\"", + ) // When val errors = validate(fixture) @@ -235,7 +430,11 @@ class NadelHydrationWhenConditionValidationTest { @Test fun `enum condition field rejects startsWith predicate`() { - val fixture = enumConditionFixture(predicate = "startsWith: \"BU\"") + val fixture = enumConditionFixture( + conditionFieldType = "IssueType", + underlyingConditionFieldType = "IssueType!", + predicate = "startsWith: \"BU\"", + ) // When val errors = validate(fixture) diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/conditional/HydrationConditionalBooleanConditionTest.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/conditional/HydrationConditionalBooleanConditionTest.kt new file mode 100644 index 000000000..069029b8e --- /dev/null +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/conditional/HydrationConditionalBooleanConditionTest.kt @@ -0,0 +1,99 @@ +package graphql.nadel.tests.next.fixtures.hydration.conditional + +import graphql.nadel.tests.next.NadelIntegrationTest + +class HydrationConditionalBooleanConditionTest : NadelIntegrationTest( + query = """ + query { + foo { + matchingBar { + name + } + nonMatchingBar { + name + } + } + } + """.trimIndent(), + variables = mapOf(), + services = listOf( + Service( + name = "service1", + overallSchema = """ + type Query { + foo: Foo + } + type Foo { + id: ID + shouldHydrate: Boolean + matchingBarId: ID @hidden + nonMatchingBarId: ID @hidden + matchingBar: Bar + @hydrated( + service: "service2" + field: "barById" + arguments: [{name: "id", value: "$source.matchingBarId"}] + when: { result: { sourceField: "shouldHydrate", predicate: { equals: true } } } + ) + nonMatchingBar: Bar + @hydrated( + service: "service2" + field: "barById" + arguments: [{name: "id", value: "$source.nonMatchingBarId"}] + when: { result: { sourceField: "shouldHydrate", predicate: { equals: false } } } + ) + } + """.trimIndent(), + runtimeWiring = { wiring -> + data class Foo( + val id: String, + val shouldHydrate: Boolean, + val matchingBarId: String, + val nonMatchingBarId: String, + ) + + val foo = Foo( + id = "foo-id", + shouldHydrate = true, + matchingBarId = "matching-bar-id", + nonMatchingBarId = "non-matching-bar-id", + ) + + wiring + .type("Query") { type -> + type.dataFetcher("foo") { foo } + } + }, + ), + Service( + name = "service2", + overallSchema = """ + type Query { + barById(id: ID): Bar + } + type Bar { + id: ID + name: String + } + """.trimIndent(), + runtimeWiring = { wiring -> + data class Bar( + val id: String, + val name: String, + ) + + val barsById = listOf( + Bar(id = "matching-bar-id", name = "Matching Bar"), + Bar(id = "non-matching-bar-id", name = "Non Matching Bar"), + ).associateBy { it.id } + + wiring + .type("Query") { type -> + type.dataFetcher("barById") { + barsById[it.getArgument("id")!!] + } + } + }, + ), + ), +) diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/conditional/HydrationConditionalBooleanConditionTestSnapshot.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/conditional/HydrationConditionalBooleanConditionTestSnapshot.kt new file mode 100644 index 000000000..c576a33ce --- /dev/null +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/conditional/HydrationConditionalBooleanConditionTestSnapshot.kt @@ -0,0 +1,132 @@ +// @formatter:off +package graphql.nadel.tests.next.fixtures.hydration.conditional + +import graphql.nadel.tests.next.ExpectedNadelResult +import graphql.nadel.tests.next.ExpectedServiceCall +import graphql.nadel.tests.next.TestSnapshot +import graphql.nadel.tests.next.listOfJsonStrings +import kotlin.Suppress +import kotlin.collections.List +import kotlin.collections.listOf + +private suspend fun main() { + graphql.nadel.tests.next.update() +} + +/** + * This class is generated. Do NOT modify. + * + * Refer to [graphql.nadel.tests.next.UpdateTestSnapshots] + */ +@Suppress("unused") +public class HydrationConditionalBooleanConditionTestSnapshot : TestSnapshot() { + /** + * Query + * + * ```graphql + * query { + * foo { + * matchingBar { + * name + * } + * nonMatchingBar { + * name + * } + * } + * } + * ``` + * + * Variables + * + * ```json + * {} + * ``` + */ + override val calls: List = listOf( + ExpectedServiceCall( + service = "service1", + query = """ + | { + | foo { + | __typename__hydration__matchingBar: __typename + | __typename__hydration__nonMatchingBar: __typename + | hydration__matchingBar__matchingBarId: matchingBarId + | hydration__nonMatchingBar__nonMatchingBarId: nonMatchingBarId + | hydration__matchingBar__shouldHydrate: shouldHydrate + | hydration__nonMatchingBar__shouldHydrate: shouldHydrate + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "foo": { + | "hydration__matchingBar__matchingBarId": "matching-bar-id", + | "hydration__matchingBar__shouldHydrate": true, + | "__typename__hydration__matchingBar": "Foo", + | "hydration__nonMatchingBar__nonMatchingBarId": "non-matching-bar-id", + | "hydration__nonMatchingBar__shouldHydrate": true, + | "__typename__hydration__nonMatchingBar": "Foo" + | } + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ExpectedServiceCall( + service = "service2", + query = """ + | { + | barById(id: "matching-bar-id") { + | name + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "barById": { + | "name": "Matching Bar" + | } + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ) + + /** + * ```json + * { + * "data": { + * "foo": { + * "nonMatchingBar": null, + * "matchingBar": { + * "name": "Matching Bar" + * } + * } + * } + * } + * ``` + */ + override val result: ExpectedNadelResult = ExpectedNadelResult( + result = """ + | { + | "data": { + | "foo": { + | "nonMatchingBar": null, + | "matchingBar": { + | "name": "Matching Bar" + | } + | } + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ) +}