From 4acaf70ddc5e52bfeb815486058fbb8e0ab1a44d Mon Sep 17 00:00:00 2001 From: James Bellenger Date: Sun, 9 Aug 2026 10:46:42 -0700 Subject: [PATCH] Refine arbitrary GraphQL generators --- graphql-java-arbitrary/README.md | 131 ++++ .../build.gradle | 15 +- .../main/kotlin/graphql/arbitrary/ArbExt.kt | 210 ++--- .../kotlin/graphql/arbitrary}/CheckedArb.kt | 2 +- .../kotlin/graphql/arbitrary/Comparators.kt | 7 +- .../main/kotlin/graphql/arbitrary}/Config.kt | 67 +- .../main/kotlin/graphql/arbitrary/Configs.kt | 28 +- .../kotlin/graphql/arbitrary}/CycleGroups.kt | 2 +- .../graphql/arbitrary}/DocumentBuilder.kt | 2 +- .../graphql/arbitrary}/GraphQLDocuments.kt | 62 +- .../arbitrary}/GraphQLExecutionInputs.kt | 10 +- .../arbitrary/GraphQLExternalInputValueGen.kt | 135 ++-- .../graphql/arbitrary/GraphQLInputValueGen.kt | 142 ++-- .../graphql/arbitrary/GraphQLJavaExt.kt | 57 +- .../kotlin/graphql/arbitrary}/GraphQLNames.kt | 60 +- .../graphql/arbitrary}/GraphQLOutputValues.kt | 65 +- .../arbitrary}/GraphQLRuntimeWirings.kt | 55 +- .../graphql/arbitrary}/GraphQLSchemas.kt | 105 +-- .../graphql/arbitrary}/GraphQLStrings.kt | 2 +- .../kotlin/graphql/arbitrary}/GraphQLTypes.kt | 10 +- .../kotlin/graphql/arbitrary/IDValueGen.kt | 36 + .../main/kotlin/graphql/arbitrary/Schemas.kt | 29 + .../main/kotlin/graphql/arbitrary/TypeKind.kt | 4 +- .../kotlin/graphql/arbitrary/TypeRelations.kt | 32 +- .../main/kotlin/graphql/arbitrary/Utils.kt | 123 +++ .../kotlin/graphql/arbitrary/ArbExtTest.kt | 273 +++---- .../graphql/arbitrary/ArbPropertyBaseTest.kt | 43 +- .../graphql/arbitrary}/CheckedArbTest.kt | 4 +- .../graphql/arbitrary/ComparatorsTest.kt | 41 + .../kotlin/graphql/arbitrary}/ConfigTest.kt | 58 +- .../graphql/arbitrary}/CycleGroupsTest.kt | 4 +- .../graphql/arbitrary}/DeepArbSuiteTest.kt | 2 +- .../graphql/arbitrary}/DocumentBuilderTest.kt | 2 +- .../arbitrary}/GraphQLDocumentGenTest.kt | 72 +- .../GraphQLExecutionInputGenTest.kt | 13 +- .../GraphQLExternalInputValueGenTest.kt | 105 +++ .../arbitrary/GraphQLInputValueGenTest.kt | 25 + .../graphql/arbitrary}/GraphQLNamesTest.kt | 43 +- .../arbitrary}/GraphQLOutputValuesTest.kt | 45 +- .../arbitrary}/GraphQLRuntimeWiringsTest.kt | 64 +- .../graphql/arbitrary}/GraphQLSchemasTest.kt | 138 ++-- .../graphql/arbitrary}/GraphQLStringsTest.kt | 13 +- .../graphql/arbitrary}/GraphQLTypesTest.kt | 194 +++-- .../graphql/arbitrary/IDValueGenTest.kt | 18 + .../arbitrary}/PropertyTestFixtures.kt | 3 +- .../kotlin/graphql/arbitrary}/SchemasTest.kt | 2 +- .../graphql/arbitrary/TypeRelationsTest.kt | 21 +- .../test/kotlin/graphql/arbitrary/UtilTest.kt | 178 +++++ .../graphql/arbitrary/ArbPropertyBase.kt | 23 +- .../kotlin/graphql/arbitrary}/DeepArbSuite.kt | 4 +- graphql-java-property-testing/README.md | 65 -- .../kotlin/graphql/schema/property/ArbExt.kt | 71 -- .../graphql/schema/property/CallRecorder.kt | 107 --- .../schema/property/GraphQLTestData.kt | 203 ----- .../graphql/schema/property/ArbExtTest.kt | 123 --- .../schema/property/CallRecorderTest.kt | 159 ---- .../schema/property/FailureCollector.kt | 735 ------------------ .../schema/property/GeneratedSchema.kt | 22 - .../GraphQLExternalInputValueGeneratorTest.kt | 87 --- .../schema/property/GraphQLTestDataTest.kt | 55 -- settings.gradle | 2 +- 61 files changed, 1693 insertions(+), 2715 deletions(-) create mode 100644 graphql-java-arbitrary/README.md rename {graphql-java-property-testing => graphql-java-arbitrary}/build.gradle (93%) rename graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/ArbExt.kt (53%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/CheckedArb.kt (98%) rename graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLComparators.kt => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Comparators.kt (72%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/Config.kt (75%) rename graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLGeneratorConfig.kt => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Configs.kt (91%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/CycleGroups.kt (99%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/DocumentBuilder.kt (99%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/GraphQLDocuments.kt (94%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/GraphQLExecutionInputs.kt (92%) rename graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExternalInputValueGenerator.kt => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLExternalInputValueGen.kt (53%) rename graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLInputValueGenerator.kt => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLInputValueGen.kt (60%) rename graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLJavaExt.kt (54%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/GraphQLNames.kt (72%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/GraphQLOutputValues.kt (81%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/GraphQLRuntimeWirings.kt (79%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/GraphQLSchemas.kt (88%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/GraphQLStrings.kt (98%) rename {graphql-java-property-testing/src/main/kotlin/graphql/schema/property => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary}/GraphQLTypes.kt (98%) create mode 100644 graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/IDValueGen.kt create mode 100644 graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Schemas.kt rename graphql-java-property-testing/src/main/kotlin/graphql/schema/property/TypeType.kt => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/TypeKind.kt (71%) rename graphql-java-property-testing/src/main/kotlin/graphql/schema/property/SchemaRelationships.kt => graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/TypeRelations.kt (59%) create mode 100644 graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Utils.kt rename graphql-java-property-testing/src/test/kotlin/graphql/schema/property/UtilTest.kt => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ArbExtTest.kt (52%) rename graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBaseTest.kt => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ArbPropertyBaseTest.kt (72%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/CheckedArbTest.kt (98%) create mode 100644 graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ComparatorsTest.kt rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/ConfigTest.kt (79%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/CycleGroupsTest.kt (98%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/DeepArbSuiteTest.kt (97%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/DocumentBuilderTest.kt (99%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/GraphQLDocumentGenTest.kt (92%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/GraphQLExecutionInputGenTest.kt (93%) create mode 100644 graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLExternalInputValueGenTest.kt create mode 100644 graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLInputValueGenTest.kt rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/GraphQLNamesTest.kt (74%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/GraphQLOutputValuesTest.kt (76%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/GraphQLRuntimeWiringsTest.kt (85%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/GraphQLSchemasTest.kt (87%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/GraphQLStringsTest.kt (93%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/GraphQLTypesTest.kt (73%) create mode 100644 graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/IDValueGenTest.kt rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/PropertyTestFixtures.kt (94%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary}/SchemasTest.kt (97%) rename graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt => graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/TypeRelationsTest.kt (68%) create mode 100644 graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/UtilTest.kt rename graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBase.kt => graphql-java-arbitrary/src/testFixtures/kotlin/graphql/arbitrary/ArbPropertyBase.kt (85%) rename {graphql-java-property-testing/src/test/kotlin/graphql/schema/property => graphql-java-arbitrary/src/testFixtures/kotlin/graphql/arbitrary}/DeepArbSuite.kt (97%) delete mode 100644 graphql-java-property-testing/README.md delete mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/ArbExt.kt delete mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CallRecorder.kt delete mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTestData.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ArbExtTest.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CallRecorderTest.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/FailureCollector.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratedSchema.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExternalInputValueGeneratorTest.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTestDataTest.kt diff --git a/graphql-java-arbitrary/README.md b/graphql-java-arbitrary/README.md new file mode 100644 index 000000000..8b5bc2834 --- /dev/null +++ b/graphql-java-arbitrary/README.md @@ -0,0 +1,131 @@ +# Arbitrary + +This module generates streams of arbitrary GraphQL objects that are suitable for +use in fuzzing and property-based testing. + +The generators produce graphql-java objects that can be used to test any GraphQL +system built on graphql-java. + +The arbitrary object streams are exposed as a Kotest property +[Arb](https://kotest.io/docs/proptest/property-test-generators.html#arbitrary); +properties of an `Arb` can be tested using JUnit, Kotest, or other unit testing +frameworks. + +## Dependency + +Use the same version as graphql-java: + +```kotlin +testImplementation("com.graphql-java:graphql-java-arbitrary:") +``` + +Reusable test base classes such as `DeepArbSuite` and `ArbPropertyBase` are +published as Gradle test fixtures: + +```kotlin +testImplementation(testFixtures("com.graphql-java:graphql-java-arbitrary:")) +``` + +## Quick Start + +```kotlin +import graphql.arbitrary.graphQLSchema +import graphql.schema.idl.SchemaParser +import graphql.schema.idl.SchemaPrinter +import io.kotest.property.Arb +import io.kotest.property.checkAll +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test + +class QuickStartTest { + @Test + fun `arbitrary schemas can be roundtripped through SDL`(): Unit = runBlocking { + Arb.graphQLSchema().checkAll { schema -> + val sdl = SchemaPrinter().print(schema) + SchemaParser().parse(sdl) + } + } +} +``` + +## Configuration + +All generator functions in this module accept an optional `Config` parameter. + +This is used to customize the shape of objects that are produced, and is +primarily oriented around "weights" that control how often a GraphQL feature +will be explored by a generator. + +Omitting a `Config` will use a default configuration that approximates a +real-world probability distribution. The generators in this module are tuned +to have acceptable performance for approximately 1000 generations when using +the default `Config`. + +A `Config` may be modified to steer the distribution of generated objects +towards an area of interest. This library includes configuration knobs for +controlling the probabilities of a field definition taking arguments, a +resolver throwing an exception, an inline fragment omitting a type condition, +and more. + +See [`Configs.kt`](src/main/kotlin/graphql/arbitrary/Configs.kt) for a full list +of configuration knobs. + +### Example + +```kotlin +import graphql.arbitrary.Config +import graphql.arbitrary.ObjectTypeSize +import graphql.arbitrary.SchemaSize +import graphql.arbitrary.graphQLSchema +import io.kotest.property.Arb + +// Create a custom Config that generates wide object types. +val extraLargeObjectConfig = Config( + SchemaSize(10), + ObjectTypeSize(200..1000) +) + +val arbSchema = Arb.graphQLSchema(extraLargeObjectConfig) +``` + +## Library + +Useful generators and methods provided by this library. + +### Schema and Documents + +| Generator | Description | +|-----------------------------|-------------------------------------------------------------------| +| `Arb.graphQLSchema` | Generate arbitrary `graphql.schema.GraphQLSchema` objects | +| `Arb.graphQLDocument` | Generate valid `graphql.language.Document` objects for a schema | +| `Arb.graphQLExecutionInput` | Generate valid `graphql.ExecutionInput` objects for a schema | +| `Arb.graphQLTypes` | Generate compatible collections of graphql-java schema types | +| `Arb.graphQLName` | Generate names suitable for use in GraphQL | + +### Values + +| Generator | Description | +|--------------------------|--------------------------------------------------------------------| +| `Arb.graphQLOutputValue` | Generate native result values for a type, selection, or operation | + +### Execution + +| Generator | Description | +|--------------------|--------------------------------------------------------------------| +| `arbGraphQL` | Create an executable `GraphQL` backed by deterministic arbitrary data | +| `arbCodeRegistry` | Create a `GraphQLCodeRegistry` that serves arbitrary data | +| `arbRuntimeWiring` | Create a `RuntimeWiring` that serves arbitrary data for an SDL schema | + +## History + +This code was originally created by +[James Bellenger](https://github.com/jbellenger) at Airbnb as part of the +[Viaduct project](https://github.com/airbnb/viaduct). + +It was adapted from Viaduct's +[`core/shared/arbitrary` module](https://github.com/airbnb/viaduct/tree/e91e45f1976d7311729af32d48fcd4aab8652516/core/shared/arbitrary) +to generate graphql-java's native schema and language AST types. + +The original source is licensed under the Apache License 2.0. The license text +is included in the published artifact and is also available in this repository +at `additionallicenses/APACHE-LICENSE-2.0.txt`. diff --git a/graphql-java-property-testing/build.gradle b/graphql-java-arbitrary/build.gradle similarity index 93% rename from graphql-java-property-testing/build.gradle rename to graphql-java-arbitrary/build.gradle index 085b5263b..5da8f88c2 100644 --- a/graphql-java-property-testing/build.gradle +++ b/graphql-java-arbitrary/build.gradle @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id 'java-library' + id 'java-test-fixtures' id 'org.jetbrains.kotlin.jvm' id 'org.jetbrains.dokka-javadoc' id 'maven-publish' @@ -46,6 +47,8 @@ dependencies { api "org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}" api "io.kotest:kotest-property:${kotestVersion}" + testFixturesApi 'org.junit.jupiter:junit-jupiter:5.14.4' + testImplementation "io.kotest:kotest-assertions-core:${kotestVersion}" testImplementation 'org.junit.jupiter:junit-jupiter:5.14.4' testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.14.4' @@ -58,7 +61,7 @@ jar { } from 'README.md' manifest { - attributes('Automatic-Module-Name': 'com.graphqljava.propertytesting') + attributes('Automatic-Module-Name': 'com.graphqljava.arbitrary') } } @@ -101,12 +104,12 @@ tasks.withType(Test).configureEach { publishing { publications { - propertyTesting(MavenPublication) { - artifactId = 'graphql-java-property-testing' + arbitrary(MavenPublication) { + artifactId = 'graphql-java-arbitrary' from components.java artifact dokkaJavadocJar pom { - name = 'graphql-java property testing' + name = 'graphql-java arbitrary' description = 'Kotest property generators for graphql-java schemas, documents, and values' url = 'https://github.com/graphql-java/graphql-java' scm { @@ -147,7 +150,3 @@ signing { tasks.withType(PublishToMavenRepository).configureEach { dependsOn tasks.named('build') } - -tasks.withType(GenerateModuleMetadata).configureEach { - enabled = false -} diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/ArbExt.kt similarity index 53% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/ArbExt.kt index 68d52c77a..966dbbf7a 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/ArbExt.kt @@ -1,15 +1,13 @@ -package graphql.schema.property +@file:Suppress("ForbiddenImport") + +package graphql.arbitrary -import graphql.Directives -import graphql.Scalars -import graphql.schema.GraphQLDirective -import graphql.schema.GraphQLInterfaceType -import graphql.schema.GraphQLScalarType import io.kotest.property.Arb +import io.kotest.property.Gen import io.kotest.property.RandomSource +import io.kotest.property.Sample import io.kotest.property.arbitrary.bind import io.kotest.property.arbitrary.choose -import io.kotest.property.arbitrary.constant import io.kotest.property.arbitrary.filter import io.kotest.property.arbitrary.filterNot import io.kotest.property.arbitrary.flatMap @@ -20,30 +18,13 @@ import io.kotest.property.arbitrary.next import io.kotest.property.arbitrary.of import io.kotest.property.arbitrary.pair import io.kotest.property.arbitrary.shuffle +import io.kotest.property.asSample import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.delay -internal val builtinScalars: Map = - listOf( - Scalars.GraphQLBoolean, - Scalars.GraphQLID, - Scalars.GraphQLInt, - Scalars.GraphQLFloat, - Scalars.GraphQLString - ).associateBy { it.name } - -internal val builtinDirectives: Map = - Directives.BUILT_IN_DIRECTIVES.associateBy { it.name } - -/** Return an IntRange containing only this Int. */ -fun Int.asIntRange(): IntRange = IntRange(this, this) - -/** Return a LongRange containing only this Int. */ -fun Int.asLongRange(): LongRange = toLong().asLongRange() - -/** Return a LongRange containing only this Long. */ -fun Long.asLongRange(): LongRange = LongRange(this, this) +/** Convert an arb to an infinite [kotlin.sequences.Sequence] */ +fun Gen.asSequence(rs: RandomSource): Sequence = generate(rs).map { it.value } /** * Filter an Arb to only yield non-empty IntRange values. @@ -59,48 +40,6 @@ fun Arb.filterNotNull(): Arb = filter { it != null }.map { it as T } /** Generate an Arb that zips values of this arb with the values of another Arb. */ fun Arb.zip(other: Arb): Arb> = Arb.bind(this, other) { t, u -> t to u } -/** - * Get a boolean from this random source, with probability of a true - * value being equal to the provided weight. - */ -fun RandomSource.sampleWeight(weight: Double): Boolean = - when (weight) { - 0.0 -> false - 1.0 -> true - else -> random.nextDouble(0.0, 1.0) <= weight - } - -/** - * Return an integer describing how many times the provided [CompoundingWeight] - * was sampled before it hit its `max` sample count or returned false. - */ -fun RandomSource.count(weight: CompoundingWeight): Int { - tailrec fun loop(count: Int): Int = - if (count == weight.max || !sampleWeight(weight.weight)) count else loop(count + 1) - return loop(0) -} - -/** Suspend for a value of milliseconds bounded by [latencyMillis]. */ -internal suspend fun RandomSource.maybeDelay(latencyMillis: LongRange) { - if (latencyMillis.last > 0) delay(Arb.long(latencyMillis).next(this)) -} - -/** Throw [ResolverException] if sampling the weight described by [key] returns true. */ -internal fun maybeThrowResolverException( - cfg: Config, - key: ConfigKey, - rs: RandomSource -) { - if (rs.sampleWeight(cfg[key])) throw ResolverException(key) -} - -class ResolverException(val key: ConfigKey<*>) : Exception() { - override val message: String = - "This is a synthetic ResolverException configured by ${key.javaClass.name}" -} - -fun RandomSource.fork(): RandomSource = RandomSource.seeded(random.nextLong()) - /** * Return subsets of a given arb. Subsets will have a size determined by the provided * range, or if no range is provided, then subsets will contain between 0 and size-of-the-input-set @@ -114,12 +53,6 @@ fun Arb>.subset(range: IntRange? = null): Arb> = .map { (shuffled, count) -> shuffled.take(count).toSet() } } -/** - * Return an Arb describing subsets of this Set. - * @see Arb>.subset - */ -fun Set.arbSubset(range: IntRange? = null): Arb> = Arb.constant(this).subset(range) - /** * This method is a replacement for [Arb.Companion.choose]. This method will * never pick an Arb with a 0 weight. [Arb.Companion.choose] can, via edge cases, select an Arb @@ -162,72 +95,85 @@ fun Arb.Companion.weightedChoose(arbs: List>>): Arb { fun Arb.Companion.unit(): Arb = Arb.of(Unit) /** - * Transform a Collection of Arb into an Arb of List. - * - * Example: - * val list = listOf(Arb.of(1), Arb.of(2), Arb.of(3)) - * val items = list.collect().next(rs) // listOf(1, 2, 3) + * Get a boolean from this random source, with probability of a true + * value being equal to the provided weight. */ -fun Collection>.collect(): Arb> = Arb.bind(toList()) { it } +fun RandomSource.sampleWeight(weight: Double): Boolean = + when (weight) { + 0.0 -> false + 1.0 -> true + else -> random.nextDouble(0.0, 1.0) <= weight + } /** - * Select interfaces that may be implemented together without introducing unrelated fields with - * the same name. Related interfaces may share inherited field definitions. + * Return an integer describing how many times the provided [CompoundingWeight] + * was sampled before it hit its `max` sample count or returned false. */ -internal fun Set.nonConflicting(): Set { - val groups = relatedInterfaceGroups(this) - val usedFields = mutableSetOf() - val result = linkedSetOf() - groups.forEach { group -> - val fieldNames = group.flatMap { it.fields }.map { it.name } - if (fieldNames.none(usedFields::contains)) { - result += group.first() - usedFields += fieldNames - } - } - return result +fun RandomSource.count(weight: CompoundingWeight): Int { + tailrec fun loop(count: Int): Int = + if (count == weight.max || !sampleWeight(weight.weight)) count else loop(count + 1) + return loop(0) } -private fun relatedInterfaceGroups( - interfaces: Set -): List> { - val remaining = interfaces.toMutableSet() - val groups = mutableListOf>() - while (remaining.isNotEmpty()) { - val seed = remaining.first() - val group = relatedInterfaces(seed, interfaces) - groups += group - remaining -= group - } - return groups +/** Suspend for a value of milliseconds bounded by [latencyMillis]. */ +internal suspend fun RandomSource.maybeDelay(latencyMillis: LongRange) { + if (latencyMillis.last > 0) delay(Arb.long(latencyMillis).next(this)) } -private fun relatedInterfaces( - seed: GraphQLInterfaceType, - candidates: Set -): Set { - val result = linkedSetOf(seed) - var changed = true - while (changed) { - changed = false - candidates.forEach { candidate -> - if (candidate in result) return@forEach - if (isRelated(candidate, result)) { - result += candidate - changed = true - } - } +fun RandomSource.fork(): RandomSource = RandomSource.seeded(random.nextLong()) + +/** + * Flatten this Arb into an Arb of the inner item type. + * The new Arb will return items in the same order as produced by the original Arb. + * + * The underlying Arb must eventually produce a non-empty Iterable; if every sample is empty, + * [Arb.sample] will loop indefinitely. + */ +fun Arb>.flatten(): Arb = Flatten(map { it.iterator() }) + +/** transform this Arb using [fn], dropping any null values returned by [fn] */ +@JvmName("mapNotNull") +fun Arb.mapNotNull(fn: (T) -> R?): Arb = map(fn).filter { it != null }.map { it!! } + +internal class Flatten( + val underlying: Arb>, +) : Arb() { + private var chunk: Iterator? = null + + override fun edgecase(rs: RandomSource): Sample? { + // Don't interrupt an active chunk — items within a chunk must remain consecutive. + if (chunk?.hasNext() == true) return null + val iter = underlying.edgecase(rs)?.value ?: return null + if (!iter.hasNext()) return null + chunk = iter + return chunk!!.next().asSample() } - return result -} -private fun isRelated( - candidate: GraphQLInterfaceType, - group: Set -): Boolean { - val candidateParents = candidate.interfaces.map { it.name }.toSet() - if (group.any { it.name in candidateParents }) return true - return group.any { existing -> - candidate.name in existing.interfaces.map { it.name } + override fun sample(rs: RandomSource): Sample { + while (chunk == null || chunk?.hasNext() == false) { + chunk = underlying.sample(rs).value + } + return chunk!!.next().asSample() } } + +/** + * Throw a property check failure. + * The [seed] parameter is included in the error message for reproducibility. + */ +fun failProperty( + message: String, + cause: Throwable? = null, + seed: Long? = null +): Unit = + throw AssertionError( + buildString { + if (seed != null) { + appendLine("Property failed with seed $seed") + } else { + appendLine("Property failed") + } + append(message) + }, + cause + ) diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CheckedArb.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/CheckedArb.kt similarity index 98% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CheckedArb.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/CheckedArb.kt index dc20129d8..6bd7b2da5 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CheckedArb.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/CheckedArb.kt @@ -1,6 +1,6 @@ @file:Suppress("ForbiddenImport", "TooGenericExceptionCaught") -package graphql.schema.property +package graphql.arbitrary import io.kotest.property.Arb import io.kotest.property.PropertyTesting diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLComparators.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Comparators.kt similarity index 72% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLComparators.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Comparators.kt index 67bc96fa4..d05468e8b 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLComparators.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Comparators.kt @@ -1,7 +1,8 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.ExecutionInput import graphql.language.Document +import graphql.schema.GraphQLSchema /** A [Comparator] that orders [Document]s by their node count. */ val DocumentComparator: Comparator = @@ -16,3 +17,7 @@ val DocumentComparator: Comparator = */ val ExecutionInputComparator: Comparator = Comparator.comparingInt { it.query.asDocument.allChildren.size } + +/** A [Comparator] that orders [GraphQLSchema]s by their total type count. */ +val GraphQLSchemaComparator: Comparator = + Comparator.comparingInt { it.allTypesAsList.size } diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/Config.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Config.kt similarity index 75% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/Config.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Config.kt index a362ff048..d8ebaab68 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/Config.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Config.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary /** * A container for configuration. @@ -8,7 +8,7 @@ package graphql.schema.property * ``` * // write a config with a value * val key: ConfigKey = ... - * val cfg = Config.default + (key to 10) + * val cfg = Config(key(10)) * * // read a config value * val value = cfg[key] @@ -27,32 +27,45 @@ class Config private constructor( * raised as an IllegalArgumentException */ operator fun plus(pair: Pair, T>): Config = - validateOrThrow(pair.first.validate, pair.second).let { - Config(map + pair) - } + this + Config(pair.first, pair.second) /** Return a copy of this Config, overridden by any configurations in [overrides] */ operator fun plus(overrides: Config): Config = Config(map + overrides.map) + companion object { /** A [Config] that returns default values for any [ConfigKey] */ val default = Config(emptyMap()) - internal fun validateOrThrow( - validator: Validator, - t: T - ) { + internal fun validateOrThrow(validator: Validator, t: T) { validator(t)?.let { msg -> throw InvalidConfigValue(msg, t) } } + + /** + * Construct a Config by combining the provided configurations. + * + * Example: + * ```kotlin + * + * Config( + * configKey1(value1), + * configKey2(value2) + * ) + * ``` + */ + operator fun invoke(vararg configs: Config): Config = + configs.fold(default, Config::plus) + + operator fun invoke(key: ConfigKey, value: T): Config { + validateOrThrow(key.validate, value) + return Config(mapOf(key to value)) + } } } -class InvalidConfigValue( - msg: String, - val value: Any? -) : Exception(msg) +class InvalidConfigValue(msg: String, val value: Any?) : Exception(msg) /** Validator returns a descriptive error string for invalid inputs */ typealias Validator = (t: T) -> String? @@ -79,10 +92,7 @@ object WeightValidator : Validator { * - .1% of fields would define 3 arguments, * - no fields would define 4 or more arguments */ -data class CompoundingWeight( - val weight: Double, - val max: Int -) { +data class CompoundingWeight(val weight: Double, val max: Int) { companion object { /** A [CompoundingWeight] that will be false every time it is sampled */ val Never: CompoundingWeight = CompoundingWeight(0.0, 0) @@ -111,9 +121,7 @@ object CompoundingWeightValidator : Validator { /** * Validates that Int values are within an IntRange domain */ -class IntValidator( - val domain: IntRange -) : Validator { +class IntValidator(val domain: IntRange) : Validator { override fun invoke(x: Int): String? = if (!domain.contains(x)) { "Value must be between ${domain.first} and ${domain.last}" @@ -126,9 +134,7 @@ class IntValidator( * Validates that all possible values of a provided IntRange values are * also within a specified IntRange domain */ -class IntRangeValidator( - val domain: IntRange -) : Validator { +class IntRangeValidator(val domain: IntRange) : Validator { override fun invoke(x: IntRange): String? = if (domain.first > x.first || domain.last < x.last) { "Range must be between ${domain.first} and ${domain.last}" @@ -139,17 +145,18 @@ class IntRangeValidator( } } -/** - * A Validator that accepts all input - */ +/** A Validator that accepts all input */ object Unvalidated : Validator { override fun invoke(t: Any): String? = null } -/** - * Base class for identifying configurable values. - */ +/** Base class for identifying configurable values. */ open class ConfigKey( val default: T, val validate: Validator -) +) { + operator fun invoke(value: T): Config = Config(this, value) +} + +infix fun ConfigKey.setTo(value: T): Config = + Config(this, value) diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLGeneratorConfig.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Configs.kt similarity index 91% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLGeneratorConfig.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Configs.kt index 4d34bdc02..5e6735d67 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLGeneratorConfig.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Configs.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.language.Value import io.kotest.property.Arb @@ -11,12 +11,12 @@ import io.kotest.property.Arb object SchemaSize : ConfigKey(20, IntValidator(0..1_000_000)) /** - * Relative weights of different [TypeType]s. Any unset keys will be interpreted as a weight of 1.0. + * Relative weights of different [TypeKind]s. Any unset keys will be interpreted as a weight of 1.0. * Weights may be any positive double and may be greater than 1.0. */ -object TypeTypeWeights : ConfigKey>(emptyMap(), Unvalidated) { - /** A value with all [TypeType]s configured with a 0 weight */ - val zero: Map = TypeType.entries.associateWith { 0.0 } +object TypeKindWeights : ConfigKey>(emptyMap(), Unvalidated) { + /** A value with all [TypeKind]s configured with a 0 weight */ + val zero: Map = TypeKind.entries.associateWith { 0.0 } } /** The probability that an Object type will implement any interface */ @@ -219,6 +219,9 @@ object ScalarLiteralOverrides : ConfigKey>>>( */ object ScalarValueOverrides : ConfigKey>>(emptyMap(), Unvalidated) +/** The factory to use when generating native String values for GraphQL ID scalars. */ +object IDValueGenFactory : ConfigKey(IDValueGen.Factory.default, Unvalidated) + /** probability that a selection set will contain field selections that are not wrapped in an inline fragment or a named fragment spread */ object FieldSelectionWeight : ConfigKey( CompoundingWeight(.4, 5), @@ -283,23 +286,8 @@ object ResolverExceptionWeight : ConfigKey(0.0, WeightValidator) */ object SelectedTypeBias : ConfigKey(.9, WeightValidator) -/** - * The probability that a `__typename` field value will be generated for an arbitrary object - * value. This weight is sampled independently of [ImplicitNullValueWeight]. - * - * For example, configuring [TypenameValueWeight] to 1.0 and [ImplicitNullValueWeight] to 1.0 - * causes a `__typename` field to be generated for every object value. - */ -object TypenameValueWeight : ConfigKey(.2, WeightValidator) - /** A schema type coordinate paired with an optional field name. */ typealias TypeOrFieldCoordinate = Pair /** A set of [TypeOrFieldCoordinate]s that a generator may not generate selections for. */ object BanSelectionCoordinates : ConfigKey>(emptySet(), Unvalidated) - -/** Minimum query text length accepted by [graphQLTestData]. */ -object MinQueryLength : ConfigKey(0, IntValidator(0..Int.MAX_VALUE)) - -/** Minimum number of external variables accepted by [graphQLTestData]. */ -object MinVariablesCount : ConfigKey(0, IntValidator(0..Int.MAX_VALUE)) diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/CycleGroups.kt similarity index 99% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/CycleGroups.kt index d8d889e82..13fd08af9 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/CycleGroups.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.schema.GraphQLInputObjectType import graphql.schema.GraphQLList diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/DocumentBuilder.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/DocumentBuilder.kt similarity index 99% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/DocumentBuilder.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/DocumentBuilder.kt index dcd343e55..63b8e246a 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/DocumentBuilder.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/DocumentBuilder.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.language.Argument import graphql.language.AstPrinter diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLDocuments.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLDocuments.kt similarity index 94% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLDocuments.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLDocuments.kt index 6aa5f8f4e..445645e16 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLDocuments.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLDocuments.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.introspection.Introspection.DirectiveLocation import graphql.introspection.Introspection.TypeNameMetaFieldDef @@ -66,45 +66,39 @@ internal interface DocumentGenEnv { val selectionSetGen: GraphQLSelectionSetGen val directiveGen: GraphQLDirectivesGen val argumentsGen: GraphQLArgumentsGen - val inputValueGenerator: GraphQLInputValueGenerator + val inputValueGenerator: GraphQLInputValueGen fun checkDepth(depth: Int): Boolean = depth <= cfg[MaxSelectionSetDepth] + private data class Impl( + override val schemas: Schemas, + override val cfg: Config, + override val rs: RandomSource + ) : DocumentGenEnv { + override val documentGen = GraphQLDocumentGen(this) + override val operationGen = GraphQLOperationGen(this) + override val selectionSetGen = GraphQLSelectionSetGen(this) + override val directiveGen = GraphQLDirectivesGen(this) + override val argumentsGen = GraphQLArgumentsGen(this) + override val inputValueGenerator = GraphQLInputValueGen( + schema = schemas.schema, + config = cfg, + randomSource = rs, + uncoercedValueWeight = cfg[DocumentUncoercedValueWeight], + allEdgesGraph = CycleGroups.allInputCycles(schemas.schema), + mandatoryEdgesGraph = CycleGroups.mandatoryInputCycles(schemas.schema) + ) + } + companion object { - operator fun invoke( - schema: GraphQLSchema, - cfg: Config, - rs: RandomSource - ): DocumentGenEnv = this(Schemas(schema), cfg, rs) + operator fun invoke(schema: GraphQLSchema, cfg: Config, rs: RandomSource): DocumentGenEnv = + this(Schemas(schema), cfg, rs) - operator fun invoke( - schemas: Schemas, - cfg: Config, - rs: RandomSource - ): DocumentGenEnv = DefaultDocumentGenEnv(schemas, cfg, rs) + operator fun invoke(schemas: Schemas, cfg: Config, rs: RandomSource): DocumentGenEnv = + Impl(schemas, cfg, rs) } } -private data class DefaultDocumentGenEnv( - override val schemas: Schemas, - override val cfg: Config, - override val rs: RandomSource -) : DocumentGenEnv { - override val documentGen = GraphQLDocumentGen(this) - override val operationGen = GraphQLOperationGen(this) - override val selectionSetGen = GraphQLSelectionSetGen(this) - override val directiveGen = GraphQLDirectivesGen(this) - override val argumentsGen = GraphQLArgumentsGen(this) - override val inputValueGenerator = GraphQLInputValueGenerator( - schema = schemas.schema, - config = cfg, - randomSource = rs, - uncoercedValueWeight = cfg[DocumentUncoercedValueWeight], - allEdgesGraph = CycleGroups.allInputCycles(schemas.schema), - mandatoryEdgesGraph = CycleGroups.mandatoryInputCycles(schemas.schema) - ) -} - internal data class DocumentGenCtx( val schemas: Schemas, val sb: SelectionsBuilder, @@ -538,7 +532,7 @@ internal class GraphQLArgumentsGen( } VariableReference(requireNotNull(variable.name)) } else { - inputValueGenerator.generate(type) + inputValueGenerator.gen(type) } } @@ -548,7 +542,7 @@ internal class GraphQLArgumentsGen( ): VariableDefinition { val default = if (rs.sampleWeight(cfg[DefaultValueWeight])) { - inputValueGenerator.generate(forType) + inputValueGenerator.gen(forType) } else { null } diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExecutionInputs.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLExecutionInputs.kt similarity index 92% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExecutionInputs.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLExecutionInputs.kt index 3d72b9412..21031cc27 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExecutionInputs.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLExecutionInputs.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.ExecutionInput import graphql.language.AstPrinter @@ -36,7 +36,7 @@ fun Arb.Companion.graphQLExecutionInput( config: Config = Config.default ): Arb = arbitrary { randomSource -> - GraphQLExecutionInputGen(schema, config, randomSource).generate(document) + GraphQLExecutionInputGen(schema, config, randomSource).gen(document) } internal class GraphQLExecutionInputGen( @@ -44,9 +44,9 @@ internal class GraphQLExecutionInputGen( private val config: Config, private val randomSource: RandomSource ) { - private val valueGenerator = GraphQLExternalInputValueGenerator(schema, config, randomSource) + private val valueGen = GraphQLExternalInputValueGen(schema, config, randomSource) - fun generate(document: Document): ExecutionInput { + fun gen(document: Document): ExecutionInput { val operations = document.getDefinitionsOfType(OperationDefinition::class.java) require(operations.isNotEmpty()) { "Document must define at least one operation" } val operation = Arb.of(operations).next(randomSource) @@ -55,7 +55,7 @@ internal class GraphQLExecutionInputGen( val type = definition.type.asSchemaType(schema) as GraphQLInputType val canOmit = definition.defaultValue != null || GraphQLTypeUtil.isNullable(type) if (!canOmit || !randomSource.sampleWeight(config[ImplicitNullValueWeight])) { - values[requireNotNull(definition.name)] = valueGenerator.generate(type) + values[requireNotNull(definition.name)] = valueGen.gen(type) } values } diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExternalInputValueGenerator.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLExternalInputValueGen.kt similarity index 53% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExternalInputValueGenerator.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLExternalInputValueGen.kt index d007f9a03..076fd6198 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExternalInputValueGenerator.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLExternalInputValueGen.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.schema.GraphQLEnumType import graphql.schema.GraphQLInputObjectField @@ -13,6 +13,7 @@ import io.kotest.property.Arb import io.kotest.property.RandomSource import io.kotest.property.arbitrary.int import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.numericDouble import io.kotest.property.arbitrary.of import io.kotest.property.arbitrary.string @@ -20,9 +21,10 @@ import io.kotest.property.arbitrary.string * Generates external variable values accepted by graphql-java for a GraphQL input type. * * Generation observes nullability, oneOf semantics, defaults, list coercion, and recursive input - * object cycles. Unknown custom scalars require an entry in [ScalarValueOverrides]. + * object cycles. Unknown custom scalars require an entry in [ScalarValueOverrides]. String ID + * values are generated by [IDValueGenFactory]. */ -internal class GraphQLExternalInputValueGenerator( +internal class GraphQLExternalInputValueGen( schema: GraphQLSchema, private val config: Config, private val randomSource: RandomSource, @@ -30,83 +32,108 @@ internal class GraphQLExternalInputValueGenerator( private val allEdgesGraph: CycleGroups = CycleGroups.allInputCycles(schema), private val mandatoryEdgesGraph: CycleGroups = CycleGroups.mandatoryInputCycles(schema) ) { - fun generate(type: GraphQLInputType): Any? = - generate(ExternalValueGenerationContext(type = type, maxDepth = config[MaxValueDepth])) + private val idValueGen by lazy { + config[IDValueGenFactory](IDValueGen.Factory.Params(schema, config, randomSource)) + } + + private data class Ctx( + val type: GraphQLInputType, + val depth: Int = 0, + val maxDepth: Int, + val nullable: Boolean = true + ) { + val overBudget: Boolean + get() = depth >= maxDepth + + fun push( + nextType: GraphQLInputType, + nullable: Boolean = nextType !is GraphQLNonNull + ): Ctx = + copy(type = nextType, depth = depth + 1, nullable = nullable) + + fun unwrap( + nextType: GraphQLInputType, + nullable: Boolean + ): Ctx = copy(type = nextType, nullable = nullable) + } - private fun generate(context: ExternalValueGenerationContext): Any? { - val type = context.type + fun gen(type: GraphQLInputType): Any? = + gen(Ctx(type = type, maxDepth = config[MaxValueDepth])) + + private fun gen(ctx: Ctx): Any? { + val type = ctx.type if (type is GraphQLNonNull) { - return generate(context.unwrap(type.wrappedType as GraphQLInputType, nullable = false)) + return gen(ctx.unwrap(type.wrappedType as GraphQLInputType, nullable = false)) } - if (context.nullable && shouldGenerateNull(context)) return null - if (type is GraphQLList) return generateList(type, context) - if (type is GraphQLScalarType) return generateScalar(type) + if (ctx.nullable && shouldGenerateNull(ctx)) return null + if (type is GraphQLList) return genList(type, ctx) + if (type is GraphQLScalarType) return genScalar(type) if (type is GraphQLEnumType) return Arb.of(type.values).next(randomSource).name - if (type is GraphQLInputObjectType && type.isOneOf) return generateOneOf(type, context) - if (type is GraphQLInputObjectType) return generateInputObject(type, context) + if (type is GraphQLInputObjectType && type.isOneOf) return genOneOf(type, ctx) + if (type is GraphQLInputObjectType) return genInputObject(type, ctx) throw UnsupportedOperationException("Unsupported input type: $type") } - private fun shouldGenerateNull(context: ExternalValueGenerationContext): Boolean = - context.overBudget || randomSource.sampleWeight(config[ExplicitNullValueWeight]) + private fun shouldGenerateNull(ctx: Ctx): Boolean = + ctx.overBudget || randomSource.sampleWeight(config[ExplicitNullValueWeight]) - private fun generateList( + private fun genList( listType: GraphQLList, - context: ExternalValueGenerationContext + ctx: Ctx ): Any? { val wrappedType = listType.wrappedType as GraphQLInputType - val itemContext = context.push(wrappedType) + val itemContext = ctx.push(wrappedType) val canUseSingleValueCoercion = GraphQLTypeUtil.unwrapNonNull(wrappedType) !is GraphQLList if (canUseSingleValueCoercion && !itemContext.overBudget && randomSource.sampleWeight(uncoercedValueWeight)) { - return generate(context.push(wrappedType, nullable = false)) + return gen(ctx.push(wrappedType, nullable = false)) } val size = if (itemContext.overBudget) 0 else Arb.int(config[ListValueSize]).next(randomSource) - return List(size) { generate(itemContext) } + return List(size) { gen(itemContext) } } - private fun generateScalar(type: GraphQLScalarType): Any? { + private fun genScalar(type: GraphQLScalarType): Any? { config[ScalarValueOverrides][type.name]?.let { return it.next(randomSource) } return when (type.name) { "Boolean" -> randomSource.random.nextBoolean() - "Float" -> generateFloat() - "Int" -> randomSource.random.nextInt() - "ID" -> generateId() - "String" -> generateString() + "Float" -> genFloat() + "Int" -> Arb.int().next(randomSource) + "ID" -> genId() + "String" -> genString() else -> throw UnsupportedOperationException( "No external value generator configured for scalar `${type.name}`" ) } } - private fun generateFloat(): Number = + private fun genFloat(): Number = if (randomSource.sampleWeight(uncoercedValueWeight)) { - randomSource.random.nextInt() + Arb.int().next(randomSource) } else { - randomSource.random.nextDouble(-1_000_000.0, 1_000_000.0) + Arb.numericDouble().next(randomSource) } - private fun generateId(): Any = + private fun genId(): Any = if (randomSource.sampleWeight(uncoercedValueWeight)) { - randomSource.random.nextInt() + Arb.int().next(randomSource) } else { - generateString() + idValueGen.gen() } - private fun generateString(): String = Arb.string(config[StringValueSize]).next(randomSource) + private fun genString(): String = Arb.string(config[StringValueSize]).next(randomSource) - private fun generateOneOf( + private fun genOneOf( type: GraphQLInputObjectType, - context: ExternalValueGenerationContext + ctx: Ctx ): Map { - val field = Arb.of(oneOfCandidates(type, context)).next(randomSource) - return mapOf(field.name to generate(context.push(field.type, nullable = false))) + val field = Arb.of(oneOfCandidates(type, ctx)).next(randomSource) + return mapOf(field.name to gen(ctx.push(field.type, nullable = false))) } private fun oneOfCandidates( type: GraphQLInputObjectType, - context: ExternalValueGenerationContext + ctx: Ctx ): List { - if (!context.overBudget) return type.fields + if (!ctx.overBudget) return type.fields val cycleGroup = mandatoryEdgesGraph[type.name] return type.fields.filter { field -> val unwrapped = GraphQLTypeUtil.unwrapAll(field.type) @@ -116,45 +143,25 @@ internal class GraphQLExternalInputValueGenerator( }.ifEmpty { type.fields } } - private fun generateInputObject( + private fun genInputObject( type: GraphQLInputObjectType, - context: ExternalValueGenerationContext + ctx: Ctx ): Map { val cycleGroup = allEdgesGraph[type.name] return type.fields - .filter { shouldIncludeField(it, cycleGroup, context) } - .associate { it.name to generate(context.push(it.type)) } + .filter { shouldIncludeField(it, cycleGroup, ctx) } + .associate { it.name to gen(ctx.push(it.type)) } } private fun shouldIncludeField( field: GraphQLInputObjectField, cycleGroup: Set, - context: ExternalValueGenerationContext + ctx: Ctx ): Boolean { val inputType = GraphQLTypeUtil.unwrapAll(field.type) as? GraphQLInputObjectType if (inputType != null && inputType.name in cycleGroup) return true if (!field.hasSetDefaultValue() && field.type is GraphQLNonNull) return true - return !context.overBudget && !randomSource.sampleWeight(config[ImplicitNullValueWeight]) + return !ctx.overBudget && !randomSource.sampleWeight(config[ImplicitNullValueWeight]) } -} -private data class ExternalValueGenerationContext( - val type: GraphQLInputType, - val depth: Int = 0, - val maxDepth: Int, - val nullable: Boolean = true -) { - val overBudget: Boolean - get() = depth >= maxDepth - - fun push( - nextType: GraphQLInputType, - nullable: Boolean = nextType !is GraphQLNonNull - ): ExternalValueGenerationContext = - copy(type = nextType, depth = depth + 1, nullable = nullable) - - fun unwrap( - nextType: GraphQLInputType, - nullable: Boolean - ): ExternalValueGenerationContext = copy(type = nextType, nullable = nullable) } diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLInputValueGenerator.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLInputValueGen.kt similarity index 60% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLInputValueGenerator.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLInputValueGen.kt index 35834f1f3..e1ebb5382 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLInputValueGenerator.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLInputValueGen.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.language.ArrayValue import graphql.language.BooleanValue @@ -23,18 +23,22 @@ import io.kotest.property.Arb import io.kotest.property.RandomSource import io.kotest.property.arbitrary.int import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.numericDouble import io.kotest.property.arbitrary.of import io.kotest.property.arbitrary.string import java.math.BigDecimal import java.math.BigInteger +// JMB TODO: lots of overlap between this and the ExternalInputValueGen. Can they be consolidated? + /** * Generates GraphQL language AST literals that are valid inputs for a schema type. * * Generation observes nullability, oneOf semantics, list coercion, defaults, and recursive input * object cycles. [uncoercedValueWeight] controls valid literals that rely on input coercion. + * String ID values are generated by [IDValueGenFactory]. */ -internal class GraphQLInputValueGenerator( +internal class GraphQLInputValueGen( schema: GraphQLSchema, private val config: Config, private val randomSource: RandomSource, @@ -42,97 +46,122 @@ internal class GraphQLInputValueGenerator( private val allEdgesGraph: CycleGroups = CycleGroups.mandatoryInputCycles(schema), private val mandatoryEdgesGraph: CycleGroups = CycleGroups.mandatoryInputCycles(schema) ) { - fun generate(type: GraphQLInputType): Value<*> = - generate(ValueGenerationContext(type = type, maxDepth = config[MaxValueDepth])) + private val idValueGen by lazy { + config[IDValueGenFactory](IDValueGen.Factory.Params(schema, config, randomSource)) + } + + private data class Ctx( + val type: GraphQLInputType, + val depth: Int = 0, + val maxDepth: Int, + val nullable: Boolean = true + ) { + val overBudget: Boolean + get() = depth >= maxDepth + + fun push( + nextType: GraphQLInputType, + nullable: Boolean = nextType !is GraphQLNonNull + ): Ctx = + copy(type = nextType, depth = depth + 1, nullable = nullable) + + fun unwrap( + nextType: GraphQLInputType, + nullable: Boolean + ): Ctx = copy(type = nextType, nullable = nullable) + } - private fun generate(context: ValueGenerationContext): Value<*> { - val type = context.type + fun gen(type: GraphQLInputType): Value<*> = + gen(Ctx(type = type, maxDepth = config[MaxValueDepth])) + + private fun gen(ctx: Ctx): Value<*> { + val type = ctx.type if (type is GraphQLNonNull) { - return generate(context.unwrap(type.wrappedType as GraphQLInputType, nullable = false)) + return gen(ctx.unwrap(type.wrappedType as GraphQLInputType, nullable = false)) } - if (context.nullable && shouldGenerateNull(context)) { + if (ctx.nullable && shouldGenerateNull(ctx)) { return NullValue.newNullValue().build() } - if (type is GraphQLList) return generateList(type, context) - if (type is GraphQLScalarType) return generateScalar(type) - if (type is GraphQLEnumType) return generateEnum(type) + if (type is GraphQLList) return genList(type, ctx) + if (type is GraphQLScalarType) return genScalar(type) + if (type is GraphQLEnumType) return genEnum(type) if (type is GraphQLInputObjectType && type.isOneOf) { - return generateOneOf(type, context) + return genOneOf(type, ctx) } - if (type is GraphQLInputObjectType) return generateInputObject(type, context) + if (type is GraphQLInputObjectType) return genInputObject(type, ctx) throw UnsupportedOperationException("Unsupported input type: $type") } - private fun shouldGenerateNull(context: ValueGenerationContext): Boolean = - context.overBudget || randomSource.sampleWeight(config[ExplicitNullValueWeight]) + private fun shouldGenerateNull(ctx: Ctx): Boolean = + ctx.overBudget || randomSource.sampleWeight(config[ExplicitNullValueWeight]) - private fun generateList( + private fun genList( listType: GraphQLList, - context: ValueGenerationContext + ctx: Ctx ): Value<*> { val wrappedType = listType.wrappedType as GraphQLInputType - val itemContext = context.push(wrappedType) + val itemContext = ctx.push(wrappedType) val itemCanUseSingleValueCoercion = GraphQLTypeUtil.unwrapNonNull(wrappedType) !is GraphQLList if (itemCanUseSingleValueCoercion && !itemContext.overBudget && randomSource.sampleWeight(uncoercedValueWeight)) { // A non-list literal is coerced as a single list item. Null is the exception: it // represents a null list, so it is not valid after a non-null list has been selected. - return generate(context.push(wrappedType, nullable = false)) + return gen(ctx.push(wrappedType, nullable = false)) } val size = if (itemContext.overBudget) 0 else Arb.int(config[ListValueSize]).next(randomSource) - val values = List(size) { generate(itemContext) } + val values = List(size) { gen(itemContext) } return ArrayValue.newArrayValue().values(values).build() } - private fun generateScalar(type: GraphQLScalarType): Value<*> { + private fun genScalar(type: GraphQLScalarType): Value<*> { config[ScalarLiteralOverrides][type.name]?.let { return it.next(randomSource) } return when (type.name) { "Boolean" -> BooleanValue.newBooleanValue(randomSource.random.nextBoolean()).build() - "Float" -> generateFloat() - "Int" -> IntValue.newIntValue(BigInteger.valueOf(randomSource.random.nextInt().toLong())).build() - "ID" -> generateId() - "String" -> generateString() - else -> generateString() + "Float" -> genFloat() + "Int" -> IntValue.newIntValue(BigInteger.valueOf(Arb.int().next(randomSource).toLong())).build() + "ID" -> genId() + "String" -> genString() + else -> genString() } } - private fun generateFloat(): Value<*> { + private fun genFloat(): Value<*> { if (randomSource.sampleWeight(uncoercedValueWeight)) { - return IntValue.newIntValue(BigInteger.valueOf(randomSource.random.nextInt().toLong())).build() + return IntValue.newIntValue(BigInteger.valueOf(Arb.int().next(randomSource).toLong())).build() } - val value = randomSource.random.nextDouble(-1_000_000.0, 1_000_000.0) + val value = Arb.numericDouble().next(randomSource) return FloatValue.newFloatValue(BigDecimal.valueOf(value)).build() } - private fun generateId(): Value<*> { + private fun genId(): Value<*> { if (randomSource.sampleWeight(uncoercedValueWeight)) { - return IntValue.newIntValue(BigInteger.valueOf(randomSource.random.nextInt().toLong())).build() + return IntValue.newIntValue(BigInteger.valueOf(Arb.int().next(randomSource).toLong())).build() } - return generateString() + return StringValue.newStringValue(idValueGen.gen()).build() } - private fun generateString(): StringValue = + private fun genString(): StringValue = StringValue.newStringValue(Arb.string(config[StringValueSize]).next(randomSource)).build() - private fun generateEnum(type: GraphQLEnumType): EnumValue { + private fun genEnum(type: GraphQLEnumType): EnumValue { val enumValue = Arb.of(type.values).next(randomSource) return EnumValue.newEnumValue(enumValue.name).build() } - private fun generateOneOf( + private fun genOneOf( type: GraphQLInputObjectType, - context: ValueGenerationContext + ctx: Ctx ): ObjectValue { - val candidates = oneOfCandidates(type, context) + val candidates = oneOfCandidates(type, ctx) val field = Arb.of(candidates).next(randomSource) - val value = generate(context.push(field.type, nullable = false)) + val value = gen(ctx.push(field.type, nullable = false)) return objectValue(field.name, value) } private fun oneOfCandidates( type: GraphQLInputObjectType, - context: ValueGenerationContext + ctx: Ctx ): List { - if (!context.overBudget) return type.fields + if (!ctx.overBudget) return type.fields val cycleGroup = mandatoryEdgesGraph[type.name] val exits = type.fields.filter { field -> val unwrapped = GraphQLTypeUtil.unwrapAll(field.type) @@ -143,17 +172,17 @@ internal class GraphQLInputValueGenerator( return exits.ifEmpty { type.fields } } - private fun generateInputObject( + private fun genInputObject( type: GraphQLInputObjectType, - context: ValueGenerationContext + ctx: Ctx ): ObjectValue { val cycleGroup = allEdgesGraph[type.name] val fields = type.fields.filter { field -> - shouldIncludeField(field, cycleGroup, context) + shouldIncludeField(field, cycleGroup, ctx) }.map { field -> ObjectField.newObjectField() .name(field.name) - .value(generate(context.push(field.type))) + .value(gen(ctx.push(field.type))) .build() } return ObjectValue.newObjectValue().objectFields(fields).build() @@ -162,12 +191,12 @@ internal class GraphQLInputValueGenerator( private fun shouldIncludeField( field: GraphQLInputObjectField, cycleGroup: Set, - context: ValueGenerationContext + ctx: Ctx ): Boolean { val inputType = GraphQLTypeUtil.unwrapAll(field.type) as? GraphQLInputObjectType if (inputType != null && inputType.name in cycleGroup) return true if (!field.hasSetDefaultValue() && field.type is GraphQLNonNull) return true - return !context.overBudget && !randomSource.sampleWeight(config[ImplicitNullValueWeight]) + return !ctx.overBudget && !randomSource.sampleWeight(config[ImplicitNullValueWeight]) } private fun objectValue(name: String, value: Value<*>): ObjectValue = @@ -175,24 +204,3 @@ internal class GraphQLInputValueGenerator( .objectField(ObjectField.newObjectField().name(name).value(value).build()) .build() } - -private data class ValueGenerationContext( - val type: GraphQLInputType, - val depth: Int = 0, - val maxDepth: Int, - val nullable: Boolean = true -) { - val overBudget: Boolean - get() = depth >= maxDepth - - fun push( - nextType: GraphQLInputType, - nullable: Boolean = nextType !is GraphQLNonNull - ): ValueGenerationContext = - copy(type = nextType, depth = depth + 1, nullable = nullable) - - fun unwrap( - nextType: GraphQLInputType, - nullable: Boolean - ): ValueGenerationContext = copy(type = nextType, nullable = nullable) -} diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLJavaExt.kt similarity index 54% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLJavaExt.kt index 3ac78bd19..3a676bb8e 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLJavaExt.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.language.Document import graphql.language.ListType @@ -9,16 +9,25 @@ import graphql.language.TypeName import graphql.parser.Parser import graphql.parser.ParserEnvironment import graphql.parser.ParserOptions +import graphql.schema.GraphQLDirective +import graphql.schema.GraphQLEnumType +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLInputObjectType import graphql.schema.GraphQLInputType import graphql.schema.GraphQLList import graphql.schema.GraphQLNamedType import graphql.schema.GraphQLNonNull import graphql.schema.GraphQLSchema +import graphql.schema.GraphQLSchemaElement import graphql.schema.GraphQLType +import graphql.schema.GraphQLTypeReference import graphql.schema.GraphQLTypeUtil +import graphql.schema.GraphQLUnmodifiedType import graphql.schema.idl.FastSchemaGenerator import graphql.schema.idl.RuntimeWiring import graphql.schema.idl.SchemaParser +import graphql.util.TraversalControl +import graphql.util.TraverserContext /** Return a mocked [GraphQLSchema] described by this String value. */ val String.asSchema: GraphQLSchema @@ -60,3 +69,49 @@ fun GraphQLType.asAstType(): Type<*> = is GraphQLNamedType -> TypeName(name) else -> throw UnsupportedOperationException("Unsupported schema type: $this") } + +internal val GraphQLInputType.asReference: GraphQLInputType + get() = + when (this) { + is GraphQLList -> + GraphQLList((wrappedType as GraphQLInputType).asReference) + is GraphQLNonNull -> + GraphQLNonNull((wrappedType as GraphQLInputType).asReference) + is GraphQLTypeReference -> this + is GraphQLUnmodifiedType -> GraphQLTypeReference(name) + else -> throw IllegalArgumentException("Unsupported type: $this") + } + +internal fun TraverserContext.unlessIntrospection( + fn: () -> TraversalControl +): TraversalControl { + val node = thisNode() + if (node is GraphQLNamedType && node.name.startsWith("__")) { + return TraversalControl.ABORT + } + if (node is GraphQLFieldDefinition && node.name.startsWith("__")) { + return TraversalControl.ABORT + } + return fn() +} + +internal fun TraverserContext.collectTraversedDirectives(): Set = + parentNodes.mapNotNull { (it as? GraphQLDirective)?.name }.toSet() + +internal fun TraverserContext.containingDirectiveName(): String? = + parentNodes.firstNotNullOfOrNull { (it as? GraphQLDirective)?.name } + +internal fun TraverserContext.collectContainingInputLikeTypeNames(): Set = + mutableSetOf().also { names -> + parentNodes.forEach { element -> + element.inputLikeTypeName()?.let(names::add) + } + thisNode().inputLikeTypeName()?.let(names::add) + } + +internal fun GraphQLSchemaElement.inputLikeTypeName(): String? = + when (this) { + is GraphQLInputObjectType -> name + is GraphQLEnumType -> name + else -> null + } diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLNames.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLNames.kt similarity index 72% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLNames.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLNames.kt index 5d22737d3..103cb8b04 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLNames.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLNames.kt @@ -1,42 +1,41 @@ -package graphql.schema.property +package graphql.arbitrary import io.kotest.property.Arb -import io.kotest.property.arbitrary.flatMap import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.set import io.kotest.property.arbitrary.withEdgecases /** Bucketed sets of names that can be used in building GraphQL types */ class GraphQLNames internal constructor( - internal val names: Map> + internal val names: Map> ) { operator fun plus(other: GraphQLNames): GraphQLNames = GraphQLNames( - TypeType + TypeKind .values() .associateWith { nt -> ((names[nt] ?: emptySet()) + (other.names[nt] ?: emptySet())) } ) val interfaces: Set - get() = names[TypeType.Interface] ?: emptySet() + get() = names[TypeKind.Interface] ?: emptySet() val objects: Set - get() = names[TypeType.Object] ?: emptySet() + get() = names[TypeKind.Object] ?: emptySet() val inputs: Set - get() = names[TypeType.Input] ?: emptySet() + get() = names[TypeKind.Input] ?: emptySet() val unions: Set - get() = names[TypeType.Union] ?: emptySet() + get() = names[TypeKind.Union] ?: emptySet() val scalars: Set - get() = names[TypeType.Scalar] ?: emptySet() + get() = names[TypeKind.Scalar] ?: emptySet() val enums: Set - get() = names[TypeType.Enum] ?: emptySet() + get() = names[TypeKind.Enum] ?: emptySet() val directives: Set - get() = names[TypeType.Directive] ?: emptySet() + get() = names[TypeKind.Directive] ?: emptySet() val allNames: Set get() = names.values.flatten().toSet() @@ -56,7 +55,7 @@ class GraphQLNames internal constructor( companion object { val empty: GraphQLNames = GraphQLNames(emptyMap()) - private fun List.prefix(nameType: TypeType): List = + private fun List.prefix(nameType: TypeKind): List = this.map { buildString { append(nameType.name) @@ -75,35 +74,34 @@ class GraphQLNames internal constructor( cfg: Config = Config.default ): GraphQLNames { tailrec fun loop( - acc: Map>, + acc: Map>, pool: List, - typeTypeCounts: List> + typeKindCounts: List> ): GraphQLNames = - if (typeTypeCounts.isNotEmpty()) { - val (tt, count) = typeTypeCounts.first() + if (typeKindCounts.isNotEmpty()) { + val (tt, count) = typeKindCounts.first() val entry = tt to pool.take(count).prefix(tt).toSet() loop( acc = acc + entry, pool = pool.drop(count), - typeTypeCounts = typeTypeCounts.drop(1), + typeKindCounts = typeKindCounts.drop(1), ) } else { GraphQLNames(acc) } - val typeTypes = - TypeType.values().let { tts -> + val typeKinds = + TypeKind.values().let { tts -> tts.filter { when (it) { - TypeType.Scalar -> cfg[GenCustomScalars] + TypeKind.Scalar -> cfg[GenCustomScalars] else -> true } } } - val typeTypeCounts = typeTypes - .associateWith { tt -> - cfg[TypeTypeWeights][tt] ?: 1.0 - }.let { ttWeights -> + val typeKindCounts = typeKinds + .associateWith { tt -> cfg[TypeKindWeights][tt] ?: 1.0 } + .let { ttWeights -> // normalize val total = ttWeights.values.sum() ttWeights.mapValues { (_, weight) -> @@ -114,7 +112,7 @@ class GraphQLNames internal constructor( return loop( acc = emptyMap(), pool = names, - typeTypeCounts = typeTypeCounts.toList() + typeKindCounts = typeKindCounts.toList() ) } } @@ -134,24 +132,24 @@ fun Arb.Companion.graphQLNames(cfg: Config = Config.default): Arb }.withEdgecases(GraphQLNames.empty) .map { names -> if (cfg[IncludeBuiltinScalars]) { - names + GraphQLNames(mapOf(TypeType.Scalar to builtinScalars.keys)) + names + GraphQLNames(mapOf(TypeKind.Scalar to builtinScalars.keys)) } else { names } }.map { names -> if (cfg[IncludeBuiltinDirectives]) { - names + GraphQLNames(mapOf(TypeType.Directive to builtinDirectives.keys)) + names + GraphQLNames(mapOf(TypeKind.Directive to builtinDirectives.keys)) } else { names } }.map { names -> val ban = cfg[BanDirectiveNames] if (ban.isNotEmpty()) { - val filteredDirNames = names.names[TypeType.Directive] + val filteredDirNames = names.names[TypeKind.Directive] ?.filter { !ban.contains(it) } ?.toSet() ?: emptySet() - GraphQLNames(names.names + (TypeType.Directive to filteredDirNames)) + GraphQLNames(names.names + (TypeKind.Directive to filteredDirNames)) } else { names } @@ -162,6 +160,4 @@ fun Arb.Companion.graphQLNames(cfg: Config = Config.default): Arb } else { names } - } - -fun Arb.Companion.graphQLNames(cfg: Arb): Arb = cfg.flatMap(::graphQLNames) + } \ No newline at end of file diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLOutputValues.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLOutputValues.kt similarity index 81% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLOutputValues.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLOutputValues.kt index a592e25fb..6fb751f5c 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLOutputValues.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLOutputValues.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.language.Document import graphql.language.Field @@ -16,12 +16,12 @@ import graphql.schema.GraphQLObjectType import graphql.schema.GraphQLOutputType import graphql.schema.GraphQLScalarType import graphql.schema.GraphQLSchema -import graphql.schema.GraphQLTypeUtil import io.kotest.property.Arb import io.kotest.property.RandomSource import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.int import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.numericDouble import io.kotest.property.arbitrary.of import io.kotest.property.arbitrary.string @@ -34,8 +34,8 @@ data class GeneratedObjectValue( /** * Generate a native result value for [type] containing exactly the requested [selections]. * - * Supported configuration keys are [ExplicitNullValueWeight], [ListValueSize], - * [ScalarValueOverrides], [SelectedTypeBias], and [StringValueSize]. + * Supported configuration keys are [ExplicitNullValueWeight], [IDValueGenFactory], + * [ListValueSize], [ScalarValueOverrides], [SelectedTypeBias], and [StringValueSize]. */ fun Arb.Companion.graphQLOutputValue( schema: GraphQLSchema, @@ -45,8 +45,8 @@ fun Arb.Companion.graphQLOutputValue( config: Config = Config.default ): Arb = arbitrary { randomSource -> - GraphQLOutputValueGenerator(schema, config, randomSource) - .generate(type, selections, fragments) + GraphQLOutputValueGen(schema, config, randomSource) + .gen(type, selections, fragments) } /** Generate a native result value for one operation in [document]. */ @@ -62,55 +62,58 @@ fun Arb.Companion.graphQLOutputValue( require(operations.isNotEmpty()) { "Document must define at least one operation" } val operation = Arb.of(operations).next(randomSource) val rootType = operation.rootType(schema) - GraphQLOutputValueGenerator(schema, config, randomSource) - .generate(rootType, operation.selectionSet, fragments) as GeneratedObjectValue + GraphQLOutputValueGen(schema, config, randomSource) + .gen(rootType, operation.selectionSet, fragments) as GeneratedObjectValue } -private class GraphQLOutputValueGenerator( +private class GraphQLOutputValueGen( private val schema: GraphQLSchema, private val config: Config, private val randomSource: RandomSource ) { - private val relationships = SchemaRelationships(schema) + private val rels = TypeRelations(schema) + private val idValueGen by lazy { + config[IDValueGenFactory](IDValueGen.Factory.Params(schema, config, randomSource)) + } - fun generate( + fun gen( type: GraphQLOutputType, selections: SelectionSet?, fragments: Map - ): Any? = generate(type, selections, fragments, nullable = true) + ): Any? = gen(type, selections, fragments, nullable = true) - private fun generate( + private fun gen( type: GraphQLOutputType, selections: SelectionSet?, fragments: Map, nullable: Boolean ): Any? { if (type is GraphQLNonNull) { - return generate(type.wrappedType as GraphQLOutputType, selections, fragments, nullable = false) + return gen(type.wrappedType as GraphQLOutputType, selections, fragments, nullable = false) } if (nullable && randomSource.sampleWeight(config[ExplicitNullValueWeight])) return null - if (type is GraphQLList) return generateList(type, selections, fragments) - if (type is GraphQLObjectType) return generateObject(type, requireNotNull(selections), fragments) + if (type is GraphQLList) return genList(type, selections, fragments) + if (type is GraphQLObjectType) return genObject(type, requireNotNull(selections), fragments) if (type is GraphQLCompositeType) { val concreteType = concretize(type, requireNotNull(selections), fragments) - return generateObject(concreteType, selections, fragments) + return genObject(concreteType, selections, fragments) } if (type is GraphQLEnumType) return Arb.of(type.values).next(randomSource).value - if (type is GraphQLScalarType) return generateScalar(type) + if (type is GraphQLScalarType) return genScalar(type) throw UnsupportedOperationException("Unsupported output type: $type") } - private fun generateList( + private fun genList( type: GraphQLList, selections: SelectionSet?, fragments: Map ): List { val itemType = type.wrappedType as GraphQLOutputType val size = Arb.int(config[ListValueSize]).next(randomSource) - return List(size) { generate(itemType, selections, fragments, nullable = true) } + return List(size) { gen(itemType, selections, fragments, nullable = true) } } - private fun generateObject( + private fun genObject( type: GraphQLObjectType, selections: SelectionSet, fragments: Map @@ -143,7 +146,7 @@ private class GraphQLOutputValueGenerator( val definition = requireNotNull(type.getFieldDefinition(field.name)) { "Unexpected field: ${type.name}.${field.name}" } - val value = generate(definition.type, field.selectionSet, fragments, nullable = true) + val value = gen(definition.type, field.selectionSet, fragments, nullable = true) return mapOf(field.resultKey to value) } @@ -153,8 +156,8 @@ private class GraphQLOutputValueGenerator( fragments: Map ): Map { val fragmentType = schema.requireCompositeType(requireNotNull(fragment.typeCondition.name)) - if (!relationships.isSpreadable(type, fragmentType)) return emptyMap() - return generateObject(type, fragment.selectionSet, fragments).fields + if (!rels.isSpreadable(type, fragmentType)) return emptyMap() + return genObject(type, fragment.selectionSet, fragments).fields } private fun generateInlineFragment( @@ -163,8 +166,8 @@ private class GraphQLOutputValueGenerator( fragments: Map ): Map { val fragmentType = fragment.typeCondition?.name?.let(schema::requireCompositeType) ?: type - if (!relationships.isSpreadable(type, fragmentType)) return emptyMap() - return generateObject(type, fragment.selectionSet, fragments).fields + if (!rels.isSpreadable(type, fragmentType)) return emptyMap() + return genObject(type, fragment.selectionSet, fragments).fields } private fun concretize( @@ -173,7 +176,7 @@ private class GraphQLOutputValueGenerator( fragments: Map ): GraphQLObjectType { if (type is GraphQLObjectType) return type - val possibleTypes = relationships.possibleObjectTypes(type) + val possibleTypes = rels.possibleObjectTypes(type) require(possibleTypes.isNotEmpty()) { "Cannot generate a value for abstract type `${type.name}`: no implementations found" } @@ -211,13 +214,13 @@ private class GraphQLOutputValueGenerator( return selectedTypes } - private fun generateScalar(type: GraphQLScalarType): Any? { + private fun genScalar(type: GraphQLScalarType): Any? { config[ScalarValueOverrides][type.name]?.let { return it.next(randomSource) } return when (type.name) { "Boolean" -> randomSource.random.nextBoolean() - "Float" -> randomSource.random.nextDouble(-1_000_000.0, 1_000_000.0) - "Int" -> randomSource.random.nextInt() - "ID" -> Arb.string(config[StringValueSize]).next(randomSource) + "Float" -> Arb.numericDouble().next(randomSource) + "Int" -> Arb.int().next(randomSource) + "ID" -> idValueGen.gen() "String" -> Arb.string(config[StringValueSize]).next(randomSource) else -> throw UnsupportedOperationException( "No external value generator configured for scalar `${type.name}`" diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLRuntimeWirings.kt similarity index 79% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLRuntimeWirings.kt index e5ba5ad74..c08ed2598 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLRuntimeWirings.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.GraphQL import graphql.TypeResolutionEnvironment @@ -26,6 +26,7 @@ import io.kotest.property.Arb import io.kotest.property.RandomSource import io.kotest.property.arbitrary.int import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.numericDouble import io.kotest.property.arbitrary.of import io.kotest.property.arbitrary.string @@ -34,23 +35,24 @@ import io.kotest.property.arbitrary.string * * Although the returned data is arbitrary, a given [seed] always produces the same data for * the same GraphQL document and variables. Supported configuration keys are - * [ExplicitNullValueWeight], [ListValueSize], [NullNonNullableWeight], - * [ResolverExceptionWeight], [ScalarValueOverrides], and [StringValueSize]. + * [ExplicitNullValueWeight], [IDValueGenFactory], [ListValueSize], + * [NullNonNullableWeight], [ResolverExceptionWeight], [ScalarValueOverrides], + * and [StringValueSize]. */ -fun arbitraryCodeRegistry( +fun arbCodeRegistry( schema: GraphQLSchema, seed: Long, config: Config = Config.default -): GraphQLCodeRegistry = ArbitraryCodeRegistryGenerator(schema, seed, config).generate() +): GraphQLCodeRegistry = GraphQLCodeRegistryGen(schema, seed, config).gen() /** Create an executable [GraphQL] instance backed by deterministic arbitrary data. */ -fun arbitraryGraphQL( +fun arbGraphQL( schema: GraphQLSchema, seed: Long, config: Config = Config.default ): GraphQL { val executableSchema = schema.transform { builder -> - builder.codeRegistry(arbitraryCodeRegistry(schema, seed, config)) + builder.codeRegistry(arbCodeRegistry(schema, seed, config)) } return GraphQL.newGraphQL(executableSchema).build() } @@ -63,8 +65,9 @@ fun arbitraryGraphQL( * given graphql document. * * The properties of the generated values can be configured in [cfg]. Supported [ConfigKey]s - * are [ExplicitNullValueWeight], [ListValueSize], [NullNonNullableWeight], - * [ResolverExceptionWeight], [ScalarValueOverrides], and [StringValueSize]. + * are [ExplicitNullValueWeight], [IDValueGenFactory], [ListValueSize], + * [NullNonNullableWeight], [ResolverExceptionWeight], [ScalarValueOverrides], + * and [StringValueSize]. */ fun arbRuntimeWiring( sdl: String, @@ -72,7 +75,7 @@ fun arbRuntimeWiring( cfg: Config = Config.default ): RuntimeWiring { val schema = sdl.asSchema - val codeRegistry = arbitraryCodeRegistry(schema, seed, cfg) + val codeRegistry = arbCodeRegistry(schema, seed, cfg) val builder = RuntimeWiring.newRuntimeWiring() schema.allTypesAsList.forEach { type -> when (type) { @@ -97,14 +100,14 @@ fun arbRuntimeWiring( return builder.build() } -private class ArbitraryCodeRegistryGenerator( +private class GraphQLCodeRegistryGen( private val schema: GraphQLSchema, private val seed: Long, private val config: Config ) { - private val relationships = SchemaRelationships(schema) + private val rels = TypeRelations(schema) - fun generate(): GraphQLCodeRegistry { + fun gen(): GraphQLCodeRegistry { val builder = GraphQLCodeRegistry.newCodeRegistry(schema.codeRegistry) schema.allTypesAsList.forEach { type -> if (type.name.startsWith("__")) return@forEach @@ -126,7 +129,7 @@ private class ArbitraryCodeRegistryGenerator( builder.dataFetcher(coordinates, DataFetcher { environment: DataFetchingEnvironment -> val randomSource = saltedRandom(environment.stableHash) maybeThrowResolverException(randomSource) - generateValue(environment.fieldType, randomSource) + genValue(environment.fieldType, randomSource) }) } } @@ -138,7 +141,7 @@ private class ArbitraryCodeRegistryGenerator( builder.typeResolver(type.name) { environment -> val randomSource = saltedRandom(environment.stableHash) maybeThrowResolverException(randomSource) - val possibleTypes = relationships.possibleObjectTypes(type).toList() + val possibleTypes = rels.possibleObjectTypes(type).toList() val selectedType = Arb.of(possibleTypes).next(randomSource) environment.schema.getObjectType(selectedType.name) } @@ -150,32 +153,35 @@ private class ArbitraryCodeRegistryGenerator( } } - private fun generateValue(type: GraphQLOutputType, randomSource: RandomSource): Any? { + private fun genValue(type: GraphQLOutputType, randomSource: RandomSource): Any? { if (type is GraphQLNonNull && randomSource.sampleWeight(config[NullNonNullableWeight])) return null if (type !is GraphQLNonNull && randomSource.sampleWeight(config[ExplicitNullValueWeight])) return null val unwrappedType = GraphQLTypeUtil.unwrapNonNull(type) as GraphQLOutputType return when (unwrappedType) { - is GraphQLList -> generateList(unwrappedType, randomSource) - is GraphQLScalarType -> generateScalar(unwrappedType, randomSource) + is GraphQLList -> genList(unwrappedType, randomSource) + is GraphQLScalarType -> genScalar(unwrappedType, randomSource) is GraphQLEnumType -> Arb.of(unwrappedType.values).next(randomSource).value else -> emptyMap() } } - private fun generateList(type: GraphQLList, randomSource: RandomSource): List { + private fun genList(type: GraphQLList, randomSource: RandomSource): List { val itemType = type.wrappedType as GraphQLOutputType val size = Arb.int(config[ListValueSize]).next(randomSource) - return List(size) { generateValue(itemType, randomSource) } + return List(size) { genValue(itemType, randomSource) } } - private fun generateScalar(type: GraphQLScalarType, randomSource: RandomSource): Any? { + private fun genScalar(type: GraphQLScalarType, randomSource: RandomSource): Any? { config[ScalarValueOverrides][type.name]?.let { return it.next(randomSource) } return when (type.name) { "Boolean" -> randomSource.random.nextBoolean() - "Float" -> randomSource.random.nextDouble(-1_000_000.0, 1_000_000.0) - "Int" -> randomSource.random.nextInt() - "ID" -> Arb.string(config[StringValueSize]).next(randomSource) + "Float" -> Arb.numericDouble().next(randomSource) + "Int" -> Arb.int().next(randomSource) + "ID" -> + config[IDValueGenFactory]( + IDValueGen.Factory.Params(schema, config, randomSource) + ).gen() "String" -> Arb.string(config[StringValueSize]).next(randomSource) else -> throw UnsupportedOperationException( "No external value generator configured for scalar `${type.name}`" @@ -206,6 +212,7 @@ private val DataFetchingEnvironment.stableHash: Long private val TypeResolutionEnvironment.stableHash: Long get() = arguments.hashCode().toLong() xor field.stableHash +// JMB TODO: maybe rename to DataFetcherExceptionWeight and DataFetcherInjectedException /** Exception deliberately thrown when [ResolverExceptionWeight] selects an injected failure. */ internal class ResolverInjectedException : RuntimeException( "Injected exception, see ResolverExceptionWeight" diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLSchemas.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLSchemas.kt similarity index 88% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLSchemas.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLSchemas.kt index cdc17f8db..d7688a6ed 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLSchemas.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLSchemas.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.Directives import graphql.Scalars @@ -24,12 +24,8 @@ import graphql.schema.GraphQLSchemaElement import graphql.schema.GraphQLTypeReference import graphql.schema.GraphQLTypeVisitorStub import graphql.schema.GraphQLUnionType -import graphql.schema.GraphQLUnmodifiedType import graphql.schema.SchemaTransformer import graphql.schema.TypeResolver -import graphql.schema.idl.FastSchemaGenerator -import graphql.schema.idl.RuntimeWiring -import graphql.schema.idl.SchemaParser import graphql.util.TraversalControl import graphql.util.Traverser import graphql.util.TraverserContext @@ -54,11 +50,11 @@ fun Arb.Companion.graphQLSchema( cfg: Config = Config.default ): Arb = arbitrary { rs -> - SchemaGenerator(cfg, rs).createSchema(types) + GraphQLSchemaGen(cfg, rs).gen(types) } /** Methods for generating GraphQLSchema instances */ -internal class SchemaGenerator(val cfg: Config, val rs: RandomSource) { +internal class GraphQLSchemaGen(val cfg: Config, val rs: RandomSource) { private val emptyQuery: GraphQLObjectType = GraphQLObjectType .newObject() @@ -71,25 +67,8 @@ internal class SchemaGenerator(val cfg: Config, val rs: RandomSource) { .build() ).build() - private fun codeRegistry(types: GraphQLTypes): GraphQLCodeRegistry { - val typeResolver = TypeResolver { - throw UnsupportedOperationException("Generated schemas do not provide runtime type resolution") - } - - return GraphQLCodeRegistry - .newCodeRegistry() - .also { - types.interfaces.forEach { (name, _) -> - it.typeResolver(name, typeResolver) - } - types.unions.forEach { (name, _) -> - it.typeResolver(name, typeResolver) - } - }.build() - } - /** Generate a GraphQLSchema from a GraphQLTypes */ - fun createSchema(types: GraphQLTypes): GraphQLSchema { + fun gen(types: GraphQLTypes): GraphQLSchema { val queryType = types.objects.entries.firstOrNull()?.value ?: emptyQuery return GraphQLSchema .newSchema() @@ -106,13 +85,25 @@ internal class SchemaGenerator(val cfg: Config, val rs: RandomSource) { .let(::finalize) } - fun createSchema(sdl: String): GraphQLSchema { - val tdr = SchemaParser().parse(sdl) - return FastSchemaGenerator() - .makeExecutableSchema(tdr, RuntimeWiring.MOCKED_WIRING) + private fun codeRegistry(types: GraphQLTypes): GraphQLCodeRegistry { + val typeResolver = TypeResolver { + throw UnsupportedOperationException("Generated schemas do not provide runtime type resolution") + } + + return GraphQLCodeRegistry + .newCodeRegistry() + .also { + types.interfaces.forEach { (name, _) -> + it.typeResolver(name, typeResolver) + } + types.unions.forEach { (name, _) -> + it.typeResolver(name, typeResolver) + } + }.build() } - fun finalize(schema: GraphQLSchema): GraphQLSchema { + + private fun finalize(schema: GraphQLSchema): GraphQLSchema { val withDefaults = addDefaults(schema) val valueGenerator = inputValueGenerator(withDefaults) return SchemaTransformer.transformSchema( @@ -127,8 +118,8 @@ internal class SchemaGenerator(val cfg: Config, val rs: RandomSource) { return SchemaTransformer.transformSchema(schema, AddDefaults(valueGenerator, cfg, rs)) } - private fun inputValueGenerator(schema: GraphQLSchema): GraphQLInputValueGenerator = - GraphQLInputValueGenerator( + private fun inputValueGenerator(schema: GraphQLSchema): GraphQLInputValueGen = + GraphQLInputValueGen( schema = schema, config = cfg, randomSource = rs, @@ -140,7 +131,7 @@ internal class SchemaGenerator(val cfg: Config, val rs: RandomSource) { /** Adds generated literal defaults to arguments and non-oneOf input fields. */ internal class AddDefaults( - private val inputValueGenerator: GraphQLInputValueGenerator, + private val inputValueGenerator: GraphQLInputValueGen, private val cfg: Config, private val rs: RandomSource ) : GraphQLTypeVisitorStub() { @@ -194,7 +185,7 @@ internal class AddDefaults( } private fun genDefaultValue(type: GraphQLInputType): Value<*> { - return inputValueGenerator.generate(type) + return inputValueGenerator.gen(type) } } @@ -206,7 +197,7 @@ private data class DirectiveDependencyInfo( /** Adds schema-valid directive applications while avoiding directive dependency cycles. */ internal class AddAppliedDirectives( private val schema: GraphQLSchema, - private val inputValueGenerator: GraphQLInputValueGenerator, + private val inputValueGenerator: GraphQLInputValueGen, private val cfg: Config, private val rs: RandomSource ) : GraphQLTypeVisitorStub() { @@ -406,7 +397,7 @@ internal class AddAppliedDirectives( .transform { val args = dir.arguments.map { arg -> arg.toAppliedArgument().transform { b -> - b.valueLiteral(inputValueGenerator.generate(arg.type)) + b.valueLiteral(inputValueGenerator.gen(arg.type)) // to placate graphql-java's type-consistency checker, set the type to a GraphQLTypeReference b.type(arg.type.asReference) } @@ -558,33 +549,6 @@ internal class AddAppliedDirectives( } } - private val GraphQLInputType.asReference: GraphQLInputType - get() = - when (this) { - is GraphQLList -> - GraphQLList((this.wrappedType as GraphQLInputType).asReference) - is GraphQLNonNull -> - GraphQLNonNull((this.wrappedType as GraphQLInputType).asReference) - is GraphQLTypeReference -> this - is GraphQLUnmodifiedType -> GraphQLTypeReference(name) - else -> throw IllegalArgumentException("Unsupported type: $this") - } - - private fun TraverserContext.unlessIntrospection(fn: () -> TraversalControl): TraversalControl { - val node = thisNode() - if (node is GraphQLNamedType && node.name.startsWith("__")) { - return TraversalControl.ABORT - } - if (node is GraphQLFieldDefinition && node.name.startsWith("__")) { - return TraversalControl.ABORT - } - return fn() - } - - private fun TraverserContext.collectTraversedDirectives(): Set = parentNodes.mapNotNull { (it as? GraphQLDirective)?.name }.toSet() - - private fun TraverserContext.containingDirectiveName(): String? = parentNodes.firstNotNullOfOrNull { (it as? GraphQLDirective)?.name } - private fun TraverserContext.ownerDirectiveNames(): Set = mutableSetOf().also { names -> containingDirectiveName()?.let(names::add) @@ -592,19 +556,4 @@ internal class AddAppliedDirectives( names.addAll(directivesByReferencedInputLikeType[typeName].orEmpty()) } } - - private fun TraverserContext.collectContainingInputLikeTypeNames(): Set = - mutableSetOf().also { names -> - parentNodes.forEach { element -> - element.inputLikeTypeName()?.let(names::add) - } - thisNode().inputLikeTypeName()?.let(names::add) - } - - private fun GraphQLSchemaElement.inputLikeTypeName(): String? = - when (this) { - is GraphQLInputObjectType -> name - is GraphQLEnumType -> name - else -> null - } } diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLStrings.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLStrings.kt similarity index 98% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLStrings.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLStrings.kt index 06e66ad07..cd96b5064 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLStrings.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLStrings.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTypes.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLTypes.kt similarity index 98% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTypes.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLTypes.kt index 986836635..a2a631049 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTypes.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/GraphQLTypes.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.Directives import graphql.Scalars @@ -103,7 +103,7 @@ fun Arb.Companion.graphQLTypes( private class InputTypeDescriptor( val underlyingTypeName: String, - val underlyingTypeType: TypeType, + val underlyingTypeKind: TypeKind, val type: GraphQLInputType ) { // This is used to ensure that we do not create cyclic references that graphql-java will reject. @@ -238,7 +238,7 @@ internal class GraphQLTypesGen( * in GraphQLNonNull and GraphQLList wrappers. */ private fun genInputTypeDescriptor(allowNonNullable: Boolean = true): Arb { - val namePools = setOf(TypeType.Scalar, TypeType.Input, TypeType.Enum) + val namePools = setOf(TypeKind.Scalar, TypeKind.Input, TypeKind.Enum) .mapNotNull { tt -> names.names[tt] ?.takeIf { it.isNotEmpty() } @@ -252,7 +252,7 @@ internal class GraphQLTypesGen( }.map { (tt, name) -> InputTypeDescriptor( underlyingTypeName = name, - underlyingTypeType = tt, + underlyingTypeKind = tt, type = decorate(GraphQLTypeReference.typeRef(name), allowNonNullable) as GraphQLInputType ) } @@ -479,7 +479,7 @@ internal class GraphQLTypesGen( .asSequence(rs) .take(fieldCount * 100) .filter { itd -> - if (itd.usedNonNullably && itd.underlyingTypeType == TypeType.Input) { + if (itd.usedNonNullably && itd.underlyingTypeKind == TypeKind.Input) { itd.underlyingTypeName.compareTo(hostType) < 0 } else { true diff --git a/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/IDValueGen.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/IDValueGen.kt new file mode 100644 index 000000000..4118627fc --- /dev/null +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/IDValueGen.kt @@ -0,0 +1,36 @@ +package graphql.arbitrary + +import graphql.schema.GraphQLSchema +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.string + +/** A generator that produces native String values for GraphQL ID scalars. */ +fun interface IDValueGen { + fun gen(): String + + /** A factory for producing [IDValueGen]s. */ + fun interface Factory { + data class Params( + val schema: GraphQLSchema, + val cfg: Config, + val rs: RandomSource + ) + + operator fun invoke(params: Params): IDValueGen + + /** Produces arbitrary strings with sizes bounded by [StringValueSize]. */ + object ArbString : Factory { + override fun invoke(params: Params): IDValueGen = + IDValueGen { + Arb.string(params.cfg[StringValueSize]).next(params.rs) + } + } + + companion object { + /** The default ID value generator factory. */ + val default: Factory = ArbString + } + } +} diff --git a/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Schemas.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Schemas.kt new file mode 100644 index 000000000..81804eae1 --- /dev/null +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Schemas.kt @@ -0,0 +1,29 @@ +package graphql.arbitrary + +import graphql.introspection.Introspection.DirectiveLocation +import graphql.schema.GraphQLDirective +import graphql.schema.GraphQLSchema + +/** A cache of relationships used while generating documents for a GraphQL schema. */ +internal interface Schemas { + val schema: GraphQLSchema + val rels: TypeRelations + val directivesByLocation: Map> + + private class Impl(override val schema: GraphQLSchema) : Schemas { + override val rels = TypeRelations(schema) + + override val directivesByLocation: Map> = + schema.directives + .flatMap { directive -> + directive.validLocations().map { location -> directive to location } + } + .groupBy({ it.second }, { it.first }) + .mapValues { (_, directives) -> directives.toSet() } + } + + companion object { + /** create a Schemas from the provided [schema] */ + operator fun invoke(schema: GraphQLSchema): Schemas = Impl(schema) + } +} diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/TypeType.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/TypeKind.kt similarity index 71% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/TypeType.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/TypeKind.kt index 7f761c419..5120e84ab 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/TypeType.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/TypeKind.kt @@ -1,7 +1,7 @@ -package graphql.schema.property +package graphql.arbitrary /** An enumeration of GraphQL object flavors */ -enum class TypeType { +enum class TypeKind { Interface, Object, Input, diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/SchemaRelationships.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/TypeRelations.kt similarity index 59% rename from graphql-java-property-testing/src/main/kotlin/graphql/schema/property/SchemaRelationships.kt rename to graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/TypeRelations.kt index 408c15750..b7d041c75 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/SchemaRelationships.kt +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/TypeRelations.kt @@ -1,40 +1,14 @@ -package graphql.schema.property +package graphql.arbitrary -import graphql.introspection.Introspection.DirectiveLocation import graphql.schema.GraphQLCompositeType -import graphql.schema.GraphQLDirective import graphql.schema.GraphQLInterfaceType import graphql.schema.GraphQLObjectType import graphql.schema.GraphQLSchema import graphql.schema.GraphQLUnionType -/** A cache of relationships used while generating documents for a GraphQL schema. */ -internal interface Schemas { - val schema: GraphQLSchema - val rels: SchemaRelationships - val directivesByLocation: Map> - - companion object { - operator fun invoke(schema: GraphQLSchema): Schemas = DefaultSchemas(schema) - } -} - -private class DefaultSchemas( - override val schema: GraphQLSchema -) : Schemas { - override val rels = SchemaRelationships(schema) - - override val directivesByLocation: Map> = - schema.directives - .flatMap { directive -> - directive.validLocations().map { location -> directive to location } - } - .groupBy({ it.second }, { it.first }) - .mapValues { (_, directives) -> directives.toSet() } -} - +// JMB TODO: maybe bring over full type relations /** Computes the fragment-spread relationships between composite schema types. */ -internal class SchemaRelationships(private val schema: GraphQLSchema) { +internal class TypeRelations(private val schema: GraphQLSchema) { private val compositeTypes = schema.allTypesAsList.filterIsInstance() /** Return the types that may be used as a type condition within [parentType]. */ diff --git a/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Utils.kt b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Utils.kt new file mode 100644 index 000000000..8a5e483ab --- /dev/null +++ b/graphql-java-arbitrary/src/main/kotlin/graphql/arbitrary/Utils.kt @@ -0,0 +1,123 @@ +package graphql.arbitrary + +import graphql.Directives +import graphql.Scalars +import graphql.schema.GraphQLDirective +import graphql.schema.GraphQLInterfaceType +import graphql.schema.GraphQLScalarType +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.constant + +internal val builtinScalars: Map = + listOf( + Scalars.GraphQLBoolean, + Scalars.GraphQLID, + Scalars.GraphQLInt, + Scalars.GraphQLFloat, + Scalars.GraphQLString + ).associateBy { it.name } + +internal val builtinDirectives: Map = + Directives.BUILT_IN_DIRECTIVES.associateBy { it.name } + +/** Return an IntRange containing only this Int. */ +fun Int.asIntRange(): IntRange = IntRange(this, this) + +/** Return a LongRange containing only this Int. */ +fun Int.asLongRange(): LongRange = toLong().asLongRange() + +/** Return a LongRange containing only this Long. */ +fun Long.asLongRange(): LongRange = LongRange(this, this) + +/** Throw [ResolverException] if sampling the weight described by [key] returns true. */ +internal fun maybeThrowResolverException( + cfg: Config, + key: ConfigKey, + rs: RandomSource +) { + if (rs.sampleWeight(cfg[key])) throw ResolverException(key) +} + +class ResolverException(val key: ConfigKey<*>) : Exception() { + override val message: String = + "This is a synthetic ResolverException configured by ${key.javaClass.name}" +} + +/** + * Return an Arb describing subsets of this Set. + * @see Arb>.subset + */ +fun Set.arbSubset(range: IntRange? = null): Arb> = Arb.constant(this).subset(range) + +/** + * Transform a Collection of Arb into an Arb of List. + * + * Example: + * val list = listOf(Arb.of(1), Arb.of(2), Arb.of(3)) + * val items = list.collect().next(rs) // listOf(1, 2, 3) + */ +fun Collection>.collect(): Arb> = Arb.bind(toList()) { it } + +/** + * Select interfaces that may be implemented together without introducing unrelated fields with + * the same name. Related interfaces may share inherited field definitions. + */ +internal fun Set.nonConflicting(): Set { + val groups = relatedInterfaceGroups(this) + val usedFields = mutableSetOf() + val result = linkedSetOf() + groups.forEach { group -> + val fieldNames = group.flatMap { it.fields }.map { it.name } + if (fieldNames.none(usedFields::contains)) { + result += group.first() + usedFields += fieldNames + } + } + return result +} + +private fun relatedInterfaceGroups( + interfaces: Set +): List> { + val remaining = interfaces.toMutableSet() + val groups = mutableListOf>() + while (remaining.isNotEmpty()) { + val seed = remaining.first() + val group = relatedInterfaces(seed, interfaces) + groups += group + remaining -= group + } + return groups +} + +private fun relatedInterfaces( + seed: GraphQLInterfaceType, + candidates: Set +): Set { + val result = linkedSetOf(seed) + var changed = true + while (changed) { + changed = false + candidates.forEach { candidate -> + if (candidate in result) return@forEach + if (isRelated(candidate, result)) { + result += candidate + changed = true + } + } + } + return result +} + +private fun isRelated( + candidate: GraphQLInterfaceType, + group: Set +): Boolean { + val candidateParents = candidate.interfaces.map { it.name }.toSet() + if (group.any { it.name in candidateParents }) return true + return group.any { existing -> + candidate.name in existing.interfaces.map { it.name } + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/UtilTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ArbExtTest.kt similarity index 52% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/UtilTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ArbExtTest.kt index 27ed43187..1b9c12952 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/UtilTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ArbExtTest.kt @@ -1,16 +1,10 @@ @file:Suppress("ForbiddenImport") -@file:OptIn(ExperimentalTime::class) +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) -package graphql.schema.property +package graphql.arbitrary -import graphql.ExecutionInput -import graphql.language.AstPrinter -import graphql.language.ListType -import graphql.language.NonNullType -import graphql.language.Type -import graphql.language.TypeName -import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.property.Arb +import io.kotest.property.RandomSource import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.char import io.kotest.property.arbitrary.constant @@ -18,67 +12,39 @@ import io.kotest.property.arbitrary.element import io.kotest.property.arbitrary.flatMap import io.kotest.property.arbitrary.int import io.kotest.property.arbitrary.intRange -import io.kotest.property.arbitrary.long +import io.kotest.property.arbitrary.list import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.of import io.kotest.property.arbitrary.orNull import io.kotest.property.arbitrary.pair import io.kotest.property.arbitrary.set import io.kotest.property.arbitrary.string -import io.kotest.property.checkAll -import io.kotest.property.forAll -import io.kotest.property.forNone +import io.kotest.property.arbitrary.take import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime import kotlin.time.measureTime +import java.util.UUID +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows -class UtilTest : KotestPropertyBase() { +class ArbExtTest : ArbPropertyBase() { @Test - fun `Int asIntRange`(): Unit = + fun `Gen_asSequence`(): Unit = runBlocking { - Arb.int().forAll { i -> - val range = i.asIntRange() - val isEmpty = range.isEmpty() - val containsI = range.contains(i) - val checkLo = if (i > Int.MIN_VALUE) !range.contains(i - 1) else true - val checkHi = if (i < Int.MAX_VALUE) !range.contains(i + 1) else true - - !isEmpty && checkLo && containsI && checkHi - } - } - - @Test - fun `Int asLongRange`(): Unit = - runBlocking { - Arb.int().forAll { i -> - val range = i.asLongRange() - val isEmpty = range.isEmpty() - val containsI = range.contains(i) - val checkLo = !range.contains(i.toLong() - 1) - val checkHi = !range.contains(i.toLong() + 1) - - !isEmpty && checkLo && containsI && checkHi - } - } - - @Test - fun `Long asLongRange()`(): Unit = - runBlocking { - Arb.long().forAll { l -> - val range = l.asLongRange() - val isEmpty = range.isEmpty() - val containsL = range.contains(l) - val checkLo = if (l > Long.MIN_VALUE) !range.contains(l - 1) else true - val checkHi = if (l < Long.MAX_VALUE) !range.contains(l + 1) else true - - !isEmpty && checkLo && containsL && checkHi - } + Arb + .list(Arb.int(), 0..10) + .map { list -> + val arb = Arb.constant(list) + val seq = arb.asSequence(RandomSource.default()) + arb to seq + }.forAll { (arb, seq) -> + seq.first() == arb.next(randomSource) + } } @Test @@ -116,26 +82,6 @@ class UtilTest : KotestPropertyBase() { } } - @Test - fun `Set arbSubset`(): Unit = - runBlocking { - // without range - Arb - .set(Arb.int()) - .forAll { set -> - val subset = set.arbSubset().bind() - set.containsAll(subset) - } - - // with range - Arb - .set(Arb.int()) - .forAll { set -> - val subset = set.arbSubset(set.size.asIntRange()).bind() - set == subset - } - } - @Test fun filterNotNull(): Unit = runBlocking { @@ -213,16 +159,6 @@ class UtilTest : KotestPropertyBase() { @Test fun `Arb unit`(): Unit = runBlocking { Arb.unit().forAll { it == Unit } } - @Test - fun collect(): Unit = - runBlocking { - listOf(Arb.int(), Arb.string(), Arb.char()) - .collect() - .forAll { l -> - l[0] is Int && l[1] is String && l[2] is Char - } - } - @Test fun `RandomSource sampleWeight`(): Unit = runBlocking { @@ -248,66 +184,11 @@ class UtilTest : KotestPropertyBase() { } @Test - fun `GraphQLTypes can be roundtripped through Type`() { - val sdl = """ - type Query { - a:Int - b:Enum - c:[[Int]] - d:[[Int!]!]! - e: U - f(inp:Inp!):Int - } - union U = Query - enum Enum { A, B } - input Inp { - x:Int! - inp:Inp - } - """.trimIndent() - val doc = sdl.asDocument - val schema = sdl.asSchema - - val types = doc.allChildrenOfType>() - // sanity - assertTrue(types.isNotEmpty()) + fun `RandomSource fork`() { + val seed = 123L + val expectedSeed = RandomSource.seeded(seed).random.nextLong() - types.forEach { t1 -> - val t2 = t1.asSchemaType(schema).asAstType() - assertTypesEqual(t1, t2) - } - } - - @Test - fun `DocumentComparator and ExecutionInputComparator -- sorts documents by node count`() { - val q1 = "{ someVeryLongFieldName }" - val q2 = "{ x { y } }" - - // DocumentComparator - let { - val d1 = q1.asDocument - val d2 = q2.asDocument - assertEquals(-1, DocumentComparator.compare(d1, d2)) - } - - // ExecutionInputComparator - let { - val e1 = ExecutionInput.newExecutionInput(q1).build() - val e2 = ExecutionInput.newExecutionInput(q2).build() - assertEquals(-1, ExecutionInputComparator.compare(e1, e2)) - } - } - - @Test - fun `String asSchema`() { - val schema = "type Query { x:Int }".asSchema - assertNotNull(schema.queryType.getField("x")) - } - - @Test - fun `String asDocument`() { - val sdl = "type Query {x: Int}" - assertEquals(sdl, AstPrinter.printAstCompact(sdl.asDocument)) + assertEquals(expectedSeed, RandomSource.seeded(seed).fork().seed) } @Test @@ -332,39 +213,87 @@ class UtilTest : KotestPropertyBase() { } @Test - fun `maybeThrowResolverException`(): Unit = + fun `Arb_flatten`(): Unit = runBlocking { - // does not throw - val key = object : ConfigKey(0.0, WeightValidator) {} - assertDoesNotThrow { - maybeThrowResolverException(Config.default, key, randomSource) - } - - // throws - val err = assertThrows { - maybeThrowResolverException(Config.default + (key to 1.0), key, randomSource) - } - assertEquals(key, err.key) - assertTrue(key.javaClass.name in err.message) + val chunkSize = 10 + val arb = Arb + .int(0..10) + .map { i -> + Arb + .char('a'..'z') + .map { c -> i to c } + .take(chunkSize, randomSource) + .toList() + }.flatten() + + arb + .take(chunkSize * 1_000, randomSource) + .chunked(chunkSize) + .forEach { chunk -> + val firsts = chunk.map { it.first } + assertEquals(1, firsts.distinct().size) + } } -} -private fun assertTypesEqual( - t1: Type<*>, - t2: Type<*> -) { - when (t1) { - is TypeName -> { - t2.shouldBeInstanceOf() - assertEquals(t1.name, t2.name) + + @Test + fun `Arb_flatten -- edge case delegates to underlying`() { + val arb = Arb.constant(listOf(1, 2, 3)).flatten() + val edge = arb.edgecase(RandomSource.seeded(0)) + // edgecase returns first element from underlying's edgecase iterator + assertTrue(edge != null && edge.value in listOf(1, 2, 3)) + } + + @Test + fun `Arb_mapNotNull`(): Unit = + runBlocking { + Arb.int(0..100) + .mapNotNull { + it.takeIf { it % 2 == 0 }?.toString() + }.forAll { + (it.toInt() % 2 == 0) + } } - is NonNullType -> { - t2.shouldBeInstanceOf() - assertTypesEqual(t1.type, t2.type) + + @Test + fun `Any_failProperty -- failure message includes seed when provided`() { + // no seed value is provided + assertThrows { + failProperty("msg") + }.let { + assertTrue(it.message?.contains("Property failed") ?: false) + assertTrue(it.message?.contains("msg") ?: false) } - is ListType -> { - t2.shouldBeInstanceOf() - assertTypesEqual(t1.type, t2.type) + + // provide a seed value + val explicitSeed = UUID.randomUUID().leastSignificantBits + assertThrows { + failProperty("msg", seed = explicitSeed) + }.let { + assertTrue(it.message?.contains(explicitSeed.toString()) ?: false) } - else -> throw IllegalArgumentException("unknown Type: $t1") + } + + @Test + fun `Arb_flatten -- items within a chunk are consecutive`() { + val arb = Arb.of(listOf(1, 1), listOf(2, 2)).flatten() + val values = arb.asSequence(randomSource).take(4).toList() + // each pair of consecutive items must come from the same chunk + assertEquals(values[0], values[1]) + assertEquals(values[2], values[3]) + } + + @Test + fun `Arb_flatten -- skips empty chunks`() { + // Alternates between empty and non-empty lists; flatten must not throw + val arb = Arb.of(emptyList(), listOf("a"), emptyList(), listOf("b")).flatten() + val values = arb.asSequence(randomSource).take(10).toList() + assertTrue(values.all { it == "a" || it == "b" }) + } + + @Test + fun `mapNotNull drops nulls`() { + val arb = Arb.of(1, 2, 3, 4, 5).mapNotNull { if (it % 2 == 0) it else null } + val values = arb.asSequence(randomSource).take(100).toSet() + assertEquals(setOf(2, 4), values) } } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBaseTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ArbPropertyBaseTest.kt similarity index 72% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBaseTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ArbPropertyBaseTest.kt index 62f230ecd..97a471de8 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBaseTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ArbPropertyBaseTest.kt @@ -1,9 +1,8 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import io.kotest.property.Arb -import io.kotest.property.arbitrary.constant import io.kotest.property.arbitrary.int import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -12,45 +11,25 @@ import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows - -class KotestPropertyBaseTest : KotestPropertyBase() { - @Test - fun `checkInvariants throws AssertionError on failure`() { - assertThrows { - runBlocking { - Arb.constant(true).checkInvariants { v, check -> - check.isFalse(v, "test-label") - } - } - } - } - - @Test - fun `checkInvariants passes on success`() = - runBlocking { - Arb.constant(true).checkInvariants { v, check -> - check.isTrue(v, "test-label") - } - } +class ArbPropertyBaseTest : ArbPropertyBase() { @Test fun `configures a seed from ctor`() { - val base = object : KotestPropertyBase(1L) {} + val base = object : ArbPropertyBase(1L) {} assertEquals(1L, base.seed) assertEquals(1L, base.randomSource.seed) } @Test fun `configures a random seed by default`() { - val base = object : KotestPropertyBase() {} + val base = object : ArbPropertyBase() {} assertEquals(base.seed, base.randomSource.seed) } @Test fun `randomSource is seeded deterministically`() { - val a = object : KotestPropertyBase(42L) {} - val b = object : KotestPropertyBase(42L) {} + val a = object : ArbPropertyBase(42L) {} + val b = object : ArbPropertyBase(42L) {} assertEquals( Arb.int().generate(a.randomSource).take(10).map { it.value }.toList(), Arb.int().generate(b.randomSource).take(10).map { it.value }.toList() @@ -61,8 +40,8 @@ class KotestPropertyBaseTest : KotestPropertyBase() { fun `forAll uses instance seed, not global state`() = runBlocking { // Create two instances with different seeds - val base1 = object : KotestPropertyBase(1L) {} - val base2 = object : KotestPropertyBase(2L) {} + val base1 = object : ArbPropertyBase(1L) {} + val base2 = object : ArbPropertyBase(2L) {} // Collect values generated by forAll from each instance val values1 = mutableListOf() @@ -83,7 +62,7 @@ class KotestPropertyBaseTest : KotestPropertyBase() { // Same seed produces same values val values1Again = mutableListOf() - with(object : KotestPropertyBase(1L) {}) { + with(object : ArbPropertyBase(1L) {}) { Arb.int().forAll(10) { v -> values1Again.add(v) true @@ -96,14 +75,14 @@ class KotestPropertyBaseTest : KotestPropertyBase() { @Test fun `concurrent instances do not interfere with each other`() = runBlocking { - // Simulate parallel test execution: multiple KotestPropertyBase instances + // Simulate parallel test execution: multiple ArbPropertyBase instances // running forAll concurrently, each with a different seed. // Each instance should produce deterministic results matching its seed, // regardless of interleaving. val seeds = (1L..100L).toList() val count = 100 - class Tester(seed: Long) : KotestPropertyBase(seed) { + class Tester(seed: Long) : ArbPropertyBase(seed) { suspend fun test(): List { val values = mutableListOf() Arb.int().forAll(count) { v -> diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CheckedArbTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/CheckedArbTest.kt similarity index 98% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CheckedArbTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/CheckedArbTest.kt index 091a8ce19..f69bde860 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CheckedArbTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/CheckedArbTest.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf @@ -16,7 +16,7 @@ import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -class CheckedArbTest : KotestPropertyBase() { +class CheckedArbTest : ArbPropertyBase() { @Test fun `check evaluates the value`() { var checkedValue: Int? = null diff --git a/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ComparatorsTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ComparatorsTest.kt new file mode 100644 index 000000000..989dc06ce --- /dev/null +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ComparatorsTest.kt @@ -0,0 +1,41 @@ +package graphql.arbitrary + +import graphql.ExecutionInput +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ComparatorsTest { + @Test + fun `DocumentComparator`() { + val shallow = "{ someVeryLongFieldName }".asDocument + val equivalent = "{ x }".asDocument + val deep = "{ x { y } }".asDocument + + assertTrue(DocumentComparator.compare(shallow, deep) < 0) + assertTrue(DocumentComparator.compare(deep, shallow) > 0) + assertEquals(0, DocumentComparator.compare(shallow, equivalent)) + } + + @Test + fun `ExecutionInputComparator`() { + val shallow = ExecutionInput.newExecutionInput("{ someVeryLongFieldName }").build() + val equivalent = ExecutionInput.newExecutionInput("{ x }").build() + val deep = ExecutionInput.newExecutionInput("{ x { y } }").build() + + assertTrue(ExecutionInputComparator.compare(shallow, deep) < 0) + assertTrue(ExecutionInputComparator.compare(deep, shallow) > 0) + assertEquals(0, ExecutionInputComparator.compare(shallow, equivalent)) + } + + @Test + fun `GraphQLSchemaComparator`() { + val shallow = "type Query { someVeryLongFieldName: Int }".asSchema + val equivalent = "type Query { x: Int }".asSchema + val deep = "type Obj { x: Int } type Query { obj: Obj }".asSchema + + assertTrue(GraphQLSchemaComparator.compare(shallow, deep) < 0) + assertTrue(GraphQLSchemaComparator.compare(deep, shallow) > 0) + assertEquals(0, GraphQLSchemaComparator.compare(shallow, equivalent)) + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ConfigTest.kt similarity index 79% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ConfigTest.kt index c8152de4b..cf46bd637 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/ConfigTest.kt @@ -1,6 +1,6 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import io.kotest.property.Arb import io.kotest.property.arbitrary.bind @@ -14,23 +14,24 @@ import io.kotest.property.arbitrary.string import io.kotest.property.arbitrary.withEdgecases import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test -class ConfigTest : KotestPropertyBase() { +class ConfigTest : ArbPropertyBase() { @Test - fun `Config_plus validates entries`(): Unit = + fun `Config constructor validates values`(): Unit = runBlocking { val alwaysValid = ConfigKey(0) { null } val alwaysInvalid = ConfigKey(0) { "msg" } Arb.int().forAll { i -> val putValid = Result.runCatching { - Config.default + (alwaysValid to i) + Config(alwaysValid(i)) } val putInvalid = Result.runCatching { - Config.default + (alwaysInvalid to i) + Config(alwaysInvalid(i)) } putValid.isSuccess && putInvalid.isFailure } @@ -41,14 +42,26 @@ class ConfigTest : KotestPropertyBase() { val k1 = ConfigKey(1, Unvalidated) val k2 = ConfigKey(2, Unvalidated) - val c1 = Config.default + (k1 to 11) + (k2 to 22) - val c2 = Config.default + (k1 to 111) + val c1 = Config(k1(11), k2(22)) + val c2 = Config(k1(111)) val combined = c1 + c2 assertEquals(111, combined[k1]) assertEquals(22, combined[k2]) } + @Test + fun `invoked ConfigKey returns a validated Config`() { + val key = ConfigKey(0, IntValidator(0..10)) + + val config = key(7) + + assertEquals(7, config[key]) + assertThrows(InvalidConfigValue::class.java) { + key(11) + } + } + @Test fun `Config_get with missing key returns default value`(): Unit = runBlocking { @@ -63,11 +76,40 @@ class ConfigTest : KotestPropertyBase() { runBlocking { val key = ConfigKey(0, Unvalidated) Arb.int().forAll { x -> - val cfg = Config.default + (key to x) + val cfg = Config(key(x)) cfg[key] == x } } + @Test + fun `setTo returns configurations that can be combined`() { + val k1 = ConfigKey(0, Unvalidated) + val k2 = ConfigKey("", Unvalidated) + val k3 = ConfigKey(1.0f, Unvalidated) + val config = Config( + k1 setTo 2, + k2 setTo "a" + ) + + assertEquals(2, config[k1]) + assertEquals("a", config[k2]) + assertEquals(1.0f, config[k3]) + } + + @Test + fun `Config constructor combines invoked keys`() { + val k1 = ConfigKey(0, IntValidator(0..10)) + val k2 = ConfigKey("", Unvalidated) + + val config = Config( + k1(2), + k2("a") + ) + + assertEquals(2, config[k1]) + assertEquals("a", config[k2]) + } + @Test fun `WeightValidator rejects values outside of 0 and 1`(): Unit = runBlocking { diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/CycleGroupsTest.kt similarity index 98% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/CycleGroupsTest.kt index 3307d04e7..e5b4a6ef7 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/CycleGroupsTest.kt @@ -1,6 +1,6 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import io.kotest.property.Arb import io.kotest.property.arbitrary.string @@ -9,7 +9,7 @@ import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test -class CycleGroupsTest : KotestPropertyBase() { +class CycleGroupsTest : ArbPropertyBase() { @Test fun `Empty`(): Unit = runBlocking { diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuiteTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/DeepArbSuiteTest.kt similarity index 97% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuiteTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/DeepArbSuiteTest.kt index 33bd90da4..3cbe5734d 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuiteTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/DeepArbSuiteTest.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import io.kotest.property.Arb import io.kotest.property.RandomSource diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/DocumentBuilderTest.kt similarity index 99% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/DocumentBuilderTest.kt index 9566f61ba..b6947deb0 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/DocumentBuilderTest.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.Scalars.GraphQLInt import graphql.introspection.Introspection diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLDocumentGenTest.kt similarity index 92% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLDocumentGenTest.kt index a2d797458..39a8b71aa 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLDocumentGenTest.kt @@ -1,6 +1,6 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import graphql.ParseAndValidate import graphql.language.Argument @@ -32,7 +32,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.fail -class GraphQLDocumentGenTest : KotestPropertyBase( +class GraphQLDocumentGenTest : ArbPropertyBase( iterations = 5_000 ) { private val schema = @@ -69,25 +69,26 @@ class GraphQLDocumentGenTest : KotestPropertyBase( untypedInlineFragmentWeight: Double = 0.0, variableWeight: Double = 0.0, ): Config = - Config.default + - (AliasWeight to aliasWeight) + - (AnonymousOperationWeight to anonymousOperationWeight) + - (AppliedDirectiveWeight to directiveWeight) + - (BanDirectiveNames to banDirectiveNames) + - (BanSelectionCoordinates to banSelectionCoordinates) + - (DocumentUncoercedValueWeight to documentUncoercedValueWeight) + - (ExplicitNullValueWeight to explicitNullValueWeight) + - (FieldNameLength to fieldNameLength.asIntRange()) + - (FieldSelectionWeight to fieldSelectionWeight) + - (FragmentDefinitionWeight to fragmentDefinitionWeight) + - (FragmentSpreadWeight to fragmentSpreadWeight) + - (ImplicitNullValueWeight to implicitNullValueWeight) + - (InlineFragmentWeight to inlineFragmentWeight) + - (MaxSelectionSetDepth to maxSelectionSetDepth) + - (OperationCount to operationCount.asIntRange()) + - (TypeNameLength to typeNameLength.asIntRange()) + - (UntypedInlineFragmentWeight to untypedInlineFragmentWeight) + - (VariableWeight to variableWeight) + Config( + AliasWeight(aliasWeight), + AnonymousOperationWeight(anonymousOperationWeight), + AppliedDirectiveWeight(directiveWeight), + BanDirectiveNames(banDirectiveNames), + BanSelectionCoordinates(banSelectionCoordinates), + DocumentUncoercedValueWeight(documentUncoercedValueWeight), + ExplicitNullValueWeight(explicitNullValueWeight), + FieldNameLength(fieldNameLength.asIntRange()), + FieldSelectionWeight(fieldSelectionWeight), + FragmentDefinitionWeight(fragmentDefinitionWeight), + FragmentSpreadWeight(fragmentSpreadWeight), + ImplicitNullValueWeight(implicitNullValueWeight), + InlineFragmentWeight(inlineFragmentWeight), + MaxSelectionSetDepth(maxSelectionSetDepth), + OperationCount(operationCount.asIntRange()), + TypeNameLength(typeNameLength.asIntRange()), + UntypedInlineFragmentWeight(untypedInlineFragmentWeight), + VariableWeight(variableWeight) + ) @Test fun `manual inspection`(): Unit = @@ -136,10 +137,11 @@ class GraphQLDocumentGenTest : KotestPropertyBase( @Test fun `Arb_graphQLDocument -- generates valid documents for arbitrary schemas`() { // use the default config, but dial it down to keep this test from being too slow - val cfg = Config.default + - (SchemaSize to 20) + - (FragmentSpreadWeight to CompoundingWeight(.2, 1)) + - (InlineFragmentWeight to CompoundingWeight(.2, 1)) + val cfg = Config( + SchemaSize(20), + FragmentSpreadWeight(CompoundingWeight(.2, 1)), + InlineFragmentWeight(CompoundingWeight(.2, 1)) + ) // our test matrix is N schemas by M documents. To get good coverage while being in // the ballpark of `iterations`, let's use a square root value for each M/N dimension @@ -188,7 +190,7 @@ class GraphQLDocumentGenTest : KotestPropertyBase( union U = Foo | Bar type Query { u:U } """.trimIndent(), - cfg = Config.default + (FragmentSpreadWeight to CompoundingWeight.Never) + cfg = Config(FragmentSpreadWeight(CompoundingWeight.Never)) ) @Test @@ -561,10 +563,11 @@ class GraphQLDocumentGenTest : KotestPropertyBase( // For example, when building operation B, we may spread a fragment that was originally // generated for use in operation A. If that fragment makes use of variables, then those // variables need to be reconciled with any existing variables for operation B. - val cfg = Config.default + - (OperationCount to 2..2) + - (FragmentSpreadWeight to CompoundingWeight(.4, 1)) + - (VariableWeight to .3) + val cfg = Config( + OperationCount(2..2), + FragmentSpreadWeight(CompoundingWeight(.4, 1)), + VariableWeight(.3) + ) assertAllDocumentsValid("type Query { x(a:Int!):Int }", cfg) } @@ -577,9 +580,10 @@ class GraphQLDocumentGenTest : KotestPropertyBase( // https://github.com/graphql-java/graphql-java/issues/3927 // // This test ensures that we are not generating this pattern - val cfg = Config.default + - (AppliedDirectiveWeight to CompoundingWeight.Once) + - (VariableWeight to 1.0) + val cfg = Config( + AppliedDirectiveWeight(CompoundingWeight.Once), + VariableWeight(1.0) + ) val schema = "type Query { x(a:Int!):Int }".asSchema Arb @@ -669,7 +673,7 @@ private fun debug( doc: Document, vararg extras: Any, ) { - val rels = SchemaRelationships(schema) + val rels = TypeRelations(schema) val docString = AstPrinter.printAst(doc) println(docString) val operations = doc.getDefinitionsOfType(OperationDefinition::class.java) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputGenTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLExecutionInputGenTest.kt similarity index 93% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputGenTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLExecutionInputGenTest.kt index 47430596b..54fc0e529 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputGenTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLExecutionInputGenTest.kt @@ -1,6 +1,6 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import graphql.ExecutionInput import graphql.ParseAndValidate @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test -class GraphQLExecutionInputGenTest : KotestPropertyBase() { +class GraphQLExecutionInputGenTest : ArbPropertyBase() { private val schema = """ directive @dir(arg:Int!) repeatable on QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | VARIABLE_DEFINITION @@ -30,10 +30,11 @@ class GraphQLExecutionInputGenTest : KotestPropertyBase() { operationCount: Int = 1, implicitNullValueWeight: Double = 0.0, ): Config = - Config.default + - (AnonymousOperationWeight to anonymousOperationWeight) + - (ImplicitNullValueWeight to implicitNullValueWeight) + - (OperationCount to operationCount.asIntRange()) + Config( + AnonymousOperationWeight(anonymousOperationWeight), + ImplicitNullValueWeight(implicitNullValueWeight), + OperationCount(operationCount.asIntRange()) + ) @Test fun AnonymousOperationWeight(): Unit = diff --git a/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLExternalInputValueGenTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLExternalInputValueGenTest.kt new file mode 100644 index 000000000..ce3f937ca --- /dev/null +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLExternalInputValueGenTest.kt @@ -0,0 +1,105 @@ +package graphql.arbitrary + +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLScalarType +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.constant +import org.junit.jupiter.api.Test + +class GraphQLExternalInputValueGenTest { + @Test + fun IDValueGenFactory() { + val schema = parseTestSchema("type Query { echo(id: ID): ID }") + val idType = schema.getType("ID") as GraphQLScalarType + val factory = IDValueGen.Factory { IDValueGen { "configured-id" } } + val config = Config( + ExplicitNullValueWeight(0.0), + IDValueGenFactory(factory) + ) + + GraphQLExternalInputValueGen(schema, config, RandomSource.seeded(1)) + .gen(idType) + .shouldBe("configured-id") + } + + @Test + fun ExplicitNullValueWeight() { + val schema = parseTestSchema("type Query { x: Int }") + val scalar = schema.getType("Int") as GraphQLScalarType + val config = Config(ExplicitNullValueWeight(1.0)) + val nullable = GraphQLExternalInputValueGen(schema, config, RandomSource.seeded(1)) + nullable.gen(scalar).shouldBe(null) + + val nonNull = GraphQLExternalInputValueGen(schema, config, RandomSource.seeded(1)) + (nonNull.gen(GraphQLNonNull.nonNull(scalar)) is Int).shouldBe(true) + } + + @Test + fun `lists honor configured size and item nullability`() { + val schema = parseTestSchema("type Query { x: Int }") + val scalar = schema.getType("Int") as GraphQLInputType + val type = GraphQLNonNull.nonNull(GraphQLList.list(GraphQLNonNull.nonNull(scalar))) + val config = Config( + ExplicitNullValueWeight(1.0), + ListValueSize(3..3) + ) + val value = GraphQLExternalInputValueGen(schema, config, RandomSource.seeded(2)) + .gen(type) as List<*> + value.size.shouldBe(3) + value.all { it is Int }.shouldBe(true) + } + + @Test + fun `oneOf values contain exactly one non-null member`() { + val schema = parseTestSchema( + "input Choice @oneOf { text: String, number: Int } type Query { x(choice: Choice): Int }" + ) + val type = schema.getType("Choice") as GraphQLInputObjectType + val config = Config(ExplicitNullValueWeight(1.0)) + val value = GraphQLExternalInputValueGen(schema, config, RandomSource.seeded(3)) + .gen(GraphQLNonNull.nonNull(type)) as Map<*, *> + value.size.shouldBe(1) + (value.values.single() != null).shouldBe(true) + } + + @Test + fun `recursive input values stop at MaxValueDepth`() { + val schema = parseTestSchema( + "input Recursive { next: Recursive } type Query { x(value: Recursive): Int }" + ) + val type = schema.getType("Recursive") as GraphQLInputObjectType + val config = Config( + ExplicitNullValueWeight(0.0), + ImplicitNullValueWeight(0.0), + MaxValueDepth(2) + ) + val value = GraphQLExternalInputValueGen(schema, config, RandomSource.seeded(4)) + .gen(GraphQLNonNull.nonNull(type)) as Map<*, *> + val nested = value["next"] as Map<*, *> + nested.shouldBe(mapOf("next" to null)) + } + + @Test + fun `custom scalars require and use ScalarValueOverrides`() { + val schema = parseTestSchema("scalar Custom type Query { x(value: Custom): Int }") + val scalar = schema.getType("Custom") as GraphQLScalarType + shouldThrow { + GraphQLExternalInputValueGen(schema, Config.default, RandomSource.seeded(5)) + .gen(scalar) + } + + val config = Config( + ExplicitNullValueWeight(0.0), + ScalarValueOverrides(mapOf("Custom" to Arb.constant("custom-value"))) + ) + GraphQLExternalInputValueGen(schema, config, RandomSource.seeded(5)) + .gen(scalar) + .shouldBe("custom-value") + } +} diff --git a/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLInputValueGenTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLInputValueGenTest.kt new file mode 100644 index 000000000..277affaa4 --- /dev/null +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLInputValueGenTest.kt @@ -0,0 +1,25 @@ +package graphql.arbitrary + +import graphql.language.StringValue +import graphql.schema.GraphQLInputType +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.kotest.property.RandomSource +import org.junit.jupiter.api.Test + +class GraphQLInputValueGenTest { + @Test + fun IDValueGenFactory() { + val schema = parseTestSchema("type Query { echo(id: ID): ID }") + val idType = schema.getType("ID") as GraphQLInputType + val factory = IDValueGen.Factory { IDValueGen { "configured-id" } } + val config = Config( + ExplicitNullValueWeight(0.0), + IDValueGenFactory(factory) + ) + + val value = GraphQLInputValueGen(schema, config, RandomSource.seeded(1)).gen(idType) + + value.shouldBeInstanceOf().value.shouldBe("configured-id") + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLNamesTest.kt similarity index 74% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLNamesTest.kt index 8f189a267..04ff31cdf 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLNamesTest.kt @@ -1,6 +1,6 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import io.kotest.property.Arb import io.kotest.property.arbitrary.flatMap @@ -12,14 +12,14 @@ import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -class GraphQLNamesTest : KotestPropertyBase() { +class GraphQLNamesTest : ArbPropertyBase() { @Test fun `Arb_graphQLNames`(): Unit = runBlocking { Arb .int(min = 1, max = 1000) .forAll { count -> - val cfg = Config.default + (SchemaSize to count) + val cfg = Config(SchemaSize(count)) val names = Arb.graphQLNames(cfg).bind() val subtotal = @@ -41,13 +41,13 @@ class GraphQLNamesTest : KotestPropertyBase() { Arb .of(true, false) .flatMap { incl -> - val cfg = Config.default + (GenCustomScalars to incl) + val cfg = Config(GenCustomScalars(incl)) Arb .graphQLNames(cfg) .map { names -> incl to names.scalars - builtinScalars.keys } - }.checkInvariants { (incl, scalars), check -> + }.checkAll { (incl, scalars) -> if (!incl) { - check.isTrue( + assertTrue( scalars.isEmpty(), "Found ${scalars.size} scalars when IncludeCustomScalars is $incl" ) @@ -56,25 +56,29 @@ class GraphQLNamesTest : KotestPropertyBase() { } @Test - fun `Arb_graphQLNames -- TypeTypeWeights -- zero`(): Unit = + fun `Arb_graphQLNames -- TypeKindWeights -- zero`(): Unit = runBlocking { // all weights are 0 - val cfg = Config.default + - (TypeTypeWeights to TypeTypeWeights.zero) + - (IncludeBuiltinScalars to false) + - (IncludeBuiltinDirectives to false) + val cfg = Config( + TypeKindWeights(TypeKindWeights.zero), + IncludeBuiltinScalars(false), + IncludeBuiltinDirectives(false) + ) Arb.graphQLNames(cfg).forAll { names -> names.allNames.isEmpty() } } @Test - fun `Arb_graphQLNames -- TypeTypeWeights -- skewed weights`(): Unit = + fun `Arb_graphQLNames -- TypeKindWeights -- skewed weights`(): Unit = runBlocking { - val ttw = TypeTypeWeights.default + (TypeType.Enum to 10.0) + (TypeType.Union to 0.0) + val ttw = TypeKindWeights.default + (TypeKind.Enum to 10.0) + (TypeKind.Union to 0.0) Arb .int(0 until 1_000) .flatMap { schemaSize -> - val cfg = Config.default + (TypeTypeWeights to ttw) + (SchemaSize to schemaSize) + val cfg = Config( + TypeKindWeights(ttw), + SchemaSize(schemaSize) + ) Arb.graphQLNames(cfg) }.forAll { names -> names.unions.isEmpty() && names.enums.size >= names.objects.size @@ -86,10 +90,11 @@ class GraphQLNamesTest : KotestPropertyBase() { runBlocking { val dirName = "Directive_a" - val cfg = Config.default + - (TypeNameLength to 1.asIntRange()) + - (SchemaSize to 10) + - (TypeTypeWeights to (TypeTypeWeights.zero) + (TypeType.Directive to 1.0)) + val cfg = Config( + TypeNameLength(1.asIntRange()), + SchemaSize(10), + TypeKindWeights(TypeKindWeights.zero + (TypeKind.Directive to 1.0)) + ) // sanity check that a "Directive_a" name *can* be generated assertTrue( @@ -100,7 +105,7 @@ class GraphQLNamesTest : KotestPropertyBase() { ) // assert that the name never gets generated when banned - Arb.graphQLNames(cfg + (BanDirectiveNames to setOf(dirName))) + Arb.graphQLNames(cfg + BanDirectiveNames(setOf(dirName))) .forAll { names -> dirName !in names.directives } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLOutputValuesTest.kt similarity index 76% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLOutputValuesTest.kt index 9048e2ac1..d8217d074 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLOutputValuesTest.kt @@ -1,6 +1,6 @@ @file:OptIn(io.kotest.common.ExperimentalKotest::class) -package graphql.schema.property +package graphql.arbitrary import graphql.language.FragmentDefinition import graphql.language.SelectionSet @@ -17,13 +17,29 @@ import io.kotest.property.arbitrary.constant import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test -class GraphQLOutputValuesTest : KotestPropertyBase() { +class GraphQLOutputValuesTest : ArbPropertyBase() { + @Test + fun IDValueGenFactory(): Unit = + runBlocking { + val schema = parseTestSchema("type Query { id: ID }") + val idType = schema.getType("ID") as GraphQLOutputType + val factory = IDValueGen.Factory { IDValueGen { "configured-id" } } + val config = Config( + ExplicitNullValueWeight(0.0), + IDValueGenFactory(factory) + ) + + Arb.graphQLOutputValue(schema, idType, null, config = config).checkAll(20) { + it.shouldBe("configured-id") + } + } + @Test fun `nullable and non-null scalar values honor ExplicitNullValueWeight`(): Unit = runBlocking { val schema = parseTestSchema("type Query { x: Int }") val scalar = schema.getType("Int") as GraphQLOutputType - val nullConfig = Config.default + (ExplicitNullValueWeight to 1.0) + val nullConfig = Config(ExplicitNullValueWeight(1.0)) Arb.graphQLOutputValue(schema, scalar, null, config = nullConfig).checkAll(20) { it.shouldBe(null) } @@ -39,9 +55,10 @@ class GraphQLOutputValuesTest : KotestPropertyBase() { val schema = parseTestSchema("type Query { x: Int }") val scalar = schema.getType("Int") as GraphQLOutputType val type = GraphQLNonNull.nonNull(GraphQLList.list(GraphQLNonNull.nonNull(scalar))) - val config = Config.default + - (ExplicitNullValueWeight to 1.0) + - (ListValueSize to 2..2) + val config = Config( + ExplicitNullValueWeight(1.0), + ListValueSize(2..2) + ) Arb.graphQLOutputValue(schema, type, null, config = config).checkAll(20) { value -> val list = value.shouldBeInstanceOf>() list.size.shouldBe(2) @@ -56,7 +73,7 @@ class GraphQLOutputValuesTest : KotestPropertyBase() { val document = Parser().parseDocument( "query Q { obj { alias: x ... on Obj { y } ...F } } fragment F on Obj { __typename }" ) - val config = Config.default + (ExplicitNullValueWeight to 0.0) + val config = Config(ExplicitNullValueWeight(0.0)) Arb.graphQLOutputValue(schema, document, config).checkAll(30) { root -> root.typeName.shouldBe("Query") val obj = root.fields.getValue("obj").shouldBeInstanceOf() @@ -73,9 +90,10 @@ class GraphQLOutputValuesTest : KotestPropertyBase() { ) val union = schema.getType("U") as GraphQLUnionType val selections = outputSelections("fragment F on U { ... on A { __typename } }") - val config = Config.default + - (SelectedTypeBias to 1.0) + - (ExplicitNullValueWeight to 0.0) + val config = Config( + SelectedTypeBias(1.0), + ExplicitNullValueWeight(0.0) + ) Arb.graphQLOutputValue(schema, union, selections, config = config).checkAll(50) { value -> value.shouldBeInstanceOf().typeName.shouldBe("A") } @@ -86,9 +104,10 @@ class GraphQLOutputValuesTest : KotestPropertyBase() { runBlocking { val schema = parseTestSchema("scalar Custom type Query { x: Custom }") val scalar = schema.getType("Custom") as GraphQLOutputType - val config = Config.default + - (ExplicitNullValueWeight to 0.0) + - (ScalarValueOverrides to mapOf("Custom" to Arb.constant("custom-value"))) + val config = Config( + ExplicitNullValueWeight(0.0), + ScalarValueOverrides(mapOf("Custom" to Arb.constant("custom-value"))) + ) Arb.graphQLOutputValue(schema, scalar, null, config = config).checkAll(20) { it.shouldBe("custom-value") } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLRuntimeWiringsTest.kt similarity index 85% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLRuntimeWiringsTest.kt index df8e9db6a..25962dfa7 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLRuntimeWiringsTest.kt @@ -1,11 +1,12 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import graphql.ExceptionWhileDataFetching import graphql.GraphQL import graphql.execution.NonNullableFieldWasNullError import graphql.language.Field +import graphql.schema.FieldCoordinates import graphql.schema.idl.RuntimeWiring import graphql.schema.idl.SchemaGenerator import graphql.schema.idl.SchemaParser @@ -19,10 +20,50 @@ import io.kotest.property.arbitrary.long import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.take import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotSame import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -class GraphQLRuntimeWiringsTest : KotestPropertyBase() { +class GraphQLRuntimeWiringsTest : ArbPropertyBase() { + @Test + fun IDValueGenFactory() { + val factory = IDValueGen.Factory { IDValueGen { "configured-id" } } + val config = Config(IDValueGenFactory(factory)) + val result = arbGraphQL( + schema = "type Query { id: ID! }".asSchema, + seed = 123L, + config = config + ).execute("{ id }") + + assertTrue(result.errors.isEmpty()) + assertEquals("configured-id", result.getData>()?.get("id")) + } + + @Test + fun `arbCodeRegistry registers data fetchers`() { + val schema = "type Query { value: Int! }".asSchema + val field = schema.queryType.getFieldDefinition("value") + val coordinates = FieldCoordinates.coordinates("Query", "value") + + val original = schema.codeRegistry.getDataFetcher(coordinates, field) + val arbitrary = arbCodeRegistry(schema, seed = 123L).getDataFetcher(coordinates, field) + + assertNotSame(original, arbitrary) + } + + @Test + fun `arbGraphQL creates an executable instance`() { + val result = arbGraphQL( + schema = "type Query { value: Int! }".asSchema, + seed = 123L + ).execute("{ value }") + + assertTrue(result.errors.isEmpty()) + assertTrue(result.getData>()?.get("value") is Int) + } + @Test fun `returns the same data for the same query and same seed`(): Unit = runBlocking { @@ -51,7 +92,7 @@ class GraphQLRuntimeWiringsTest : KotestPropertyBase() { // skip/include directives can make it hard to find a document that we expect to always select // interesting fields. Disable directives in generated docs - val cfg = Config.default + (AppliedDirectiveWeight to CompoundingWeight.Never) + val cfg = Config(AppliedDirectiveWeight(CompoundingWeight.Never)) Arb .graphQLDocument(schema, cfg) @@ -227,7 +268,7 @@ class GraphQLRuntimeWiringsTest : KotestPropertyBase() { val doc = "{x}".asDocument // disabled - (Config.default + (NullNonNullableWeight to 0.0)).let { cfg -> + Config(NullNonNullableWeight(0.0)).let { cfg -> Arb .long() .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } @@ -240,7 +281,7 @@ class GraphQLRuntimeWiringsTest : KotestPropertyBase() { } // enabled - (Config.default + (NullNonNullableWeight to 1.0)).let { cfg -> + Config(NullNonNullableWeight(1.0)).let { cfg -> Arb .long() .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } @@ -261,7 +302,7 @@ class GraphQLRuntimeWiringsTest : KotestPropertyBase() { val doc = "{x}".asDocument // disabled - (Config.default + (ExplicitNullValueWeight to 0.0)).let { cfg -> + Config(ExplicitNullValueWeight(0.0)).let { cfg -> Arb .long() .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } @@ -275,7 +316,7 @@ class GraphQLRuntimeWiringsTest : KotestPropertyBase() { } // enabled - (Config.default + (ExplicitNullValueWeight to 1.0)).let { cfg -> + Config(ExplicitNullValueWeight(1.0)).let { cfg -> Arb .long() .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } @@ -302,7 +343,10 @@ class GraphQLRuntimeWiringsTest : KotestPropertyBase() { .intRange(0..100) .filter { !it.isEmpty() } .bind() - val cfg = Config.default + (ListValueSize to listSize) + (ExplicitNullValueWeight to 0.0) + val cfg = Config( + ListValueSize(listSize), + ExplicitNullValueWeight(0.0) + ) val gql = mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) val input = Arb.graphQLExecutionInput(gql.graphQLSchema, doc, cfg).bind() listSize to gql.execute(input) @@ -320,7 +364,7 @@ class GraphQLRuntimeWiringsTest : KotestPropertyBase() { val doc = "{x}".asDocument // disabled - (Config.default + (ResolverExceptionWeight to 0.0)).let { cfg -> + Config(ResolverExceptionWeight(0.0)).let { cfg -> Arb .long() .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } @@ -333,7 +377,7 @@ class GraphQLRuntimeWiringsTest : KotestPropertyBase() { } // enabled - (Config.default + (ResolverExceptionWeight to 1.0)).let { cfg -> + Config(ResolverExceptionWeight(1.0)).let { cfg -> Arb .long() .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemasTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLSchemasTest.kt similarity index 87% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemasTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLSchemasTest.kt index 7a8e8042e..d6480b91e 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemasTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLSchemasTest.kt @@ -1,6 +1,6 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import graphql.Scalars import graphql.Scalars.GraphQLFloat @@ -36,18 +36,16 @@ import graphql.schema.idl.SchemaParser import graphql.schema.idl.SchemaPrinter import graphql.util.TraversalControl import graphql.util.TraverserContext -import io.kotest.inspectors.forAll import io.kotest.property.Arb import io.kotest.property.RandomSource import io.kotest.property.arbitrary.arbitrary -import io.kotest.property.arbitrary.flatMap -import io.kotest.property.arbitrary.next import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -class GraphQLSchemasTest : KotestPropertyBase() { +class GraphQLSchemasTest : ArbPropertyBase() { // It's useful to have a directive that can be applied to any element private val testDirective = GraphQLDirective .newDirective() @@ -56,10 +54,16 @@ class GraphQLSchemasTest : KotestPropertyBase() { .validLocations(*Introspection.DirectiveLocation.values()) .build() + @Nested + inner class SimpleGenSuite : DeepArbSuite() { + override val checkedArb = Arb.graphQLSchema().withCheck {} + override val comparator: Comparator = GraphQLSchemaComparator + } + @Test fun `Arb-graphQLSchema can generate an empty-ish schema`(): Unit = runBlocking { - val cfg = Config.default + (SchemaSize to 0) + val cfg = Config(SchemaSize(0)) Arb.graphQLSchema(cfg).checkAll { markSuccess() } @@ -73,17 +77,6 @@ class GraphQLSchemasTest : KotestPropertyBase() { } } - @Test - fun `Arb-graphQLSchema can generate a schema from arbitrary GraphQLTypes`(): Unit = - runBlocking { - val cfg = Config.default + (SchemaSize to 10) - Arb.graphQLTypes(cfg).flatMap { types -> - Arb.graphQLSchema(types, cfg) - }.forAll { - true - } - } - @Test fun `schema document can be roundtripped through sdl`(): Unit = runBlocking { @@ -98,7 +91,7 @@ class GraphQLSchemasTest : KotestPropertyBase() { fun `adds default values`(): Unit = runBlocking { // disabled - Arb.graphQLSchema(Config.default + (DefaultValueWeight to 0.0)) + Arb.graphQLSchema(Config(DefaultValueWeight(0.0))) .forAll(100) { schema -> val defaultables = CollectDefaultables(includeOneOfFields = true) .also { SchemaTraverser().depthFirstFullSchema(it, schema) } @@ -108,7 +101,7 @@ class GraphQLSchemasTest : KotestPropertyBase() { } // enabled - Arb.graphQLSchema(Config.default + (DefaultValueWeight to 1.0)) + Arb.graphQLSchema(Config(DefaultValueWeight(1.0))) .forAll(100) { schema -> val defaultables = CollectDefaultables(includeOneOfFields = false) .also { SchemaTraverser().depthFirstFullSchema(it, schema) } @@ -120,9 +113,10 @@ class GraphQLSchemasTest : KotestPropertyBase() { @Test fun `adds default values -- SchemaUncoercedValueWeight`(): Unit = runBlocking { - val cfg = Config.default + - (DefaultValueWeight to 1.0) + - (SchemaUncoercedValueWeight to 1.0) + val cfg = Config( + DefaultValueWeight(1.0), + SchemaUncoercedValueWeight(1.0) + ) // int for float run { @@ -182,10 +176,10 @@ class GraphQLSchemasTest : KotestPropertyBase() { @Test fun `adds applied directives`(): Unit = runBlocking { - val cfg = Config.default + (IncludeTypes to (GraphQLTypes.empty + testDirective)) + val cfg = Config(IncludeTypes(GraphQLTypes.empty + testDirective)) // disabled - Arb.graphQLSchema(cfg + (AppliedDirectiveWeight to CompoundingWeight.Never)) + Arb.graphQLSchema(cfg + AppliedDirectiveWeight(CompoundingWeight.Never)) .forAll(100) { schema -> val dirs = CollectAppliedDirectives() .also { SchemaTraverser().depthFirstFullSchema(it, schema) } @@ -194,7 +188,7 @@ class GraphQLSchemasTest : KotestPropertyBase() { } // enabled - Arb.graphQLSchema(cfg + (AppliedDirectiveWeight to CompoundingWeight.Once)) + Arb.graphQLSchema(cfg + AppliedDirectiveWeight(CompoundingWeight.Once)) .forAll(100) { schema -> val dirs = CollectAppliedDirectives() .also { SchemaTraverser().depthFirstFullSchema(it, schema) } @@ -205,8 +199,8 @@ class GraphQLSchemasTest : KotestPropertyBase() { // BanDirectiveNames Arb.graphQLSchema( cfg + - (AppliedDirectiveWeight to CompoundingWeight.Once) + - (BanDirectiveNames to setOf(testDirective.name)) + AppliedDirectiveWeight(CompoundingWeight.Once) + + BanDirectiveNames(setOf(testDirective.name)) ).forAll(100) { schema -> val dirs = CollectAppliedDirectives() .also { SchemaTraverser().depthFirstFullSchema(it, schema) } @@ -218,10 +212,11 @@ class GraphQLSchemasTest : KotestPropertyBase() { @Test fun `adds applied directives -- but not on introspection elements`(): Unit = runBlocking { - val cfg = Config.default + - (AppliedDirectiveWeight to CompoundingWeight.Always) + - (DirectiveIsRepeatable to 0.0) + - (IncludeTypes to (GraphQLTypes.empty + testDirective)) + val cfg = Config( + AppliedDirectiveWeight(CompoundingWeight.Always), + DirectiveIsRepeatable(0.0), + IncludeTypes(GraphQLTypes.empty + testDirective) + ) Arb.graphQLSchema(cfg) .forAll(100) { schema -> @@ -335,14 +330,15 @@ class GraphQLSchemasTest : KotestPropertyBase() { // create a configuration that is likely to produce closed OneOf subgraphs // this is characterized by a high generation of OneOf types with small numbers of fields - val cfg = Config.default + - (InputObjectTypeSize to 1.asIntRange()) + - (TypeTypeWeights to TypeTypeWeights.zero + (TypeType.Input to 1.0)) + - (ExplicitNullValueWeight to 0.0) + - (OneOfTypeWeight to 1.0) + val cfg = Config( + InputObjectTypeSize(1.asIntRange()), + TypeKindWeights(TypeKindWeights.zero + (TypeKind.Input to 1.0)), + ExplicitNullValueWeight(0.0), + OneOfTypeWeight(1.0) + ) // without default values - Arb.graphQLSchema(cfg + (DefaultValueWeight to 0.0)) + Arb.graphQLSchema(cfg + DefaultValueWeight(0.0)) .forAll { schema -> schema.allTypesAsList .mapNotNull { it as? GraphQLInputObjectType } @@ -350,7 +346,7 @@ class GraphQLSchemasTest : KotestPropertyBase() { } // with default values - Arb.graphQLSchema(cfg + (DefaultValueWeight to 1.0)) + Arb.graphQLSchema(cfg + DefaultValueWeight(1.0)) .forAll { schema -> schema.allTypesAsList .mapNotNull { it as? GraphQLInputObjectType } @@ -418,7 +414,7 @@ class GraphQLSchemasTest : KotestPropertyBase() { } Arb.graphQLSchema( - Config.default + (DefaultValueWeight to 1.0) + Config(DefaultValueWeight(1.0)) ).forAll(100) { schema -> !containsInvalidDefaultValues(schema) } @@ -427,11 +423,12 @@ class GraphQLSchemasTest : KotestPropertyBase() { @Test fun `generated schemas do not contain hard input cycles`(): Unit = runBlocking { - val names = GraphQLNames(mapOf(TypeType.Input to setOf("A", "B"))) - val cfg = Config.default + - (NonNullableTypeWeight to 1.0) + - (ListTypeWeight to CompoundingWeight.Never) + - (OneOfTypeWeight to 0.0) + val names = GraphQLNames(mapOf(TypeKind.Input to setOf("A", "B"))) + val cfg = Config( + NonNullableTypeWeight(1.0), + ListTypeWeight(CompoundingWeight.Never), + OneOfTypeWeight(0.0) + ) val arb = arbitrary { val types = Arb.graphQLTypes(names, cfg).bind() @@ -464,10 +461,11 @@ class GraphQLSchemasTest : KotestPropertyBase() { Arb.graphQLSchema( types, - Config.default + - (ExplicitNullValueWeight to 0.0) + - (MaxValueDepth to 3) + - (DefaultValueWeight to 1.0) + Config( + ExplicitNullValueWeight(0.0), + MaxValueDepth(3), + DefaultValueWeight(1.0) + ) ).forAll { schema -> val default = requireNotNull(schema.getTypeAs("Inp")) .getField("inp") @@ -478,33 +476,12 @@ class GraphQLSchemasTest : KotestPropertyBase() { } } - /** This test makes no assertions but is useful for debugging. */ - @Test - @Disabled - fun `seed march`(): Unit = - runBlocking { - var seed = 0L - - while (true) { - if (seed.mod(100) == 0) { - println("Seed $seed...") - } - try { - Arb.graphQLSchema().next(RandomSource.seeded(seed)) - } catch (e: Error) { - println("Failed on seed $seed") - throw e - } - seed += 1 - } - } - /** This test makes no assertions but is useful for debugging. */ @Test @Disabled fun `dump 1 schema`(): Unit = runBlocking { - val cfg = Config.default + (DescriptionLength to 0..0) + val cfg = Config(DescriptionLength(0..0)) Arb.graphQLSchema(cfg).checkAll(1) { val sdl = SchemaPrinter().print(it) println(sdl) @@ -573,13 +550,14 @@ class GraphQLSchemasTest : KotestPropertyBase() { private fun GraphQLSchema.transformAppliedDirectives( seed: Long = 0L, cfg: Config = - Config.default + - (AppliedDirectiveWeight to CompoundingWeight.Always) + - (DirectiveIsRepeatable to 0.0) + - (DefaultValueWeight to 0.0) + Config( + AppliedDirectiveWeight(CompoundingWeight.Always), + DirectiveIsRepeatable(0.0), + DefaultValueWeight(0.0) + ) ): GraphQLSchema { val rs = RandomSource.seeded(seed) - val valueGenerator = GraphQLInputValueGenerator( + val valueGenerator = GraphQLInputValueGen( schema = this, config = cfg, randomSource = rs, @@ -630,10 +608,10 @@ class GraphQLSchemasTest : KotestPropertyBase() { inputs: List = emptyList(), enums: List = emptyList() ): GraphQLSchema { - val cfg = - Config.default + - (AppliedDirectiveWeight to CompoundingWeight.Never) + - (DefaultValueWeight to 0.0) + val cfg = Config( + AppliedDirectiveWeight(CompoundingWeight.Never), + DefaultValueWeight(0.0) + ) val query = GraphQLObjectType .newObject() .name("Query") @@ -656,7 +634,7 @@ class GraphQLSchemasTest : KotestPropertyBase() { inputs = inputs.associateBy { it.name }, enums = enums.associateBy { it.name } ) - return SchemaGenerator(cfg, RandomSource.seeded(0)).createSchema(types) + return GraphQLSchemaGen(cfg, RandomSource.seeded(0)).gen(types) } private fun controlDirective( diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLStringsTest.kt similarity index 93% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLStringsTest.kt index 7a3c190dd..ae04f64f7 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLStringsTest.kt @@ -1,6 +1,6 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import graphql.Scalars import graphql.schema.GraphQLFieldDefinition @@ -10,7 +10,7 @@ import io.kotest.property.Arb import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test -class GraphQLStringsTest : KotestPropertyBase() { +class GraphQLStringsTest : ArbPropertyBase() { @Test fun `Arb_graphQLName produces spec-compliant names`(): Unit = runBlocking { @@ -108,10 +108,11 @@ class GraphQLStringsTest : KotestPropertyBase() { } // create a set of single-char names - val cfg = Config.default + - (BanFieldNames to banned) + - (FieldNameLength to 1..2) + - (SchemaSize to 1) + val cfg = Config( + BanFieldNames(banned), + FieldNameLength(1..2), + SchemaSize(1) + ) val arbs = listOf( Arb.graphQLFieldName(cfg), diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLTypesTest.kt similarity index 73% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLTypesTest.kt index 22aae99a0..566ab6b95 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/GraphQLTypesTest.kt @@ -1,6 +1,6 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import graphql.Scalars import graphql.schema.GraphQLFieldDefinition @@ -22,33 +22,35 @@ import io.kotest.property.arbitrary.of import io.kotest.property.arbitrary.pair import io.kotest.property.arbitrary.set import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -class GraphQLTypesTest : KotestPropertyBase() { +class GraphQLTypesTest : ArbPropertyBase() { /** * Tests in this suite can be slow when using [Config.default]. * Define a minimal config that has most features disabled. * Tests will need to modify this config to enable the features that they are interested in. */ - private val minimalConfig = Config.default + - (SchemaSize to 10) + - (DefaultValueWeight to 0.0) + - (AppliedDirectiveWeight to CompoundingWeight.Never) + - (DirectiveHasArgs to CompoundingWeight.Never) + - (FieldArgumentWeight to CompoundingWeight.Never) + - (ListTypeWeight to CompoundingWeight.Never) + - (InterfaceImplementsInterface to CompoundingWeight.Never) + - (ObjectImplementsInterface to CompoundingWeight.Never) + - (DescriptionLength to 0..0) + - (InputObjectTypeSize to 1..1) + - (ObjectTypeSize to 1..1) + - (UnionTypeSize to 1..1) + - (InterfaceTypeSize to 1..1) + - (EnumTypeSize to 1..1) + - (MaxValueDepth to 0) + - (StringValueSize to 1..1) + - (ListValueSize to 0..0) + - (OneOfTypeWeight to 0.0) + private val minimalConfig = Config( + SchemaSize(10), + DefaultValueWeight(0.0), + AppliedDirectiveWeight(CompoundingWeight.Never), + DirectiveHasArgs(CompoundingWeight.Never), + FieldArgumentWeight(CompoundingWeight.Never), + ListTypeWeight(CompoundingWeight.Never), + InterfaceImplementsInterface(CompoundingWeight.Never), + ObjectImplementsInterface(CompoundingWeight.Never), + DescriptionLength(0..0), + InputObjectTypeSize(1..1), + ObjectTypeSize(1..1), + UnionTypeSize(1..1), + InterfaceTypeSize(1..1), + EnumTypeSize(1..1), + MaxValueDepth(0), + StringValueSize(1..1), + ListValueSize(0..0), + OneOfTypeWeight(0.0) + ) private fun mkGen( cfg: Config = minimalConfig, @@ -70,14 +72,14 @@ class GraphQLTypesTest : KotestPropertyBase() { Arb .set(Arb.graphQLName()) .flatMap { dirNames -> - val names = GraphQLNames(mapOf(TypeType.Directive to dirNames)) - val cfg = minimalConfig + (DirectiveHasArgs to CompoundingWeight.Never) + val names = GraphQLNames(mapOf(TypeKind.Directive to dirNames)) + val cfg = minimalConfig + DirectiveHasArgs(CompoundingWeight.Never) Arb.graphQLTypes(names, cfg).map { dirNames to it } - }.checkInvariants { (dirNames, types), check -> - check.containsAtLeastElementsIn( - types.directives.keys, - dirNames, - "Missing directives" + }.checkAll { (dirNames, types) -> + val missing = dirNames - types.directives.keys + assertTrue( + missing.isEmpty(), + "Missing directives: $missing" ) } } @@ -88,7 +90,7 @@ class GraphQLTypesTest : KotestPropertyBase() { Arb .of(0.0, 1.0) .flatMap { weight -> - val cfg = minimalConfig + (DirectiveIsRepeatable to weight) + val cfg = minimalConfig + DirectiveIsRepeatable(weight) Arb.graphQLTypes(cfg).map { weight to it } }.forAll { (weight, types) -> types.directives.values @@ -116,15 +118,15 @@ class GraphQLTypesTest : KotestPropertyBase() { return loop(0, 0, this) } - val names = GraphQLNames(mapOf(TypeType.Scalar to setOf("A"))) + val names = GraphQLNames(mapOf(TypeKind.Scalar to setOf("A"))) Arb .pair( Arb.compoundingWeight(), Arb.element(0.0, 1.0), ).checkAll { (listTypeWeight, nonNullableTypeWeight) -> val cfg = minimalConfig + - (ListTypeWeight to listTypeWeight) + - (NonNullableTypeWeight to nonNullableTypeWeight) + ListTypeWeight(listTypeWeight) + + NonNullableTypeWeight(nonNullableTypeWeight) val (lists, nonNulls) = mkGen(cfg, names) .decorate(Scalars.GraphQLInt) @@ -151,7 +153,7 @@ class GraphQLTypesTest : KotestPropertyBase() { .intRange(0..100) .nonEmpty() .checkAll { range -> - val desc = mkGen(cfg = minimalConfig + (DescriptionLength to range)).genDescription() + val desc = mkGen(cfg = minimalConfig + DescriptionLength(range)).genDescription() if (range.contains(desc.length)) { markSuccess() } else { @@ -166,20 +168,20 @@ class GraphQLTypesTest : KotestPropertyBase() { Arb .compoundingWeight() .flatMap { cw -> - val cfg = minimalConfig + (FieldArgumentWeight to cw) + val cfg = minimalConfig + FieldArgumentWeight(cw) Arb.graphQLTypes(cfg).map { cw to it } - }.checkInvariants { (cw, types), check -> + }.checkAll { (cw, types) -> val fields = (types.objects.values + types.interfaces.values) .flatMap { it.fields } if (cw.weight == 1.0 && cw.max > 0 && fields.isNotEmpty()) { - check.isTrue( + assertTrue( fields.any { it.arguments.isNotEmpty() }, "No field arguments generated with FieldArgumentWeight $cw", ) } else { fields.forEach { f -> - check.isTrue( + assertTrue( f.arguments.isEmpty(), "Unexpected arguments on field ${f.name} with FieldArgumentWeight $cw", ) @@ -194,17 +196,17 @@ class GraphQLTypesTest : KotestPropertyBase() { Arb .of(true, false) .flatMap { incl -> - val cfg = minimalConfig + (IncludeBuiltinScalars to incl) + val cfg = minimalConfig + IncludeBuiltinScalars(incl) Arb.graphQLTypes(cfg).map { incl to it } - }.checkInvariants { (incl, types), check -> + }.checkAll { (incl, types) -> if (incl) { builtinScalars.keys.subtract(types.scalars.keys).let { missing -> - check.isEmpty(missing, "Missing scalars: $missing") + assertTrue(missing.isEmpty(), "Missing scalars: $missing") } } else { builtinScalars.keys.intersect(types.scalars.keys).let { intersect -> - check.isEmpty( - intersect, + assertTrue( + intersect.isEmpty(), "Expected no included directives but found $intersect" ) } @@ -218,18 +220,18 @@ class GraphQLTypesTest : KotestPropertyBase() { Arb .of(true, false) .flatMap { incl -> - val cfg = minimalConfig + (IncludeBuiltinDirectives to incl) + val cfg = minimalConfig + IncludeBuiltinDirectives(incl) Arb.graphQLTypes(cfg).map { incl to it } - }.checkInvariants { (incl, types), check -> + }.checkAll { (incl, types) -> if (incl) { - check.isTrue( + assertTrue( types.directives.keys.containsAll(builtinDirectives.keys), "Missing directives" ) } else { types.directives.keys.intersect(builtinDirectives.keys).let { intersect -> - check.isEmpty( - intersect, + assertTrue( + intersect.isEmpty(), "Expected no included directives but found $intersect" ) } @@ -244,23 +246,21 @@ class GraphQLTypesTest : KotestPropertyBase() { .compoundingWeight() .flatMap { cw -> val cfg = minimalConfig + - (DirectiveHasArgs to cw) + - (IncludeBuiltinDirectives to false) + DirectiveHasArgs(cw) + + IncludeBuiltinDirectives(false) Arb.graphQLTypes(cfg).map { cw to it } - }.checkInvariants { (cw, types), check -> + }.checkAll { (cw, types) -> if (cw.weight == 0.0 || cw.max == 0) { types.directives.values.forEach { dir -> - check.isTrue( + assertTrue( dir.arguments.isEmpty(), - "Unexpected arguments on directive {0} with DirectiveHasArgs {1}: {2}", - arrayOf(dir.name, cw.toString(), dir.arguments.toString()) + "Unexpected arguments on directive ${dir.name} with DirectiveHasArgs $cw: ${dir.arguments}" ) } } else if (types.directives.isNotEmpty()) { - check.isTrue( + assertTrue( types.directives.values.any { it.arguments.isNotEmpty() }, - "No directives with arguments generated with DirectiveHasArgs {0}", - arrayOf(cw.toString()) + "No directives with arguments generated with DirectiveHasArgs $cw" ) } } @@ -273,9 +273,9 @@ class GraphQLTypesTest : KotestPropertyBase() { .intRange(2..100) .nonEmpty() .flatMap { range -> - val cfg = minimalConfig + (FieldNameLength to range) + val cfg = minimalConfig + FieldNameLength(range) Arb.graphQLTypes(cfg).map { range to it } - }.checkInvariants { (range, types), check -> + }.checkAll { (range, types) -> val names = types.objects.values .flatMap { it.fields } .map { it.name } + @@ -287,10 +287,9 @@ class GraphQLTypesTest : KotestPropertyBase() { .map { it.name } names.forEach { name -> - check.isTrue( + assertTrue( range.contains(name.length), - "field name `{0}` has length {1} when range = {2}", - arrayOf(name, name.length.toString(), range.toString()) + "field name `$name` has length ${name.length} when range = $range" ) } } @@ -303,14 +302,13 @@ class GraphQLTypesTest : KotestPropertyBase() { .intRange(1..100) .nonEmpty() .flatMap { range -> - val cfg = minimalConfig + (InputObjectTypeSize to range) + val cfg = minimalConfig + InputObjectTypeSize(range) Arb.graphQLTypes(cfg).map { range to it } - }.checkInvariants { (range, types), check -> + }.checkAll { (range, types) -> types.inputs.values.forEach { inp -> - check.isTrue( + assertTrue( inp.fields.size <= range.last, - "input {0} has fields {1} outside of range {2}", - arrayOf(inp.name, inp.fields.size.toString(), range.toString()) + "input ${inp.name} has fields ${inp.fields.size} outside of range $range" ) } } @@ -320,13 +318,13 @@ class GraphQLTypesTest : KotestPropertyBase() { fun `GraphQLTypes - OneOfTypeWeight`(): Unit = runBlocking { // disabled - Arb.graphQLTypes(minimalConfig + (OneOfTypeWeight to 0.0)) + Arb.graphQLTypes(minimalConfig + OneOfTypeWeight(0.0)) .forAll { types -> types.inputs.values.none { it.isOneOf } } // enabled - Arb.graphQLTypes(minimalConfig + (OneOfTypeWeight to 1.0)) + Arb.graphQLTypes(minimalConfig + OneOfTypeWeight(1.0)) .forAll { types -> types.inputs.values.all { inp -> inp.isOneOf && inp.fields.all { field -> @@ -340,8 +338,8 @@ class GraphQLTypesTest : KotestPropertyBase() { @Test fun `GraphQLTypes - enum values use their names as backing values`(): Unit = runBlocking { - val names = GraphQLNames(mapOf(TypeType.Enum to setOf("E"))) - Arb.graphQLTypes(names, minimalConfig + (EnumTypeSize to 1..1)) + val names = GraphQLNames(mapOf(TypeKind.Enum to setOf("E"))) + Arb.graphQLTypes(names, minimalConfig + EnumTypeSize(1..1)) .forAll { types -> types.enums.values.all { e -> e.values.all { v -> @@ -358,14 +356,13 @@ class GraphQLTypesTest : KotestPropertyBase() { .intRange(1..100) .nonEmpty() .flatMap { range -> - val cfg = minimalConfig + (InterfaceTypeSize to range) + val cfg = minimalConfig + InterfaceTypeSize(range) Arb.graphQLTypes(cfg).map { range to it } - }.checkInvariants { (range, types), check -> + }.checkAll { (range, types) -> types.interfaces.values.forEach { iface -> - check.isTrue( + assertTrue( iface.fields.size <= range.last, - "interface {0} has fields {1} outside of range {2}", - arrayOf(iface.name, iface.fields.size.toString(), range.toString()) + "interface ${iface.name} has fields ${iface.fields.size} outside of range $range" ) } } @@ -378,14 +375,13 @@ class GraphQLTypesTest : KotestPropertyBase() { .intRange(1..100) .nonEmpty() .flatMap { range -> - val cfg = minimalConfig + (ObjectTypeSize to range) + val cfg = minimalConfig + ObjectTypeSize(range) Arb.graphQLTypes(cfg).map { range to it } - }.checkInvariants { (range, types), check -> + }.checkAll { (range, types) -> types.objects.values.forEach { obj -> - check.isTrue( + assertTrue( obj.fields.size <= range.last, - "object {0} has fields {1} outside of range {2}", - arrayOf(obj.name, obj.fields.size.toString(), range.toString()) + "object ${obj.name} has fields ${obj.fields.size} outside of range $range" ) } } @@ -410,19 +406,16 @@ class GraphQLTypesTest : KotestPropertyBase() { .compoundingWeight() .flatMap { cw -> val cfg = minimalConfig + - (ObjectImplementsInterface to cw) - (IncludeTypes to baseTypes) + ObjectImplementsInterface(cw) + + IncludeTypes(baseTypes) Arb.graphQLTypes(cfg).map { cw to it } - }.checkInvariants { (cw, types), check -> + }.checkAll { (cw, types) -> val expectInterfaces = cw.weight == 1.0 && cw.max > 0 val firstBad = types.objects.values.firstOrNull { it.interfaces.isNotEmpty() != expectInterfaces } - if (firstBad != null) { - check.addFailure( - null, - "ObjectImplementsInterface: ${firstBad.name} implements ${firstBad.interfaces.size} interfaces", - emptyArray() - ) - } + assertTrue( + firstBad == null, + "ObjectImplementsInterface: ${firstBad?.name} implements ${firstBad?.interfaces?.size} interfaces" + ) } } @@ -433,16 +426,15 @@ class GraphQLTypesTest : KotestPropertyBase() { .intRange(1..100) .nonEmpty() .flatMap { range -> - val cfg = minimalConfig + (UnionTypeSize to range) + val cfg = minimalConfig + UnionTypeSize(range) Arb.graphQLTypes(cfg).map { range to it } - }.checkInvariants { (range, types), check -> + }.checkAll { (range, types) -> // Due to collisions, union types may contain fewer members than the // lowest range value types.unions.values.forEach { union -> - check.isTrue( + assertTrue( union.types.size <= range.last, - "union {0} has members {1} greater than range range {2}", - arrayOf(union.name, union.types.size.toString(), range.toString()) + "union ${union.name} has members ${union.types.size} greater than range $range" ) } } @@ -460,7 +452,7 @@ class GraphQLTypesTest : KotestPropertyBase() { }.forAll { (names, types) -> val refs = names.names .filter { - it.key != TypeType.Directive + it.key != TypeKind.Directive }.flatMap { it.value } .map(GraphQLTypeReference::typeRef) @@ -496,7 +488,7 @@ class GraphQLTypesTest : KotestPropertyBase() { fun `GraphQLTypes -- generates escape fields for inputs`(): Unit = runBlocking { // Ensure that we can generate input types even when we insist on non-null non-list fields - Arb.graphQLTypes(minimalConfig + (NonNullableTypeWeight to 1.0)) + Arb.graphQLTypes(minimalConfig + NonNullableTypeWeight(1.0)) .forAll { types -> types.inputs.values.all { inp -> inp.fields.isNotEmpty() @@ -514,8 +506,8 @@ class GraphQLTypesTest : KotestPropertyBase() { // input A @oneOf { a:A, escape:Int } Arb.graphQLTypes( - GraphQLNames(mapOf(TypeType.Input to setOf("A"))), - minimalConfig + (OneOfTypeWeight to 1.0) + GraphQLNames(mapOf(TypeKind.Input to setOf("A"))), + minimalConfig + OneOfTypeWeight(1.0) ).forAll { types -> val a = types.inputs["A"]!! a.fields.any { it.name.startsWith("escape") && it.type is GraphQLScalarType } @@ -528,7 +520,7 @@ class GraphQLTypesTest : KotestPropertyBase() { Arb .graphQLTypes(minimalConfig) .flatMap { a -> - val cfg = minimalConfig + (IncludeTypes to a) + val cfg = minimalConfig + IncludeTypes(a) Arb.graphQLTypes(cfg).map { b -> a to b } }.forAll { (a, b) -> b.names.containsAll(a.names) @@ -538,7 +530,7 @@ class GraphQLTypesTest : KotestPropertyBase() { @Test fun `GraphQLTypes -- GenInterfaceStubsIfNeeded`(): Unit = runBlocking { - val cfg = minimalConfig + (GenInterfaceStubsIfNeeded to true) + val cfg = minimalConfig + GenInterfaceStubsIfNeeded(true) Arb.graphQLTypes(cfg).forAll { types -> val ifaces = types.interfaces.keys val implemented = types.objects.values.flatMap { it.interfaces.map { it.name } } diff --git a/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/IDValueGenTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/IDValueGenTest.kt new file mode 100644 index 000000000..8bcee7f66 --- /dev/null +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/IDValueGenTest.kt @@ -0,0 +1,18 @@ +package graphql.arbitrary + +import io.kotest.matchers.shouldBe +import io.kotest.property.RandomSource +import org.junit.jupiter.api.Test + +class IDValueGenTest { + @Test + fun `default factory uses ArbString`() { + val schema = parseTestSchema("type Query { id: ID }") + val config = Config(StringValueSize(7.asIntRange())) + val generator = config[IDValueGenFactory]( + IDValueGen.Factory.Params(schema, config, RandomSource.seeded(1)) + ) + + generator.gen().length.shouldBe(7) + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/PropertyTestFixtures.kt similarity index 94% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/PropertyTestFixtures.kt index 09d137ef5..2940921cd 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/PropertyTestFixtures.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.schema.GraphQLSchema import graphql.schema.idl.FastSchemaGenerator @@ -14,6 +14,7 @@ internal fun parseTestSchema( runtimeWiring ) +// JMB TODO: what is this? Try removing /** * Build the incomplete schema snippets used by Viaduct's original unit tests without depending on * Viaduct's permissive SchemaFactory. Missing operation roots are supplied only for test setup. diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemasTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/SchemasTest.kt similarity index 97% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemasTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/SchemasTest.kt index 54b92f0c8..3fbc492a3 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemasTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/SchemasTest.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.Directives import graphql.introspection.Introspection.DirectiveLocation diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/TypeRelationsTest.kt similarity index 68% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt rename to graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/TypeRelationsTest.kt index 8b91af894..fba4ac4df 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/TypeRelationsTest.kt @@ -1,4 +1,4 @@ -package graphql.schema.property +package graphql.arbitrary import graphql.introspection.Introspection.DirectiveLocation import graphql.schema.GraphQLCompositeType @@ -6,7 +6,8 @@ import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test -class SchemaRelationshipsTest { +// JMB TODO: not nearly enough tests +class TypeRelationsTest { private val schema = parseTestSchema( """ directive @fieldDirective on FIELD @@ -18,16 +19,16 @@ class SchemaRelationshipsTest { type Query { i: I, u: U } """.trimIndent() ) - private val relationships = SchemaRelationships(schema) + private val rels = TypeRelations(schema) @Test fun `possibleObjectTypes expands objects, interfaces, and unions`() { val interfaceType = schema.getType("I") as GraphQLCompositeType val unionType = schema.getType("U") as GraphQLCompositeType val objectType = schema.getType("A") as GraphQLCompositeType - relationships.possibleObjectTypes(interfaceType).map { it.name }.shouldBe(setOf("A", "B")) - relationships.possibleObjectTypes(unionType).map { it.name }.shouldBe(setOf("A", "C")) - relationships.possibleObjectTypes(objectType).map { it.name }.shouldBe(setOf("A")) + rels.possibleObjectTypes(interfaceType).map { it.name }.shouldBe(setOf("A", "B")) + rels.possibleObjectTypes(unionType).map { it.name }.shouldBe(setOf("A", "C")) + rels.possibleObjectTypes(objectType).map { it.name }.shouldBe(setOf("A")) } @Test @@ -36,10 +37,10 @@ class SchemaRelationshipsTest { val unionType = schema.getType("U") as GraphQLCompositeType val a = schema.getType("A") as GraphQLCompositeType val b = schema.getType("B") as GraphQLCompositeType - relationships.isSpreadable(interfaceType, unionType).shouldBe(true) - relationships.isSpreadable(a, unionType).shouldBe(true) - relationships.isSpreadable(b, unionType).shouldBe(false) - relationships.spreadableTypes(a).shouldContain(interfaceType) + rels.isSpreadable(interfaceType, unionType).shouldBe(true) + rels.isSpreadable(a, unionType).shouldBe(true) + rels.isSpreadable(b, unionType).shouldBe(false) + rels.spreadableTypes(a).shouldContain(interfaceType) } @Test diff --git a/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/UtilTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/UtilTest.kt new file mode 100644 index 000000000..dd35af591 --- /dev/null +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/UtilTest.kt @@ -0,0 +1,178 @@ +@file:Suppress("ForbiddenImport") + +package graphql.arbitrary + +import graphql.language.AstPrinter +import graphql.language.ListType +import graphql.language.NonNullType +import graphql.language.Type +import graphql.language.TypeName +import io.kotest.matchers.types.shouldBeInstanceOf +import io.kotest.property.Arb +import io.kotest.property.arbitrary.char +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.long +import io.kotest.property.arbitrary.set +import io.kotest.property.arbitrary.string +import io.kotest.property.forAll +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows + +class UtilTest : ArbPropertyBase() { + @Test + fun `Int asIntRange`(): Unit = + runBlocking { + Arb.int().forAll { i -> + val range = i.asIntRange() + val isEmpty = range.isEmpty() + val containsI = range.contains(i) + val checkLo = if (i > Int.MIN_VALUE) !range.contains(i - 1) else true + val checkHi = if (i < Int.MAX_VALUE) !range.contains(i + 1) else true + + !isEmpty && checkLo && containsI && checkHi + } + } + + @Test + fun `Int asLongRange`(): Unit = + runBlocking { + Arb.int().forAll { i -> + val range = i.asLongRange() + val isEmpty = range.isEmpty() + val containsI = range.contains(i) + val checkLo = !range.contains(i.toLong() - 1) + val checkHi = !range.contains(i.toLong() + 1) + + !isEmpty && checkLo && containsI && checkHi + } + } + + @Test + fun `Long asLongRange()`(): Unit = + runBlocking { + Arb.long().forAll { l -> + val range = l.asLongRange() + val isEmpty = range.isEmpty() + val containsL = range.contains(l) + val checkLo = if (l > Long.MIN_VALUE) !range.contains(l - 1) else true + val checkHi = if (l < Long.MAX_VALUE) !range.contains(l + 1) else true + + !isEmpty && checkLo && containsL && checkHi + } + } + + @Test + fun `Set arbSubset`(): Unit = + runBlocking { + // without range + Arb + .set(Arb.int()) + .forAll { set -> + val subset = set.arbSubset().bind() + set.containsAll(subset) + } + + // with range + Arb + .set(Arb.int()) + .forAll { set -> + val subset = set.arbSubset(set.size.asIntRange()).bind() + set == subset + } + } + + @Test + fun collect(): Unit = + runBlocking { + listOf(Arb.int(), Arb.string(), Arb.char()) + .collect() + .forAll { l -> + l[0] is Int && l[1] is String && l[2] is Char + } + } + + @Test + fun `GraphQLTypes can be roundtripped through Type`() { + val sdl = """ + type Query { + a:Int + b:Enum + c:[[Int]] + d:[[Int!]!]! + e: U + f(inp:Inp!):Int + } + union U = Query + enum Enum { A, B } + input Inp { + x:Int! + inp:Inp + } + """.trimIndent() + val doc = sdl.asDocument + val schema = sdl.asSchema + + val types = doc.allChildrenOfType>() + // sanity + assertTrue(types.isNotEmpty()) + + types.forEach { t1 -> + val t2 = t1.asSchemaType(schema).asAstType() + assertTypesEqual(t1, t2) + } + } + + @Test + fun `String asSchema`() { + val schema = "type Query { x:Int }".asSchema + assertNotNull(schema.queryType.getField("x")) + } + + @Test + fun `String asDocument`() { + val sdl = "type Query {x: Int}" + assertEquals(sdl, AstPrinter.printAstCompact(sdl.asDocument)) + } + + @Test + fun `maybeThrowResolverException`(): Unit = + runBlocking { + // does not throw + val key = object : ConfigKey(0.0, WeightValidator) {} + assertDoesNotThrow { + maybeThrowResolverException(Config.default, key, randomSource) + } + + // throws + val err = assertThrows { + maybeThrowResolverException(Config(key(1.0)), key, randomSource) + } + assertEquals(key, err.key) + assertTrue(key.javaClass.name in err.message) + } +} +private fun assertTypesEqual( + t1: Type<*>, + t2: Type<*> +) { + when (t1) { + is TypeName -> { + t2.shouldBeInstanceOf() + assertEquals(t1.name, t2.name) + } + is NonNullType -> { + t2.shouldBeInstanceOf() + assertTypesEqual(t1.type, t2.type) + } + is ListType -> { + t2.shouldBeInstanceOf() + assertTypesEqual(t1.type, t2.type) + } + else -> throw IllegalArgumentException("unknown Type: $t1") + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBase.kt b/graphql-java-arbitrary/src/testFixtures/kotlin/graphql/arbitrary/ArbPropertyBase.kt similarity index 85% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBase.kt rename to graphql-java-arbitrary/src/testFixtures/kotlin/graphql/arbitrary/ArbPropertyBase.kt index f2edcad07..d807e1452 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBase.kt +++ b/graphql-java-arbitrary/src/testFixtures/kotlin/graphql/arbitrary/ArbPropertyBase.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalKotest::class) -package graphql.schema.property +package graphql.arbitrary import io.kotest.common.ExperimentalKotest import io.kotest.property.Gen @@ -8,7 +8,6 @@ import io.kotest.property.PropTestConfig import io.kotest.property.PropertyContext import io.kotest.property.PropertyTesting import io.kotest.property.RandomSource -import io.kotest.property.checkAll import kotlin.random.Random import org.junit.jupiter.api.parallel.Execution import org.junit.jupiter.api.parallel.ExecutionMode @@ -19,7 +18,7 @@ import org.junit.jupiter.api.parallel.ExecutionMode * Provides a per-instance [seed] that is threaded through all property test invocations * via [PropTestConfig], avoiding the global mutable state of [io.kotest.property.PropertyTesting.defaultSeed]. * - * To manage test performance, test suites that extend KotestPropertyBase will be run with + * To manage test performance, test suites that extend ArbPropertyBase will be run with * [ExecutionMode.CONCURRENT]. This will run each test method in a separate thread, in its own * instance of the test suite class. * This requires opting in to concurrent junit execution in the build system of the test runner: @@ -50,7 +49,7 @@ import org.junit.jupiter.api.parallel.ExecutionMode * [forNone], etc */ @Execution(ExecutionMode.CONCURRENT) -abstract class KotestPropertyBase( +abstract class ArbPropertyBase( val seed: Long = Random.nextLong(), val iterations: Int = PropertyTesting.defaultIterationCount ) { @@ -58,7 +57,7 @@ abstract class KotestPropertyBase( val randomSource: RandomSource = RandomSource.seeded(seed) init { - println("[KotestPropertyBase] ${this::class.simpleName} seed=$seed") + println("[ArbPropertyBase] ${this::class.simpleName} seed=$seed") } private fun config(iterations: Int? = this.iterations): PropTestConfig = @@ -97,20 +96,6 @@ abstract class KotestPropertyBase( property: suspend PropertyContext.(A) -> Boolean ) = io.kotest.property.forNone(config(iterations), this, property) - /** assert that all values of an Arb pass the invariant checks defined by a provided function */ - suspend fun Gen.checkInvariants( - iter: Int? = null, - fn: PropertyContext.(T, FailureCollector) -> Unit - ) { - val cfg = config(iter) - checkAll(cfg) { - val check = FailureCollector() - this.fn(it, check) - check.assertEmpty("\n") - markSuccess() - } - } - /** Convert an arb to an infinite [kotlin.sequences.Sequence] */ fun Gen.asSequence(): Sequence = generate(randomSource).map { it.value } } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuite.kt b/graphql-java-arbitrary/src/testFixtures/kotlin/graphql/arbitrary/DeepArbSuite.kt similarity index 97% rename from graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuite.kt rename to graphql-java-arbitrary/src/testFixtures/kotlin/graphql/arbitrary/DeepArbSuite.kt index 77147fc5a..a52215a37 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuite.kt +++ b/graphql-java-arbitrary/src/testFixtures/kotlin/graphql/arbitrary/DeepArbSuite.kt @@ -1,6 +1,6 @@ @file:Suppress("ForbiddenImport") -package graphql.schema.property +package graphql.arbitrary import java.io.File import java.io.FileWriter @@ -26,7 +26,7 @@ abstract class DeepArbSuite( iterations: Int = 1_000, private val minViolationIterations: Int = iterations * 10, private val seedMarchStartingSeed: Long = 0, -) : KotestPropertyBase(seed, iterations) { +) : ArbPropertyBase(seed, iterations) { protected abstract val checkedArb: CheckedArb protected abstract val comparator: Comparator diff --git a/graphql-java-property-testing/README.md b/graphql-java-property-testing/README.md deleted file mode 100644 index 64b66acbc..000000000 --- a/graphql-java-property-testing/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# graphql-java property testing - -This module provides [Kotest property-testing][kotest] `Arb` generators for -graphql-java schemas, types, documents, execution inputs, output values, and -related test data. - -Use the same version as graphql-java: - -```kotlin -testImplementation("com.graphql-java:graphql-java-property-testing:") -``` - -For example: - -```kotlin -checkAll(Arb.graphQLSchema()) { schema -> - // Check a property of the generated GraphQLSchema. -} -``` - -The implementation is derived from the [Airbnb Viaduct repository][viaduct], -specifically its [`core/shared/arbitrary` module][module] at -[commit `e91e45f1976d7311729af32d48fcd4aab8652516`][commit], and is adapted to -generate graphql-java's native schema and language AST types. - -The upstream source is licensed under Apache License 2.0. The license text is -included in the published artifact and is also available in this repository at -`additionallicenses/APACHE-LICENSE-2.0.txt`. - -## Viaduct-only test exclusions - -The upstream tests keep their original JUnit 5 structure and use Kotest for -property generation. The following cases are intentionally not copied because -executing them would require Viaduct runtime or schema classes: - -- `GraphQLDocumentGenTest`: the `Arb.viaductSchema()` half of - `Arb_graphQLDocument -- generates valid documents for arbitrary schemas`. - The graphql-java schema half remains in the test. -- `GraphQLSchemasTest`: `Arb-viaductSchema can generate schemas` and - `regression -- applied directives can be binary encoded without directive - cycles`. The Viaduct binary-encoding assertion is also omitted from the three - `AddAppliedDirectives prevents ...` tests; their directive-cycle assertions - remain. -- `UtilTest`: `ViaductSchema objects` and `ViaductSchema objectCoordinates`. -- The complete `ArbIRResultTest`, `ArbIRTest`, and `IDValueGenTest` suites, - which require Viaduct IR, global-ID, or mapping types. -- The complete `ArbitraryEngineSelectionSetImplTest`, `CheckersTest`, - `EngineDataExerciserTest`, `FieldResolversTest`, `NodeResolversTest`, - `RequiredSelectionSetGenTest`, `ResolverConfigTest`, `ResolverValueGenTest`, - and `VariablesResolversTest` suites, which require Viaduct engine selection, - resolver, or execution APIs. The native graphql-java value-generator - replacements have their own tests in `GraphQLExternalInputValueGeneratorTest` - and `GraphQLOutputValuesTest`. -- The complete `ViaductDescriptorTest`, `ViaductExecutionInputsTest`, - `ViaductSchemasTest`, and `ViaductsTest` suites, which exercise Viaduct - service, schema, bootstrap, or execution APIs. - -The original manually-run `DeepArbSuite` seed-march/minimum-violation tests and -the `GraphQLSchemasTest` seed-march/schema-dump tests remain `@Disabled`, as they -are upstream; they are retained rather than excluded. - -[kotest]: https://kotest.io/docs/proptest/property-based-testing.html -[viaduct]: https://github.com/airbnb/viaduct -[module]: https://github.com/airbnb/viaduct/tree/e91e45f1976d7311729af32d48fcd4aab8652516/core/shared/arbitrary -[commit]: https://github.com/airbnb/viaduct/commit/e91e45f1976d7311729af32d48fcd4aab8652516 diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/ArbExt.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/ArbExt.kt deleted file mode 100644 index 4c838791f..000000000 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/ArbExt.kt +++ /dev/null @@ -1,71 +0,0 @@ -@file:Suppress("ForbiddenImport") - -package graphql.schema.property - -import io.kotest.property.Arb -import io.kotest.property.Gen -import io.kotest.property.RandomSource -import io.kotest.property.Sample -import io.kotest.property.arbitrary.filter -import io.kotest.property.arbitrary.map -import io.kotest.property.arbitrary.next -import io.kotest.property.asSample - -/** Convert an arb to an infinite [kotlin.sequences.Sequence] */ -fun Gen.asSequence(rs: RandomSource): Sequence = generate(rs).map { it.value } - -/** - * Flatten this Arb into an Arb of the inner item type. - * The new Arb will return items in the same order as produced by the original Arb. - * - * The underlying Arb must eventually produce a non-empty Iterable; if every sample is empty, - * [Arb.sample] will loop indefinitely. - */ -fun Arb>.flatten(): Arb = Flatten(map { it.iterator() }) - -/** transform this Arb using [fn], dropping any null values returned by [fn] */ -@JvmName("mapNotNull") -fun Arb.mapNotNull(fn: (T) -> R?): Arb = map(fn).filter { it != null }.map { it!! } - -internal class Flatten( - val underlying: Arb>, -) : Arb() { - private var chunk: Iterator? = null - - override fun edgecase(rs: RandomSource): Sample? { - // Don't interrupt an active chunk — items within a chunk must remain consecutive. - if (chunk?.hasNext() == true) return null - val iter = underlying.edgecase(rs)?.value ?: return null - if (!iter.hasNext()) return null - chunk = iter - return chunk!!.next().asSample() - } - - override fun sample(rs: RandomSource): Sample { - while (chunk == null || chunk?.hasNext() == false) { - chunk = underlying.sample(rs).value - } - return chunk!!.next().asSample() - } -} - -/** - * Throw a property check failure. - * The [seed] parameter is included in the error message for reproducibility. - */ -fun failProperty( - message: String, - cause: Throwable? = null, - seed: Long? = null -): Unit = - throw AssertionError( - buildString { - if (seed != null) { - appendLine("Property failed with seed $seed") - } else { - appendLine("Property failed") - } - append(message) - }, - cause - ) diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CallRecorder.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CallRecorder.kt deleted file mode 100644 index acc6b60e2..000000000 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CallRecorder.kt +++ /dev/null @@ -1,107 +0,0 @@ -@file:OptIn(ExperimentalTime::class) - -package graphql.schema.property - -import java.util.concurrent.CopyOnWriteArrayList -import kotlin.collections.plusAssign -import kotlin.time.Duration -import kotlin.time.ExperimentalTime -import kotlin.time.TimedValue -import kotlin.time.measureTimedValue - -/** - * Wraps a function [fn] (suspending or blocking) and records each invocation, capturing the - * argument, result, and elapsed time. Useful for asserting how a resolver or callback was - * invoked during a test. - */ -sealed class CallRecorder { - /** A single recorded invocation of [fn]. */ - data class Entry(val arg: T, val result: Result, val time: Duration) { - override fun toString(): String = "arg=$arg result=$result time=$time" - } - - private val _log = CopyOnWriteArrayList>() - - /** All recorded invocations, in the order they were made. */ - val log: List> get() = _log.toList() - - /** - * The argument of the single recorded invocation. - * Throws if [log] does not contain exactly 1 entry. - */ - val arg: T get() = singleEntry().arg - - /** - * The result of the single recorded invocation. - * Throws if [log] does not contain exactly 1 entry. - */ - val result: Result get() = singleEntry().result - - /** - * The elapsed time of the single recorded invocation. - * Throws if [log] does not contain exactly 1 entry. - */ - val time: Duration get() = singleEntry().time - - /** - * Returns the single entry in [log]. - * - * Throws [IllegalArgumentException] if [log] does not contain exactly 1 entry. - * Use [log] directly when more than one invocation is expected. - */ - fun singleEntry(): Entry { - require(_log.size == 1) { - buildString { - appendLine("Expected only 1 entry in the recorder log, but found ${_log.size}") - for (entry in _log) { - appendLine(entry) - } - } - } - return _log[0] - } - - protected fun record( - arg: T, - timedValue: TimedValue> - ): R { - _log += Entry(arg, timedValue.value, timedValue.duration) - return timedValue.value.getOrThrow() - } - - companion object { - operator fun invoke(fn: suspend (T) -> R): AsyncCallRecorder = AsyncCallRecorder(fn) - - fun sync(fn: (T) -> R): SyncCallRecorder = SyncCallRecorder(fn) - } -} - -class AsyncCallRecorder(private val fn: suspend (T) -> R) : CallRecorder() { - /** - * Invokes [fn] with [arg], records the result and elapsed time, then returns the - * value or rethrows any exception thrown by [fn]. - */ - suspend operator fun invoke(arg: T): R { - val timedValue = measureTimedValue { - runCatching { - fn(arg) - } - } - return record(arg, timedValue) - } -} - -class SyncCallRecorder(private val fn: (T) -> R) : CallRecorder() { - /** - * Invokes [fn] with [arg], records the result and elapsed time, then returns the - * value or rethrows any exception thrown by [fn]. - */ - operator fun invoke(arg: T): R { - val timedValue = measureTimedValue { - runCatching { - fn(arg) - } - } - return record(arg, timedValue) - } -} diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTestData.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTestData.kt deleted file mode 100644 index d17a2cf30..000000000 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTestData.kt +++ /dev/null @@ -1,203 +0,0 @@ -package graphql.schema.property - -import graphql.Directives -import graphql.language.Directive -import graphql.language.Document -import graphql.language.Field -import graphql.language.FragmentDefinition -import graphql.language.FragmentSpread -import graphql.language.InlineFragment -import graphql.language.VariableDefinition -import graphql.language.VariableReference -import graphql.parser.Parser -import graphql.schema.GraphQLEnumType -import graphql.schema.GraphQLInterfaceType -import graphql.schema.GraphQLList -import graphql.schema.GraphQLNonNull -import graphql.schema.GraphQLObjectType -import graphql.schema.GraphQLSchema -import graphql.schema.GraphQLType -import graphql.schema.GraphQLTypeUtil -import graphql.schema.GraphQLUnionType -import graphql.schema.idl.FastSchemaGenerator -import graphql.schema.idl.RuntimeWiring -import graphql.schema.idl.SchemaParser -import graphql.schema.idl.SchemaPrinter -import io.kotest.property.Arb -import io.kotest.property.arbitrary.filter -import io.kotest.property.arbitrary.flatMap -import io.kotest.property.arbitrary.map - -/** A generated schema, executable document, and matching external variable values. */ -data class GraphQLTestData( - val sdl: String, - val query: String, - val variables: Map = emptyMap() -) - -/** - * Generate complete GraphQL test data, optionally filtering it using [MinQueryLength] and - * [MinVariablesCount]. The remaining schema and document configuration keys shape the data. - */ -fun Arb.Companion.graphQLTestData(config: Config = Config.default): Arb = - graphQLSchema(config).flatMap { schema -> - graphQLExecutionInput(schema, config) - .filter { executionInput -> - executionInput.query.length >= config[MinQueryLength] && - executionInput.variables.size >= config[MinVariablesCount] - }.map { executionInput -> - GraphQLTestData( - sdl = SchemaPrinter().print(schema), - query = executionInput.query, - variables = executionInput.variables - ) - } - } - -/** Summary counts for generated GraphQL test data. */ -data class GraphQLTestDataStats( - val schemaStats: Map, - val queryStats: Map -) { - /** Format the collected statistics as an indented human-readable report. */ - fun format(indent: String = " "): String = - formatMap("schema", schemaStats, indent) + formatMap("query", queryStats, indent) - - private fun formatMap( - label: String, - values: Map, - indent: String - ): String = - buildString { - append(label.prependIndent(indent)) - appendLine(":") - values.forEach { (key, value) -> - append(key.prependIndent(indent + indent)) - appendLine(": $value") - } - } - - companion object { - /** Derive statistics from [data]. */ - operator fun invoke(data: GraphQLTestData): GraphQLTestDataStats { - val schema = parseSchema(data.sdl) - val document = Parser().parseDocument(data.query) - return GraphQLTestDataStats( - schemaStats = schemaStats(data, schema), - queryStats = queryStats(data, document) - ) - } - - private fun parseSchema(sdl: String): GraphQLSchema = - FastSchemaGenerator().makeExecutableSchema( - SchemaParser().parse(sdl), - RuntimeWiring.MOCKED_WIRING - ) - - private fun schemaStats( - data: GraphQLTestData, - schema: GraphQLSchema - ): Map { - var objectTypes = 0 - var objectFields = 0 - var unionTypes = 0 - var listTypedFields = 0 - var nonNullFields = 0 - var interfaceTypes = 0 - var interfaceFields = 0 - var enumTypes = 0 - var concretizations = 0 - - schema.typeMap.values.forEach { type -> - when (type) { - is GraphQLObjectType -> { - objectTypes++ - if (type.interfaces.isNotEmpty()) concretizations++ - objectFields += type.fieldDefinitions.size - type.fieldDefinitions.forEach { field -> - var fieldType: GraphQLType = field.type - while (GraphQLTypeUtil.isWrapped(fieldType)) { - if (fieldType is GraphQLList) listTypedFields++ - if (fieldType is GraphQLNonNull) nonNullFields++ - fieldType = GraphQLTypeUtil.unwrapOne(fieldType) - } - } - } - is GraphQLUnionType -> { - unionTypes++ - concretizations += type.types.size - } - is GraphQLEnumType -> enumTypes++ - is GraphQLInterfaceType -> { - interfaceTypes++ - interfaceFields += type.fieldDefinitions.size - } - } - } - - return linkedMapOf( - "sdl length" to data.sdl.length, - "type definitions" to schema.typeMap.size, - "object definitions" to objectTypes, - "object fields" to objectFields, - "list-typed object fields" to listTypedFields, - "non-nullable object fields" to nonNullFields, - "union definitions" to unionTypes, - "interface definitions" to interfaceTypes, - "interface fields" to interfaceFields, - "enum definitions" to enumTypes, - "type concretizations" to concretizations - ) - } - - private fun queryStats( - data: GraphQLTestData, - document: Document - ): Map { - var fieldSelections = 0 - var aliasedSelections = 0 - var argumentedSelections = 0 - var fragmentSpreads = 0 - var inlineFragments = 0 - var fragmentDefinitions = 0 - var variableReferences = 0 - var variableDefinitions = 0 - var skipIncludes = 0 - - document.allChildren.forEach { node -> - when (node) { - is Field -> { - fieldSelections++ - if (node.alias != null) aliasedSelections++ - if (node.arguments.isNotEmpty()) argumentedSelections++ - } - is FragmentDefinition -> fragmentDefinitions++ - is FragmentSpread -> fragmentSpreads++ - is InlineFragment -> inlineFragments++ - is VariableReference -> variableReferences++ - is VariableDefinition -> variableDefinitions++ - is Directive -> { - if (node.name in setOf(Directives.SkipDirective.name, Directives.IncludeDirective.name)) { - skipIncludes++ - } - } - } - } - - return linkedMapOf( - "query length" to data.query.length, - "query lines" to data.query.lines().size, - "field selections" to fieldSelections, - "aliased selections" to aliasedSelections, - "argumented selections" to argumentedSelections, - "fragment definitions" to fragmentDefinitions, - "fragment spreads" to fragmentSpreads, - "inline fragments" to inlineFragments, - "variable definitions" to variableDefinitions, - "variable references" to variableReferences, - "skips and includes" to skipIncludes, - "external variables" to data.variables.size - ) - } - } -} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ArbExtTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ArbExtTest.kt deleted file mode 100644 index 844aa1d63..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ArbExtTest.kt +++ /dev/null @@ -1,123 +0,0 @@ -@file:Suppress("ForbiddenImport") -@file:OptIn(ExperimentalCoroutinesApi::class) - -package graphql.schema.property - -import io.kotest.property.Arb -import io.kotest.property.RandomSource -import io.kotest.property.arbitrary.char -import io.kotest.property.arbitrary.constant -import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.list -import io.kotest.property.arbitrary.map -import io.kotest.property.arbitrary.next -import io.kotest.property.arbitrary.of -import io.kotest.property.arbitrary.take -import java.util.UUID -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows - -class ArbExtTest : KotestPropertyBase() { - @Test - fun `Gen_asSequence`(): Unit = - runBlocking { - Arb - .list(Arb.int(), 0..10) - .map { list -> - val arb = Arb.constant(list) - val seq = arb.asSequence(RandomSource.default()) - arb to seq - }.forAll { (arb, seq) -> - seq.first() == arb.next(randomSource) - } - } - - @Test - fun `Arb_flatten`(): Unit = - runBlocking { - val chunkSize = 10 - val arb = Arb - .int(0..10) - .map { i -> - Arb - .char('a'..'z') - .map { c -> i to c } - .take(chunkSize, randomSource) - .toList() - }.flatten() - - arb - .take(chunkSize * 1_000, randomSource) - .chunked(chunkSize) - .forEach { chunk -> - val firsts = chunk.map { it.first } - assertEquals(1, firsts.distinct().size) - } - } - - @Test - fun `Arb_flatten -- edge case delegates to underlying`() { - val arb = Arb.constant(listOf(1, 2, 3)).flatten() - val edge = arb.edgecase(RandomSource.seeded(0)) - // edgecase returns first element from underlying's edgecase iterator - assertTrue(edge != null && edge.value in listOf(1, 2, 3)) - } - - @Test - fun `Arb_mapNotNull`(): Unit = - runBlocking { - Arb.int(0..100) - .mapNotNull { - it.takeIf { it % 2 == 0 }?.toString() - }.forAll { - (it.toInt() % 2 == 0) - } - } - - @Test - fun `Any_failProperty -- failure message includes seed when provided`() { - // no seed value is provided - assertThrows { - failProperty("msg") - }.let { - assertTrue(it.message?.contains("Property failed") ?: false) - assertTrue(it.message?.contains("msg") ?: false) - } - - // provide a seed value - val explicitSeed = UUID.randomUUID().leastSignificantBits - assertThrows { - failProperty("msg", seed = explicitSeed) - }.let { - assertTrue(it.message?.contains(explicitSeed.toString()) ?: false) - } - } - - @Test - fun `Arb_flatten -- items within a chunk are consecutive`() { - val arb = Arb.of(listOf(1, 1), listOf(2, 2)).flatten() - val values = arb.asSequence(randomSource).take(4).toList() - // each pair of consecutive items must come from the same chunk - assertEquals(values[0], values[1]) - assertEquals(values[2], values[3]) - } - - @Test - fun `Arb_flatten -- skips empty chunks`() { - // Alternates between empty and non-empty lists; flatten must not throw - val arb = Arb.of(emptyList(), listOf("a"), emptyList(), listOf("b")).flatten() - val values = arb.asSequence(randomSource).take(10).toList() - assertTrue(values.all { it == "a" || it == "b" }) - } - - @Test - fun `mapNotNull drops nulls`() { - val arb = Arb.of(1, 2, 3, 4, 5).mapNotNull { if (it % 2 == 0) it else null } - val values = arb.asSequence(randomSource).take(100).toSet() - assertEquals(setOf(2, 4), values) - } -} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CallRecorderTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CallRecorderTest.kt deleted file mode 100644 index 7a5845a12..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CallRecorderTest.kt +++ /dev/null @@ -1,159 +0,0 @@ -@file:OptIn(ExperimentalTime::class) -@file:Suppress("ForbiddenImport") - -package graphql.schema.property - -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.ExperimentalTime -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows - -class CallRecorderTest { - @Test - fun `records a successful invocation`() = - runBlocking { - val recorder = CallRecorder { it.toString() } - recorder(42) - - assertEquals(1, recorder.log.size) - assertEquals(42, recorder.log[0].arg) - assertTrue(recorder.log[0].result.isSuccess) - assertEquals("42", recorder.log[0].result.getOrThrow()) - } - - @Test - fun `re-throws exception and records the failure`() { - val ex = RuntimeException("boom") - val recorder = CallRecorder { throw ex } - - val thrown = assertThrows { runBlocking { recorder(1) } } - - assertEquals(ex, thrown) - assertEquals(1, recorder.log.size) - assertFalse(recorder.log[0].result.isSuccess) - assertEquals(ex, recorder.log[0].result.exceptionOrNull()) - } - - @Test - fun `records multiple invocations in call order`() = - runBlocking { - val recorder = CallRecorder { it * 2 } - recorder(1) - recorder(2) - recorder(3) - - assertEquals(3, recorder.log.size) - assertEquals(listOf(1, 2, 3), recorder.log.map { it.arg }) - assertEquals(listOf(2, 4, 6), recorder.log.map { it.result.getOrThrow() }) - } - - @Test - fun `log returns a stable snapshot`() = - runBlocking { - val recorder = CallRecorder { it } - recorder(1) - val snapshot = recorder.log - - recorder(2) - - assertEquals(1, snapshot.size, "snapshot should not reflect subsequent invocations") - assertEquals(2, recorder.log.size) - } - - @Test - fun `records elapsed time`() = - runBlocking { - val recorder = CallRecorder { delay(50) } - recorder(Unit) - - assertTrue(recorder.time >= 50.milliseconds) { - "expected time >= 50ms, got ${recorder.time}" - } - } - - @Test - fun `Entry toString contains arg result and time`() = - runBlocking { - val recorder = CallRecorder { it.toString() } - recorder(99) - val str = recorder.singleEntry().toString() - - assertTrue("arg=99" in str) - assertTrue("result=" in str) - assertTrue("time=" in str) - } - - @Nested - inner class SingleEntryTests { - @Test - fun `returns the entry when exactly one invocation was recorded`() = - runBlocking { - val recorder = CallRecorder { it } - recorder(7) - - assertEquals(7, recorder.singleEntry().arg) - assertEquals(7, recorder.singleEntry().result.getOrThrow()) - assertEquals(recorder.singleEntry().time, recorder.time) - } - - @Test - fun `throws with count and entry details when log is not exactly 1`() = - runBlocking { - val recorder = CallRecorder { it } - - val emptyEx = assertThrows { recorder.singleEntry() } - assertTrue(emptyEx.message!!.contains("0"), "error message should include the entry count") - - recorder(10) - recorder(20) - val multiEx = assertThrows { recorder.singleEntry() } - assertTrue(multiEx.message!!.contains("2"), "error message should include the entry count") - assertTrue("arg=10" in multiEx.message!!) - assertTrue("arg=20" in multiEx.message!!) - } - } - - @Nested - inner class SyncCallRecorderTests { - @Test - fun `records a successful invocation`() { - val recorder = CallRecorder.sync { it.toString() } - recorder(42) - - assertEquals(1, recorder.log.size) - assertEquals(42, recorder.log[0].arg) - assertTrue(recorder.log[0].result.isSuccess) - assertEquals("42", recorder.log[0].result.getOrThrow()) - } - - @Test - fun `re-throws exception and records the failure`() { - val ex = RuntimeException("boom") - val recorder = CallRecorder.sync { throw ex } - - val thrown = assertThrows { recorder(1) } - - assertEquals(ex, thrown) - assertEquals(1, recorder.log.size) - assertFalse(recorder.log[0].result.isSuccess) - assertEquals(ex, recorder.log[0].result.exceptionOrNull()) - } - - @Test - fun `records multiple invocations in call order`() { - val recorder = CallRecorder.sync { it * 2 } - recorder(1) - recorder(2) - recorder(3) - - assertEquals(listOf(1, 2, 3), recorder.log.map { it.arg }) - assertEquals(listOf(2, 4, 6), recorder.log.map { it.result.getOrThrow() }) - } - } -} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/FailureCollector.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/FailureCollector.kt deleted file mode 100644 index 108151752..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/FailureCollector.kt +++ /dev/null @@ -1,735 +0,0 @@ -package graphql.schema.property - -import java.io.PrintWriter -import java.io.StringWriter -import java.lang.reflect.InvocationTargetException -import java.text.MessageFormat -import kotlin.reflect.KClass - -class FailureCollector : Iterable { - private val context = ArrayList() - private val failures = ArrayList() - - fun toMultilineString(dst: StringBuilder) { - for (f: Failure in failures) { - f.toMultilineString(dst) - } - } - - val isEmpty: Boolean - get() = failures.isEmpty() - - fun assertEmpty(separator: String = "\n") { - if (!isEmpty) { - throw AssertionError(this.joinToString(separator)) - } - } - - fun assertEmptyMultiline(message: String) { - if (isEmpty) return - val result = StringBuilder() - result.append(message) - toMultilineString(result) - throw AssertionError(result.toString()) - } - - fun toListOfErrors(): List = - failures.map { f -> - val stringBuilder = StringBuilder() - f.toMultilineString(stringBuilder) - stringBuilder.toString() - } - - val size: Int - get() = failures.size - - override fun iterator(): Iterator = failures.iterator() - - fun pushContext(moreContext: String) { - context.add(moreContext) - } - - fun popContext() { - if (context.isEmpty()) throw IllegalStateException("No context to pop.") - context.removeAt(context.size - 1) - } - - fun withContext( - moreContext: String, - body: Runnable - ) { - pushContext(moreContext) - body.run() - popContext() - } - - fun addFailure( - details: String?, - messageFormat: String, - messageArgs: Array - ) { - val msg = MessageFormat.format(messageFormat, *messageArgs) - failures.add(Failure(context.joinToString("."), msg, details)) - } - - @JvmOverloads - fun addExceptionFailure( - e: Throwable?, - messageFormat: String, - messageArgs: Array = EMPTY_ARGS - ) { - val sw = StringWriter() - val pw = PrintWriter(sw) - e?.printStackTrace(pw) - addFailure(sw.toString(), messageFormat, messageArgs) - } - - // The actual checkers - fun isTrue( - actual: Boolean, - messageFormat: String - ): Boolean = isTrue(actual, messageFormat, EMPTY_ARGS) - - fun isTrue( - actual: Boolean, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (actual) return true - addFailure(null, messageFormat, messageArgs) - return false - } - - fun isFalse( - actual: Boolean, - messageFormat: String - ): Boolean = isFalse(actual, messageFormat, EMPTY_ARGS) - - fun isFalse( - actual: Boolean, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (!actual) return true - addFailure(null, messageFormat, messageArgs) - return false - } - - fun isNull( - actual: Any?, - messageFormat: String - ): Boolean = isNull(actual, messageFormat, EMPTY_ARGS) - - fun isNull( - actual: Any?, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (actual == null) return true - val details = "$actual was not null" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun isNotNull( - actual: Any?, - messageFormat: String - ): Boolean = isNotNull(actual, messageFormat, EMPTY_ARGS) - - fun isNotNull( - actual: Any?, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (actual != null) return true - val details = "Actual was null" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun isSameInstanceAs( - expected: Any, - actual: Any, - messageFormat: String - ): Boolean = isSameInstanceAs(expected, actual, messageFormat, EMPTY_ARGS) - - fun isSameInstanceAs( - expected: Any, - actual: Any, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (expected === actual) return true - val details = "$expected (expected) is not same instance as $actual (actual)" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun isNotSameInstanceAs( - expected: Any?, - actual: Any?, - messageFormat: String - ): Boolean = isNotSameInstanceAs(expected, actual, messageFormat, EMPTY_ARGS) - - fun isNotSameInstanceAs( - expected: Any?, - actual: Any?, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (expected !== actual) return true - val details = "Expected and actual are same instance ($actual)" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun isEqualTo( - expected: Int, - actual: Int, - messageFormat: String - ): Boolean = isEqualTo(expected, actual, messageFormat, EMPTY_ARGS) - - fun isEqualTo( - expected: Int, - actual: Int, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (expected == actual) return true - val details = "$expected (expected) is not equal to $actual (actual)" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun isEqualTo( - expected: Any?, - actual: Any?, - messageFormat: String - ): Boolean = isEqualTo(expected, actual, messageFormat, EMPTY_ARGS) - - private fun eq( - expected: Any?, - actual: Any? - ): Boolean = if (expected != null && (expected == actual || expected == actual)) true else actual == null && expected == null - - fun isEqualTo( - expected: Any?, - actual: Any?, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (eq(expected, actual)) return true - - val details = if (actual == null) { - "actual was null and expected was $expected" - } else if (expected == null) { - "expected was null and actual was $actual" - } else { - "$expected (expected) is not equal to $actual (actual)" - } - addFailure(details, messageFormat, messageArgs) - - return false - } - - fun isNotEqualTo( - expected: Any?, - actual: Any?, - messageFormat: String - ): Boolean = isNotEqualTo(expected, actual, messageFormat, EMPTY_ARGS) - - fun isNotEqualTo( - expected: Any?, - actual: Any?, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (!eq(expected, actual)) return true - val details = if (actual == null && expected == null) { - "actual and expected were both null" - } else { - "Expected and actual are the same ($actual)" - } - addFailure(details, messageFormat, messageArgs) - return false - } - - fun isEmpty( - actual: Iterable<*>, - messageFormat: String - ): Boolean = isEmpty(actual, messageFormat, EMPTY_ARGS) - - fun isEmpty( - actual: Iterable<*>, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (!actual.iterator().hasNext()) return true - val details = "$actual is not empty" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun isNotEmpty( - actual: Iterable<*>, - messageFormat: String - ): Boolean = isNotEmpty(actual, messageFormat, EMPTY_ARGS) - - fun isNotEmpty( - actual: Iterable<*>, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (actual.iterator().hasNext()) return true - val details = "$actual is empty" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun isEmpty( - actual: CharSequence, - messageFormat: String - ): Boolean = isEmpty(actual, messageFormat, EMPTY_ARGS) - - fun isEmpty( - actual: CharSequence, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (actual.isEmpty()) return true - val details = "[$actual] is not empty" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun isNotEmpty( - actual: CharSequence, - messageFormat: String - ): Boolean = isNotEmpty(actual, messageFormat, EMPTY_ARGS) - - fun isNotEmpty( - actual: CharSequence, - messageFormat: String, - messageArgs: Array - ): Boolean { - if (actual.isNotEmpty()) return true - val details = "[$actual] is empty" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun contains( - expected: T, - actual: Iterable, - messageFormat: String - ): Boolean = contains(expected, actual, messageFormat, EMPTY_ARGS) - - fun contains( - expected: T, - actual: Iterable, - messageFormat: String, - messageArgs: Array - ): Boolean { - var result = false - for (v: T in actual) { - if (eq(expected, v)) { - result = true - break - } - } - if (result) return true - val details = "$actual does not contains $expected" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun containedBy( - expected: Iterable, - actual: T, - messageFormat: String - ): Boolean = containedBy(expected, actual, messageFormat, EMPTY_ARGS) - - fun containedBy( - expected: Iterable, - actual: T, - messageFormat: String, - messageArgs: Array - ): Boolean { - var result = false - for (v: T in expected) { - if (eq(v, actual)) { - result = true - break - } - } - if (result) return true - val details = "$actual is not contained by $expected" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun ?> isInOrder( - actual: Iterable, - messageFormat: String - ): Boolean = isInOrder(actual, messageFormat, EMPTY_ARGS) - - fun ?> isInOrder( - actual: Iterable, - messageFormat: String, - messageArgs: Array - ): Boolean { - val iter = actual.iterator() - if (!iter.hasNext()) return true - var right = iter.next() - if (!iter.hasNext()) return true - var left: T? = null - var inOrder = true - while (iter.hasNext()) { - left = right - right = iter.next() - if (left!! > right) { - inOrder = false - break - } - } - if (inOrder) return true - val details = "$left is not less than $right" - addFailure(details, messageFormat, messageArgs) - return false - } - - fun containsNoDuplicates( - actual: Iterable, - messageFormat: String - ): Boolean = containsNoDuplicates(actual, messageFormat, EMPTY_ARGS) - - fun containsNoDuplicates( - actual: Iterable, - messageFormat: String, - messageArgs: Array - ): Boolean { - var dups: ArrayList? = null - val found = HashSet() - var nodups = true - for (e: T in actual) { - if (!found.add(e)) { - if (dups == null) dups = ArrayList() - dups.add(e) - nodups = false - } - } - if (nodups) return true - val details = StringBuilder("Duplicate elements: ") - joinToString(details, dups!!.iterator()) - addFailure(details.toString(), messageFormat, messageArgs) - return false - } - - fun containsAtLeastElementsIn( - expected: Iterable, - actual: Iterable, - messageFormat: String - ): Boolean = containsAtLeastElementsIn(expected, actual, messageFormat, EMPTY_ARGS) - - fun containsAtLeastElementsIn( - expected: Iterable, - actual: Iterable, - messageFormat: String, - messageArgs: Array - ): Boolean { - val missing = ArrayList() - for (t: T in expected) missing.add(t) - val actualValues = actual.iterator() - while (actualValues.hasNext() && !missing.isEmpty()) { - val actualVal = actualValues.next() - missing.remove(actualVal) - } - if (missing.isEmpty()) return true - val details = StringBuilder("Missing elements: ") - joinToString(details, missing.iterator()) - addFailure(details.toString(), messageFormat, messageArgs) - return false - } - - fun containsAtMostElementsIn( - expected: Iterable, - actual: Iterable, - messageFormat: String - ): Boolean = containsAtMostElementsIn(expected, actual, messageFormat, EMPTY_ARGS) - - fun containsAtMostElementsIn( - expected: Iterable, - actual: Iterable, - messageFormat: String, - messageArgs: Array - ): Boolean { - val extra = ArrayList() - for (t: T in actual) extra.add(t) - val expectedValues = expected.iterator() - while (expectedValues.hasNext() && !extra.isEmpty()) { - val expectedVal = expectedValues.next() - extra.remove(expectedVal) - } - if (extra.isEmpty()) return true - val details = StringBuilder("Extra elements: ") - joinToString(details, extra.iterator()) - addFailure(details.toString(), messageFormat, messageArgs) - return false - } - - fun containsExactlyElementsIn( - expected: Iterable, - actual: Iterable, - messageFormat: String - ): Boolean = containsExactlyElementsIn(expected, actual, messageFormat, EMPTY_ARGS) - - fun containsExactlyElementsIn( - expected: Iterable, - actual: Iterable, - messageFormat: String, - messageArgs: Array - ): Boolean { - val expectedValues = expected.iterator() - val actualValues = actual.iterator() - - // Deal with startup corner-cases - if (!expectedValues.hasNext() && !actualValues.hasNext()) return true - var expectedVal: T? = null - var actualVal: T? = null - var differenceFound = false - while (!differenceFound && actualValues.hasNext() && expectedValues.hasNext()) { - expectedVal = expectedValues.next() - actualVal = actualValues.next() - if (!eq(expectedVal, actualVal)) differenceFound = true - } - if (!differenceFound && !actualValues.hasNext() && !expectedValues.hasNext()) { - return true - } - if (!differenceFound && !expectedValues.hasNext()) { - // Error - have extras (only) to report - val extra = StringBuilder("Extra elements: ") - joinToString(extra, actualValues) - addFailure(extra.toString(), messageFormat, messageArgs) - return false - } - val missing = ArrayList() - missing.add(expectedVal) - while (expectedValues.hasNext()) missing.add(expectedValues.next()) - val extra = ArrayList() - if (!missing.remove(actualVal)) extra.add(actualVal) - while (actualValues.hasNext()) { - actualVal = actualValues.next() - if (!missing.remove(actualVal)) extra.add(actualVal) - } - if (extra.isEmpty() && missing.isEmpty()) return true - val details = StringBuilder("Missing elements: ") - joinToString(details, missing.iterator()).append(". Extra elements: ") - joinToString(details, extra.iterator()) - addFailure(details.toString(), messageFormat, messageArgs) - return false - } - - inline fun doesThrow( - message: String, - body: () -> Unit - ): Boolean = doesThrow(message, EMPTY_ARGS, body) - - inline fun doesThrow( - messageFormat: String, - messageArgs: Array, - body: () -> Unit - ): Boolean { - val expected = T::class.java - @Suppress("Detekt.TooGenericExceptionCaught") - try { - body() - addFailure( - "Expected exception of type ${expected.name} but no exception was thrown.", - messageFormat, - messageArgs - ) - return false - } catch (t: Throwable) { - var underlying = t - // InvocationTargetException is thrown by Method.invoke when the method invoked - // throws an exception - we unwrap the exception thrown because that's the one - // we're likely wanting to test against - if (t is InvocationTargetException) { - underlying = t.cause!! - } - if (expected.isInstance(underlying)) return true - addFailure( - "Expected exception of type " + - expected.name + - " but got " + - t.javaClass.name + - ".", - messageFormat, - messageArgs - ) - return false - } - } - - interface ContingentResult { - fun ifNoThrow(body: (T) -> Unit) - - companion object { - fun of(result: T): ContingentResult = - object : ContingentResult { - override fun ifNoThrow(body: (T) -> Unit) = body(result) - } - - fun empty(): ContingentResult = - object : ContingentResult { - override fun ifNoThrow(body: (T) -> Unit) {} - } - } - } - - fun doesNotThrow( - message: String, - body: () -> T - ): ContingentResult = doesNotThrow(message, EMPTY_ARGS, body) - - fun doesNotThrow( - messageFormat: String, - messageArgs: Array, - body: () -> T - ): ContingentResult = - @Suppress("Detekt.TooGenericExceptionCaught") - try { - ContingentResult.of(body()) - } catch (e: Throwable) { - addExceptionFailure(e, messageFormat, messageArgs) - ContingentResult.empty() - } - - inline fun isInstanceOf( - actualObject: Any?, - message: String = "Expecting a ${T::class.simpleName}, found a ${actualObject?.let { it::class.simpleName } ?: "null"}", - block: (T) -> Unit - ) { - if (isInstanceOf(T::class, actualObject, message)) block(actualObject as T) - } - - inline fun isInstanceOf( - actualObject: Any?, - message: String - ): Boolean = isInstanceOf(T::class, actualObject, message, EMPTY_ARGS) - - fun isInstanceOf( - expectedClass: KClass<*>, - actualObject: Any?, - message: String - ): Boolean = isInstanceOf(expectedClass, actualObject, message, EMPTY_ARGS) - - /** - * Checks that an object is an instance of a provided class. When `expectedClass` is a class - * representing a primitive type (e.g., `int.class`), checks to ensure `actualObject` isn't null, - * and if not ensures that it's an instance of the corresponding boxed type for that primitive - * (e.g., `Integer` for ints). - */ - fun isInstanceOf( - expectedClass: KClass<*>, - actualObject: Any?, - messageFormat: String, - messageArgs: Array - ): Boolean { - /** - * Interactions of KClass, Class, and autoboxing for primitives can be surprising. - * Here are some important things to know: - * - * - Kotlin KClasses for primitive types are the same as their boxed java.lang - * counterparts - * kotlin.Boolean::class == java.lang.Boolean::class - * - * - However, their java Classes are not the same - * kotlin.Boolean::class.java != java.lang.Boolean::class.java - * - * - Kotlin uses unboxed values for non-nullable kotlin primitives, but will autobox - * when that value is represented as nullable - * val b: Boolean = true // b::class.java is primitive 'boolean' - * val b2: Boolean? = true // b2!!::class.java is java.lang.Boolean - * - * - Similar to the autoboxing above, boxed values will be automatically unboxed - * when represented as non-nullable - * val b2: Boolean? = true // b2!!::class.java is java.lang.Boolean - * b2?.let { it } // it::class.java is primitive 'boolean' - * - * - Because this method accepts values as a nullable Any?, primitive values will be - * automatically boxed, regardless of the value that this function was actually called with - * val b: Boolean = true // b::class.java is primitive 'boolean' - * isInstanceOf(actualObject = b) // actualObject!!::class.java is java.lang.Boolean - */ - if (actualObject == null && expectedClass.java.isPrimitive) { - addFailure( - "Null passed for primitive type ${expectedClass.simpleName}", - messageFormat, - messageArgs - ) - return false - } - if (actualObject != null && !expectedClass.isInstance(actualObject)) { - val msg = - "Expected instance of ${expectedClass.qualifiedName} but got " + - actualObject::class.qualifiedName - addFailure(msg, messageFormat, messageArgs) - return false - } - return true - } - - companion object { - private fun joinToString( - dst: StringBuilder, - list: Iterator - ): StringBuilder { - while (list.hasNext()) { - dst.append(list.next()) - if (list.hasNext()) dst.append(", ") - } - return dst - } - - val EMPTY_ARGS: Array = emptyArray() - } -} - -class Failure internal constructor( - val context: String, - val message: String, - val details: String? -) { - val label: String - get() = message.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[0] - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Failure) return false - val o = other - if (context != o.context) return false - if (message != o.message) return false - if (details == null && o.details != null) return false - return if (details != null && o.details == null) false else details == null || (details == o.details) - } - - override fun hashCode(): Int = 31 * 31 * context.hashCode() + 31 * message.hashCode() + (details?.hashCode() ?: 4507321) - - override fun toString(): String { - val ctx = if (!context.isEmpty()) "$context: " else "" - val deets = if (details == null) "" else " ($details)" - return "$ctx$message$deets" - } - - fun toMultilineString(dst: StringBuilder) { - dst.append(message) - if (!context.isEmpty()) dst.append("\n Context: $context") - if (details != null) { - dst - .append("\n ") - .append(details.replace(". Extra", "\nExtra").replace("\n", "\n ")) - } - dst.append("\n") - } -} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratedSchema.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratedSchema.kt deleted file mode 100644 index 4283531b1..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratedSchema.kt +++ /dev/null @@ -1,22 +0,0 @@ -package graphql.schema.property - -import graphql.schema.GraphQLSchema -import graphql.schema.idl.SchemaPrinter -import io.kotest.property.Arb -import io.kotest.property.arbitrary.map - -/** A generated schema paired with its printable SDL representation. */ -internal data class GeneratedSchema( - val schema: GraphQLSchema, - val sdl: String -) { - override fun toString(): String = sdl -} - -/** Generate graphql-java schemas together with the SDL used in property failure output. */ -internal fun Arb.Companion.generatedGraphQLSchema( - config: Config = Config.default -): Arb = - graphQLSchema(config).map { schema -> - GeneratedSchema(schema, SchemaPrinter().print(schema)) - } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExternalInputValueGeneratorTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExternalInputValueGeneratorTest.kt deleted file mode 100644 index f7b914fc4..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExternalInputValueGeneratorTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package graphql.schema.property - -import graphql.schema.GraphQLInputObjectType -import graphql.schema.GraphQLInputType -import graphql.schema.GraphQLList -import graphql.schema.GraphQLNonNull -import graphql.schema.GraphQLScalarType -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.matchers.shouldBe -import io.kotest.property.Arb -import io.kotest.property.RandomSource -import io.kotest.property.arbitrary.constant -import org.junit.jupiter.api.Test - -class GraphQLExternalInputValueGeneratorTest { - @Test - fun `nullable and non-null inputs honor ExplicitNullValueWeight`() { - val schema = parseTestSchema("type Query { x: Int }") - val scalar = schema.getType("Int") as GraphQLScalarType - val config = Config.default + (ExplicitNullValueWeight to 1.0) - val nullable = GraphQLExternalInputValueGenerator(schema, config, RandomSource.seeded(1)) - nullable.generate(scalar).shouldBe(null) - - val nonNull = GraphQLExternalInputValueGenerator(schema, config, RandomSource.seeded(1)) - (nonNull.generate(GraphQLNonNull.nonNull(scalar)) is Int).shouldBe(true) - } - - @Test - fun `lists honor configured size and item nullability`() { - val schema = parseTestSchema("type Query { x: Int }") - val scalar = schema.getType("Int") as GraphQLInputType - val type = GraphQLNonNull.nonNull(GraphQLList.list(GraphQLNonNull.nonNull(scalar))) - val config = Config.default + - (ExplicitNullValueWeight to 1.0) + - (ListValueSize to 3..3) - val value = GraphQLExternalInputValueGenerator(schema, config, RandomSource.seeded(2)) - .generate(type) as List<*> - value.size.shouldBe(3) - value.all { it is Int }.shouldBe(true) - } - - @Test - fun `oneOf values contain exactly one non-null member`() { - val schema = parseTestSchema( - "input Choice @oneOf { text: String, number: Int } type Query { x(choice: Choice): Int }" - ) - val type = schema.getType("Choice") as GraphQLInputObjectType - val config = Config.default + (ExplicitNullValueWeight to 1.0) - val value = GraphQLExternalInputValueGenerator(schema, config, RandomSource.seeded(3)) - .generate(GraphQLNonNull.nonNull(type)) as Map<*, *> - value.size.shouldBe(1) - (value.values.single() != null).shouldBe(true) - } - - @Test - fun `recursive input values stop at MaxValueDepth`() { - val schema = parseTestSchema( - "input Recursive { next: Recursive } type Query { x(value: Recursive): Int }" - ) - val type = schema.getType("Recursive") as GraphQLInputObjectType - val config = Config.default + - (ExplicitNullValueWeight to 0.0) + - (ImplicitNullValueWeight to 0.0) + - (MaxValueDepth to 2) - val value = GraphQLExternalInputValueGenerator(schema, config, RandomSource.seeded(4)) - .generate(GraphQLNonNull.nonNull(type)) as Map<*, *> - val nested = value["next"] as Map<*, *> - nested.shouldBe(mapOf("next" to null)) - } - - @Test - fun `custom scalars require and use ScalarValueOverrides`() { - val schema = parseTestSchema("scalar Custom type Query { x(value: Custom): Int }") - val scalar = schema.getType("Custom") as GraphQLScalarType - shouldThrow { - GraphQLExternalInputValueGenerator(schema, Config.default, RandomSource.seeded(5)) - .generate(scalar) - } - - val config = Config.default + - (ExplicitNullValueWeight to 0.0) + - (ScalarValueOverrides to mapOf("Custom" to Arb.constant("custom-value"))) - GraphQLExternalInputValueGenerator(schema, config, RandomSource.seeded(5)) - .generate(scalar) - .shouldBe("custom-value") - } -} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTestDataTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTestDataTest.kt deleted file mode 100644 index 83e7964fc..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTestDataTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) - -package graphql.schema.property - -import graphql.ParseAndValidate -import graphql.schema.idl.SchemaParser -import io.kotest.matchers.collections.shouldContain -import io.kotest.matchers.shouldBe -import io.kotest.property.Arb -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Test - -class GraphQLTestDataTest : KotestPropertyBase() { - @Test - fun `graphQLTestData generates round-trippable schemas and valid queries`(): Unit = - runBlocking { - val config = Config.default + (SchemaSize to 10) - Arb.graphQLTestData(config).checkAll(30) { data -> - SchemaParser().parse(data.sdl) - val schema = parseTestSchema(data.sdl) - val input = graphql.ExecutionInput.newExecutionInput() - .query(data.query) - .variables(data.variables) - .build() - ParseAndValidate.parseAndValidate(schema, input).isFailure.shouldBe(false) - } - } - - @Test - fun `minimum query length filters generated test data`(): Unit = - runBlocking { - val config = Config.default + - (SchemaSize to 5) + - (MinQueryLength to 10) - Arb.graphQLTestData(config).checkAll(10) { data -> - (data.query.length >= 10).shouldBe(true) - } - } - - @Test - fun `GraphQLTestDataStats counts schema and query constructs`() { - val data = GraphQLTestData( - sdl = "type Obj { value: Int! } type Query { obj: Obj, values: [Int] }", - query = "query Q(\$include: Boolean!) { alias: obj @include(if: \$include) { ...F } } fragment F on Obj { value }", - variables = mapOf("include" to true) - ) - val stats = GraphQLTestDataStats(data) - stats.schemaStats["object definitions"].shouldBe(8) - stats.schemaStats["list-typed object fields"].shouldBe(11) - stats.queryStats["aliased selections"].shouldBe(1) - stats.queryStats["fragment definitions"].shouldBe(1) - stats.queryStats["external variables"].shouldBe(1) - stats.format().lines().shouldContain(" external variables: 1") - } -} diff --git a/settings.gradle b/settings.gradle index 2453d7c29..3302019aa 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,4 +19,4 @@ plugins { rootProject.name = 'graphql-java' include 'performance-results-page' -include 'graphql-java-property-testing' +include 'graphql-java-arbitrary'