From 8e57399be7ac520b896628e811c5e3426fa38a77 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Sat, 1 Aug 2026 09:32:09 +1000 Subject: [PATCH 1/5] Add Kotest schema property testing Port Airbnb Viaduct's arbitrary GraphQL schema support to graphql-java native schema and AST types. Cover generated schema validity, SDL round trips, defaults, input cycles, oneOf inputs, coercion, and applied directives with Kotest properties. --- .github/workflows/master.yml | 6 +- .github/workflows/pull_request.yml | 6 +- build.gradle | 24 +- graphql-java-property-testing/README.md | 33 + graphql-java-property-testing/build.gradle | 150 ++++ .../kotlin/graphql/schema/property/Config.kt | 155 ++++ .../graphql/schema/property/CycleGroups.kt | 126 ++++ .../schema/property/DocumentBuilder.kt | 598 +++++++++++++++ .../graphql/schema/property/GeneratorUtils.kt | 123 ++++ .../schema/property/GraphQLAstUtils.kt | 42 ++ .../schema/property/GraphQLDocuments.kt | 681 ++++++++++++++++++ .../schema/property/GraphQLExecutionInputs.kt | 79 ++ .../GraphQLExternalInputValueGenerator.kt | 160 ++++ .../schema/property/GraphQLGeneratorConfig.kt | 305 ++++++++ .../property/GraphQLInputValueGenerator.kt | 198 +++++ .../graphql/schema/property/GraphQLNames.kt | 167 +++++ .../schema/property/GraphQLOutputValues.kt | 244 +++++++ .../schema/property/GraphQLRuntimeWirings.kt | 169 +++++ .../graphql/schema/property/GraphQLSchemas.kt | 610 ++++++++++++++++ .../graphql/schema/property/GraphQLStrings.kt | 90 +++ .../schema/property/GraphQLTestData.kt | 203 ++++++ .../graphql/schema/property/GraphQLTypes.kt | 642 +++++++++++++++++ .../schema/property/SchemaRelationships.kt | 66 ++ .../graphql/schema/property/TypeType.kt | 12 + .../graphql/schema/property/ConfigTest.kt | 84 +++ .../schema/property/CycleGroupsTest.kt | 69 ++ .../schema/property/DocumentBuilderTest.kt | 212 ++++++ .../schema/property/GeneratedSchema.kt | 22 + .../schema/property/GeneratorUtilsTest.kt | 76 ++ .../schema/property/GraphQLComparators.kt | 13 + .../schema/property/GraphQLDocumentGenTest.kt | 416 +++++++++++ .../property/GraphQLExecutionInputsTest.kt | 73 ++ .../GraphQLExternalInputValueGeneratorTest.kt | 82 +++ .../property/GraphQLGeneratorPropertyTest.kt | 125 ++++ .../schema/property/GraphQLNamesTest.kt | 76 ++ .../property/GraphQLOutputValuesTest.kt | 93 +++ .../property/GraphQLRuntimeWiringsTest.kt | 103 +++ .../GraphQLSchemaDirectiveCycleTest.kt | 143 ++++ .../property/GraphQLSchemaPropertyTest.kt | 289 ++++++++ .../schema/property/GraphQLStringsTest.kt | 66 ++ .../schema/property/GraphQLTestDataTest.kt | 51 ++ .../schema/property/GraphQLTypesTest.kt | 320 ++++++++ .../schema/property/PropertyTestFixtures.kt | 15 + .../schema/property/PropertyTestUtils.kt | 128 ++++ .../schema/property/PropertyTestUtilsTest.kt | 70 ++ .../property/SchemaRelationshipsTest.kt | 49 ++ settings.gradle | 1 + 47 files changed, 7435 insertions(+), 30 deletions(-) create mode 100644 graphql-java-property-testing/README.md create mode 100644 graphql-java-property-testing/build.gradle create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/Config.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/DocumentBuilder.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLDocuments.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExecutionInputs.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExternalInputValueGenerator.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLGeneratorConfig.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLInputValueGenerator.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLNames.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLOutputValues.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLSchemas.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLStrings.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTestData.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTypes.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/SchemaRelationships.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/TypeType.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratedSchema.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratorUtilsTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLComparators.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputsTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExternalInputValueGeneratorTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLGeneratorPropertyTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaDirectiveCycleTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaPropertyTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTestDataTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtils.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtilsTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 466bd9a9a..f6ba0c0fc 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -57,9 +57,7 @@ jobs: run: | total=0; failures=0; errors=0; skipped=0 for dir_name in ${{ matrix.test-results-dirs }}; do - dir="build/test-results/$dir_name" - for f in "$dir"/TEST-*.xml; do - [ -f "$f" ] || continue + while IFS= read -r -d '' f; do t=$(grep -o 'tests="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') fl=$(grep -o 'failures="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') e=$(grep -o 'errors="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') @@ -68,7 +66,7 @@ jobs: failures=$((failures + ${fl:-0})) errors=$((errors + ${e:-0})) skipped=$((skipped + ${s:-0})) - done + done < <(find . -type f -path "*/build/test-results/$dir_name/TEST-*.xml" -print0) done passed=$((total - failures - errors - skipped)) mkdir -p /tmp/test-stats diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 6e5162820..9ce0b2ea6 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -72,9 +72,7 @@ jobs: run: | total=0; failures=0; errors=0; skipped=0 for dir_name in ${{ matrix.test-results-dirs }}; do - dir="build/test-results/$dir_name" - for f in "$dir"/TEST-*.xml; do - [ -f "$f" ] || continue + while IFS= read -r -d '' f; do t=$(grep -o 'tests="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') fl=$(grep -o 'failures="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') e=$(grep -o 'errors="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') @@ -83,7 +81,7 @@ jobs: failures=$((failures + ${fl:-0})) errors=$((errors + ${e:-0})) skipped=$((skipped + ${s:-0})) - done + done < <(find . -type f -path "*/build/test-results/$dir_name/TEST-*.xml" -print0) done passed=$((total - failures - errors - skipped)) mkdir -p /tmp/test-stats diff --git a/build.gradle b/build.gradle index aaa3d9ce3..973d2b1e2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,5 @@ import aQute.bnd.gradle.BundleTaskExtension import net.ltgt.gradle.errorprone.CheckSeverity -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import java.text.SimpleDateFormat @@ -27,9 +25,8 @@ plugins { id "io.github.reyerizo.gradle.jcstress" version "0.9.0" id "net.ltgt.errorprone" version '5.1.0' id 'jacoco' - // - // Kotlin just for tests - not production code - id 'org.jetbrains.kotlin.jvm' version '2.4.0' + id 'org.jetbrains.kotlin.jvm' version '2.4.0' apply false + id 'org.jetbrains.dokka-javadoc' version '2.2.0' apply false } java { @@ -38,19 +35,6 @@ java { } } -kotlin { - compilerOptions { - apiVersion = KotlinVersion.KOTLIN_2_0 - languageVersion = KotlinVersion.KOTLIN_2_0 - jvmTarget = JvmTarget.JVM_11 - javaParameters = true - freeCompilerArgs = [ - '-Xemit-jvm-type-annotations', - '-Xjspecify-annotations=strict', - ] - } -} - def makeDevelopmentVersion(parts) { def version = String.join("-", parts) println "created development version: $version" @@ -174,8 +158,6 @@ dependencies { errorprone 'com.uber.nullaway:nullaway:0.13.8' errorprone 'com.google.errorprone:error_prone_core:2.50.0' - // just tests - no Kotlin otherwise - testImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' } shadowJar { @@ -801,5 +783,3 @@ tasks.withType(PublishToMavenRepository) { tasks.withType(GenerateModuleMetadata) { enabled = false } - - diff --git a/graphql-java-property-testing/README.md b/graphql-java-property-testing/README.md new file mode 100644 index 000000000..44cb1d54b --- /dev/null +++ b/graphql-java-property-testing/README.md @@ -0,0 +1,33 @@ +# 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`. + +[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/build.gradle b/graphql-java-property-testing/build.gradle new file mode 100644 index 000000000..f7efc2af5 --- /dev/null +++ b/graphql-java-property-testing/build.gradle @@ -0,0 +1,150 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.dokka-javadoc' + id 'maven-publish' + id 'signing' +} + +def kotlinVersion = '2.4.0' +def kotestVersion = '6.2.3' + +group = rootProject.group +version = rootProject.version + +repositories { + mavenCentral() +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + withSourcesJar() +} + +kotlin { + compilerOptions { + apiVersion = KotlinVersion.KOTLIN_2_0 + languageVersion = KotlinVersion.KOTLIN_2_0 + jvmTarget = JvmTarget.JVM_11 + javaParameters = true + freeCompilerArgs = [ + '-Xemit-jvm-type-annotations', + '-Xjspecify-annotations=strict', + ] + } +} + +dependencies { + api project(':') + api "org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}" + api "io.kotest:kotest-property:${kotestVersion}" + + testImplementation "io.kotest:kotest-runner-junit5:${kotestVersion}" + testImplementation "io.kotest:kotest-assertions-core:${kotestVersion}" + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.14.4' +} + +jar { + from rootProject.file('LICENSE.md') + from(rootProject.file('additionallicenses/APACHE-LICENSE-2.0.txt')) { + into 'additionallicenses' + } + from 'README.md' + manifest { + attributes('Automatic-Module-Name': 'com.graphqljava.propertytesting') + } +} + +tasks.named('sourcesJar') { + from rootProject.file('LICENSE.md') + from(rootProject.file('additionallicenses/APACHE-LICENSE-2.0.txt')) { + into 'additionallicenses' + } + from 'README.md' +} + +def dokkaJavadocJar = tasks.register('dokkaJavadocJar', Jar) { + dependsOn tasks.named('dokkaGeneratePublicationJavadoc') + from(tasks.named('dokkaGeneratePublicationJavadoc').flatMap { it.outputDirectory }) + archiveClassifier = 'javadoc' +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() + maxHeapSize = '1g' + testLogging { + events 'FAILED', 'SKIPPED' + exceptionFormat = 'FULL' + } +} + +[11, 17, 21].each { javaVersion -> + tasks.register("testWithJava${javaVersion}", Test) { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(javaVersion) + } + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + dependsOn tasks.named('testClasses') + } +} + +publishing { + publications { + propertyTesting(MavenPublication) { + artifactId = 'graphql-java-property-testing' + from components.java + artifact dokkaJavadocJar + pom { + name = 'graphql-java property testing' + description = 'Kotest property generators for graphql-java schemas, documents, and values' + url = 'https://github.com/graphql-java/graphql-java' + scm { + url = 'https://github.com/graphql-java/graphql-java' + connection = 'https://github.com/graphql-java/graphql-java' + developerConnection = 'https://github.com/graphql-java/graphql-java' + } + licenses { + license { + name = 'MIT' + url = 'https://github.com/graphql-java/graphql-java/blob/master/LICENSE.md' + distribution = 'repo' + } + license { + name = 'Apache License 2.0' + url = 'https://www.apache.org/licenses/LICENSE-2.0.txt' + distribution = 'repo' + } + } + developers { + developer { + id = 'andimarek' + name = 'Andreas Marek' + } + } + } + } + } +} + +signing { + setRequired { !rootProject.hasProperty('publishToMavenLocal') } + def signingKey = System.env.MAVEN_CENTRAL_PGP_KEY + useInMemoryPgpKeys(signingKey, '') + sign publishing.publications +} + +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/Config.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/Config.kt new file mode 100644 index 000000000..a362ff048 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/Config.kt @@ -0,0 +1,155 @@ +package graphql.schema.property + +/** + * A container for configuration. + * `Config.default` will return default values for any provided [ConfigKey] + * + * Example: + * ``` + * // write a config with a value + * val key: ConfigKey = ... + * val cfg = Config.default + (key to 10) + * + * // read a config value + * val value = cfg[key] + * ``` + */ +class Config private constructor( + private val map: Map, Any?> +) { + @Suppress("UNCHECKED_CAST") + operator fun get(key: ConfigKey): T = map[key] as? T ?: key.default + + /** + * Return a new Config with the provided key-value pair. + * + * Values that are invalid for the given ConfigKey will be + * raised as an IllegalArgumentException + */ + operator fun plus(pair: Pair, T>): Config = + validateOrThrow(pair.first.validate, pair.second).let { + Config(map + pair) + } + + /** 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 + ) { + validator(t)?.let { msg -> + throw InvalidConfigValue(msg, t) + } + } + } +} + +class InvalidConfigValue( + msg: String, + val value: Any? +) : Exception(msg) + +/** Validator returns a descriptive error string for invalid inputs */ +typealias Validator = (t: T) -> String? + +/** + * Validates that Double values are between 0.0 and 1.0, inclusive + */ +object WeightValidator : Validator { + override fun invoke(x: Double): String? = + if (x !in 0.0..1.0) { + "Value must be between 0.0 and 1.0" + } else { + null + } +} + +/** + * Describes a weight that may be sampled repeatedly, up to `max` times + * + * For example, if field argument size were described using `CompoundingWeight(.1, 3)`, + * then we would expect approximately + * - 10% of fields would define at least 1 argument, + * - 1% of fields would define at least 2 arguments, + * - .1% of fields would define 3 arguments, + * - no fields would define 4 or more arguments + */ +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) + + /** A CompoundingWeight that will be true the first time it is sampled and false every other time */ + val Once: CompoundingWeight = CompoundingWeight(1.0, 1) + + /** A CompoundingWeight that will always return true */ + val Always: CompoundingWeight = CompoundingWeight(1.0, Int.MAX_VALUE) + } +} + +/** + * Validates that a [CompoundingWeight] has a weight between 0.0 and 1.0 + * and a max less than or equal to configured value + */ +object CompoundingWeightValidator : Validator { + override fun invoke(x: CompoundingWeight): String? { + if (!(0..Int.MAX_VALUE).contains(x.max)) { + return "Maximum must be in 0..Int.MAX_VALUE" + } + return WeightValidator.invoke(x.weight) + } +} + +/** + * Validates that Int values are within an IntRange domain + */ +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}" + } else { + null + } +} + +/** + * Validates that all possible values of a provided IntRange values are + * also within a specified IntRange domain + */ +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}" + } else if (x.isEmpty()) { + "Range must not be empty, got $x" + } else { + null + } +} + +/** + * A Validator that accepts all input + */ +object Unvalidated : Validator { + override fun invoke(t: Any): String? = null +} + +/** + * Base class for identifying configurable values. + */ +open class ConfigKey( + val default: T, + val validate: Validator +) diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt new file mode 100644 index 000000000..e15435ab9 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt @@ -0,0 +1,126 @@ +package graphql.schema.property + +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLSchema +import graphql.schema.GraphQLTypeUtil + +/** + * Represents groups of input object types that participate in recursive cycles. + * + * Types within a group are mutually reachable from each other through the edges + * defined by the factory method that created this instance (e.g., [mandatoryInputCycles] or [allInputCycles]). + * Each group represents a Strongly Connected Component (SCC) in the input object dependency graph. + */ +internal class CycleGroups(private val groups: Map>) { + operator fun get(name: String): Set = groups[name].orEmpty() + + fun isEmpty(): Boolean = groups.isEmpty() + + companion object { + /** + * Returns a [CycleGroups] that identifies cycles formed by mandatory input edges. + * + * A mandatory edge represents a structural dependency that must be satisfied + * to produce a valid input value. This includes: + * - Non-nullable fields with no default value in a standard input object. + * - All fields of a `@oneOf` input object (as the `@oneOf` constraint requires + * exactly one field to be selected). + * + * List-typed fields (even non-nullable ones) are not considered mandatory edges + * because an empty list successfully satisfies the requirement without recursing. + * + * These edges define paths that a generator may be "forced" to follow to produce + * a well-formed value. A cycle of mandatory edges is only valid in a GraphQL + * schema if it contains at least one `@oneOf` input object that provides a + * non-cyclic "exit" field (such as a scalar or an optional dependency) to + * terminate the recursion. + */ + fun mandatoryInputCycles(schema: GraphQLSchema): CycleGroups = + build(schema) { inputObject -> + inputObject.fields.mapNotNullTo(linkedSetOf()) { field -> + if (field.hasSetDefaultValue()) return@mapNotNullTo null + val unwrapped = GraphQLTypeUtil.unwrapAll(field.type) + if (unwrapped !is GraphQLInputObjectType) return@mapNotNullTo null + if (inputObject.isOneOf) return@mapNotNullTo unwrapped.name + val type = field.type + if (type is GraphQLNonNull && type.wrappedType !is GraphQLList) { + unwrapped.name + } else { + null + } + } + } + + /** + * Returns a [CycleGroups] that identifies all recursive cycles between + * input objects. + * + * This considers every reference between input objects as an edge, regardless + * of nullability, list wrappers, or default values. + */ + fun allInputCycles(schema: GraphQLSchema): CycleGroups = + build(schema) { inputObject -> + inputObject.fields.mapNotNullTo(linkedSetOf()) { field -> + (GraphQLTypeUtil.unwrapAll(field.type) as? GraphQLInputObjectType)?.name + } + } + + private fun build( + schema: GraphQLSchema, + neighbors: (GraphQLInputObjectType) -> Set + ): CycleGroups { + val inputTypes = schema.allTypesAsList.filterIsInstance() + val adjacency = inputTypes.associate { it.name to neighbors(it) } + return CycleGroups(stronglyConnectedCycles(adjacency)) + } + + private fun stronglyConnectedCycles( + adjacency: Map> + ): Map> { + var nextIndex = 0 + val indices = mutableMapOf() + val lowLinks = mutableMapOf() + val stack = ArrayDeque() + val onStack = mutableSetOf() + val result = mutableMapOf>() + + fun connect(vertex: String) { + indices[vertex] = nextIndex + lowLinks[vertex] = nextIndex + nextIndex++ + stack.addLast(vertex) + onStack += vertex + + adjacency[vertex].orEmpty().forEach { neighbor -> + if (neighbor !in adjacency) return@forEach + if (neighbor !in indices) { + connect(neighbor) + lowLinks[vertex] = minOf(lowLinks.getValue(vertex), lowLinks.getValue(neighbor)) + } else if (neighbor in onStack) { + lowLinks[vertex] = minOf(lowLinks.getValue(vertex), indices.getValue(neighbor)) + } + } + + if (lowLinks[vertex] != indices[vertex]) return + val component = linkedSetOf() + do { + val item = stack.removeLast() + onStack -= item + component += item + } while (item != vertex) + + val selfLoop = component.size == 1 && vertex in adjacency[vertex].orEmpty() + if (component.size > 1 || selfLoop) { + component.forEach { result[it] = component } + } + } + + adjacency.keys.forEach { vertex -> + if (vertex !in indices) connect(vertex) + } + return result + } + } +} diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/DocumentBuilder.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/DocumentBuilder.kt new file mode 100644 index 000000000..dcd343e55 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/DocumentBuilder.kt @@ -0,0 +1,598 @@ +package graphql.schema.property + +import graphql.language.Argument +import graphql.language.AstPrinter +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.OperationDefinition +import graphql.language.SelectionSet +import graphql.language.TypeName +import graphql.language.Value +import graphql.language.VariableDefinition +import graphql.schema.GraphQLCompositeType +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNamedType +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLOutputType +import graphql.schema.GraphQLType +import graphql.schema.GraphQLTypeUtil +import graphql.validation.VariablesTypesMatcher + +private typealias GJSelection = graphql.language.Selection<*> + +internal class Fragments( + private val schemas: Schemas, + private val fragmentsByName: MutableMap = mutableMapOf(), + private val fragmentsByType: MutableMap> = mutableMapOf(), +) : Schemas by schemas { + val fragments: List get() = fragmentsByName.values.toList() + + operator fun get(key: String): FragmentDef? = fragmentsByName[key] + + fun spreadableFragments( + sb: SelectionsBuilder, + type: GraphQLCompositeType + ): List = + fragmentsByType + .flatMap { (k, v) -> + if (!schemas.rels.isSpreadable(type, k)) emptyList() else v + }.filter { fb -> + fb.sb.fieldSelections().all(sb::canAdd) + } + + fun add(fragment: FragmentDef) { + require(get(fragment.name) == null) { + "cannot overwrite fragment with name `${fragment.name}" + } + fragmentsByName += (fragment.name to fragment) + + val fragType = requireNotNull( + schemas.schema.getTypeAs(fragment.typeCondition.name) + ) + fragmentsByType.getOrPut(fragType) { mutableListOf() } += fragment + } + + operator fun plusAssign(fragment: FragmentDef) = add(fragment) +} + +internal class Variables( + private val schemas: Schemas, + private val variablesByName: MutableMap = mutableMapOf(), +) { + private val matcher = VariablesTypesMatcher() + + val variables: List get() = variablesByName.values.toList() + + operator fun get(name: String): VariableDefinition? = variablesByName[name] + + /** Get any variables that can be validly applied at a given location */ + fun variablesForType( + locationType: GraphQLInputType, + locationDefault: Value<*>? + ): List = + variables.filter { v -> + val vtype = v.type.asSchemaType(schemas.schema) + + // Consider this valid schema and document + // schema: type Query { x(a:Int!=0) } + // doc: query ($a:Int) { x(a:$a) } + // This doc uses $a, which may be null, in a position that requires a non-nullable value. + // This is considered valid by the spec (see IsVariableUsageAllowed), though a + // validation error will be raised at runtime if the value of $a is explicitly null. + // + // It would be nice to support this edge case, though doing so is somewhat difficult. + // Let's disallow this kind of variable reuse. + val nullableUsedNonNullably = + GraphQLTypeUtil.isNullable(vtype) && GraphQLTypeUtil.isNonNull(locationType) + + if (nullableUsedNonNullably) { + false + } else { + matcher.doesVariableTypesMatch( + vtype, + v.defaultValue, + locationType, + locationDefault + ) + } + } + + fun add(variable: VariableDefinition) { + val name = requireNotNull(variable.name) + require(!variablesByName.containsKey(name)) { + "cannot overwrite variable with name `$name`" + } + variablesByName[name] = variable + } +} + +/** + * Accumulator for label strings used in @defer and @stream. + * Labels must be unique per document. + */ +internal class IncrementalLabels( + private val set: MutableSet = mutableSetOf() +) { + fun contains(label: String) = label in set + + fun add(label: String) { + set += label + } +} + +internal sealed interface Selection { + /** + * Return the field selections provided by this [Selection]. + * This is non-recursive and should not include sub selections of fields. + */ + fun flatten(): List + + /** the graphql-java representation of this Selection */ + val gjSelection: GJSelection +} + +internal class FieldSelection private constructor( + val key: FieldKey, + override val gjSelection: Field, + /** the merge tree of this Fields subselections */ + val mergeKeyTree: KeyTree? +) : Selection { + override fun flatten(): List = listOf(this) + + companion object { + operator fun invoke( + key: FieldKey, + selections: SelectionsBuilder? = null, + directives: List = emptyList() + ): FieldSelection { + val field = Field + .newField() + .name(key.fieldName) + .alias(key.alias) + .arguments( + key.arguments.map { it.arg }.toList() + ).selectionSet(selections?.build()) + .directives(directives) + .build() + return FieldSelection(key, field, selections?.mergeKeyTree) + } + } +} + +internal data class InlineFragmentSelection( + override val gjSelection: InlineFragment, + private val selections: List, +) : Selection { + constructor( + sdlTypeCondition: String? = null, + selections: List = emptyList(), + directives: List = emptyList() + ) : this( + InlineFragment + .newInlineFragment() + .typeCondition(sdlTypeCondition?.let(::TypeName)) + .selectionSet(SelectionsBuilder(selections).build()) + .directives(directives) + .build(), + selections, + ) + + override fun flatten(): List = selections.flatMap { it.flatten() } +} + +internal data class FragmentSpreadSelection( + override val gjSelection: FragmentSpread, + /** the selections of the fragment definition */ + private val selections: List +) : Selection { + constructor( + name: String, + selections: List, + directives: List = emptyList() + ) : this( + FragmentSpread + .newFragmentSpread(name) + .directives(directives) + .build(), + selections + ) + + override fun flatten(): List = selections.flatMap { it.flatten() } +} + +internal data class DocumentBuilder( + val schemas: Schemas, + val fragments: Fragments = Fragments(schemas), + // the draft version of the graphql-spec requires that labels are unique per operation, + // though graphql-java requires that they are unique per-document. + // We'll follow the GJ behavior here + val incrementalLabels: IncrementalLabels = IncrementalLabels(), + val operations: MutableList = mutableListOf() +) : Schemas by schemas { + fun add(operationDefinition: OperationDefinition) { + require(operations.none { it.name == operationDefinition.name }) { + "Cannot overwrite operation with name `${operationDefinition.name}`" + } + operations += operationDefinition + } + + fun build(): Document = Document(operations + fragments.fragments.map { it.def }) +} + +@ConsistentCopyVisibility +internal data class ArgumentKey private constructor( + private val repr: String, + val arg: Argument +) { + constructor(arg: Argument) : this(AstPrinter.printAstCompact(arg), arg) + + override fun toString(): String = repr + + override fun equals(other: Any?): Boolean = repr == (other as? ArgumentKey)?.repr + + override fun hashCode(): Int = repr.hashCode() +} + +internal data class FieldKey( + val fieldName: String, + val alias: String?, + val arguments: Set, + val fieldType: TypeExpr +) { + val resultKey: String get() = alias ?: fieldName + + override fun toString(): String = + buildString { + if (alias != null) { + append(alias) + append(':') + } + append(fieldName) + if (arguments.isNotEmpty()) { + append('(') + append(arguments.map { it.toString() }.joinToString(",")) + append(')') + } + append(" type=") + append(fieldType.name) + } +} + +/** + * TypeExpr describes a GraphQL type and its [GraphQLNonNull] and [GraphQLList] + * type wrappers. + * + * Type wrappers are converted into a distinct hash value, [listNullHash]. + * This hash allows comparing that two types are equal, where equality is defined as + * referential equality of the unwrapped type and structural equality of the type wrappers. + */ +@ConsistentCopyVisibility +internal data class TypeExpr private constructor( + val type: GraphQLNamedType, + val listNullHash: Int, +) { + val name: String = type.name + + companion object { + operator fun invoke(type: GraphQLOutputType): TypeExpr { + // build an integer hash that represents the wrappers on the provided type. + // The rules are: + // - the initial value will be 1 + // - the value will be left-shifted by 1 for every wrapper + // - the value will be incremented by 1 when the wrapper is non-null + // + // Examples: + // Int -> no transformations -> 1 + // [Int] -> (1 shl 1) -> 2 + // Int! -> (1 shl 1) + 1 -> 3 + // [Int]! -> ((1 shl 1) + 1) shl 1 -> 5 + tailrec fun loop( + acc: Int, + type: GraphQLType + ): TypeExpr = + when (type) { + is GraphQLList -> loop(acc shl 1, type.wrappedType) + is GraphQLNonNull -> loop((acc shl 1) + 1, type.wrappedType) + is GraphQLNamedType -> TypeExpr(type, acc) + else -> throw IllegalArgumentException("Unsupported type $type") + } + return loop(1, type) + } + } +} + +internal fun GraphQLFieldDefinition.key( + alias: String? = null, + arguments: Set = emptySet() +): FieldKey = FieldKey(name, alias, arguments.map(::ArgumentKey).toSet(), TypeExpr(type)) + +internal data class FragmentDef( + val name: String, + val typeCondition: GraphQLCompositeType, + val def: FragmentDefinition, + val sb: SelectionsBuilder, + val variables: List, +) { + companion object { + operator fun invoke( + name: String, + typeCondition: GraphQLCompositeType, + sb: SelectionsBuilder, + variables: List, + directives: List, + ): FragmentDef { + val def = FragmentDefinition + .newFragmentDefinition() + .name(name) + .typeCondition(TypeName(typeCondition.name)) + .selectionSet(sb.build()) + .directives(directives) + .build() + return FragmentDef(name, typeCondition, def, sb, variables) + } + } +} + +/** A shared mutable view of merged fields in a selection set */ +internal class KeyTree private constructor( + /** + * `map` models the merged tree of a selection set, where each key + * in the map describes a field and the value of that key describes its + * subselections. + * A null value indicates that a field will never have subselections, while + * a non-null value describes the subselections that may be complete or incompletely + * assembled. + */ + private val map: MutableMap, + private val resultNameToKey: MutableMap +) { + constructor() : this(mutableMapOf(), mutableMapOf()) + + operator fun get(key: FieldKey): KeyTree? = map[key] + + fun toMap(): Map?> = map.mapValues { it.value?.toMap() } + + fun isEmpty(): Boolean = map.isEmpty() + + val keys: Set get() = map.keys.toSet() + + fun clone(): KeyTree { + val new = KeyTree() + new.merge(this) + return new + } + + fun checkCycles() { + // check tree for cyclic references, which can cause infinite + // loops while merging + fun loop( + path: List, + tree: KeyTree + ) { + check(tree !in path) { + "cycle detected" + } + tree.map.values.forEach { + if (it != null) { + loop(path + tree, it) + } + } + } + loop(emptyList(), this) + } + + /** merge the provided entry into this [KeyTree] */ + fun merge( + key: FieldKey, + value: KeyTree? + ) { + if (map.contains(key)) { + val extant = map[key] + checkValueTypes(extant, value) + extant?.merge(requireNotNull(value)) + } else { + write(key, value) + } + } + + /** merge the provided [KeyTree] into this object */ + fun merge(other: KeyTree): KeyTree { + other.map.forEach { (k, v) -> + if (k in map) { + val extant = map[k] + checkValueTypes(extant, v) + extant?.merge(requireNotNull(v)) + } else { + write(k, v) + } + } + return this + } + + private fun write( + key: FieldKey, + value: KeyTree? + ) { + check(canMerge(key)) { + "Cannot overwrite extant key ${resultNameToKey[key.resultKey]} for result `${key.resultKey}` with $key" + } + map[key] = value?.clone() + resultNameToKey[key.resultKey] = key + } + + fun canMerge(key: FieldKey): Boolean = resultNameToKey[key.resultKey]?.let { it == key } ?: true + + fun canMerge( + key: FieldKey, + value: KeyTree? + ): Boolean { + if (!canMerge(key)) return false + if (map.containsKey(key)) { + val extant = map[key] + checkValueTypes(extant, value) + if (extant != null) { + return extant.canMerge(requireNotNull(value)) + } + } + return true + } + + fun canMerge(other: KeyTree): Boolean = + other.map.all { (k, v) -> + if (!canMerge(k)) { + false + } else if (map.contains(k)) { + val extant = map[k] + checkValueTypes(extant, v) + extant?.canMerge(requireNotNull(v)) ?: true + } else { + true + } + } + + /** check that both of the provided 's support or don't support sub selections */ + private fun checkValueTypes( + extant: KeyTree?, + value: KeyTree? + ) { + require((extant == null) == (value == null)) { + "mismatched value types" + } + } + + companion object { + /** + * Initialize a new [KeyTree] that for the provided key + * The returned KeyTree will model the object that contains [key] + */ + operator fun invoke(key: FieldKey): KeyTree { + val value = if (key.fieldType.type is GraphQLCompositeType) { + KeyTree() + } else { + null + } + return KeyTree().also { it.merge(key, value) } + } + } +} + +/** + * A SelectionsBuilder assembles a selection set that is capable of being merged + * according to the rules of GraphQL field selection merging, described in detail at + * https://spec.graphql.org/draft/#sec-Field-Selection-Merging + */ +internal class SelectionsBuilder private constructor( + private val mutableSelections: MutableList, + /** + * A tree of fields that this builder is merge-compatible with. + * This tree includes all keys provided by [mutableSelections] + */ + val mergeKeyTree: KeyTree, +) { + companion object { + fun buildKeyTree(f: FieldSelection): KeyTree = + KeyTree().also { + it.merge(f.key, f.mergeKeyTree) + } + + operator fun invoke(localSelections: List = emptyList()): SelectionsBuilder { + val mergeKeyTree = localSelections + .flatMap { it.flatten() } + .map(::buildKeyTree) + .fold(KeyTree()) { acc, other -> acc.merge(other) } + + return SelectionsBuilder( + localSelections.toMutableList(), + mergeKeyTree + ) + } + } + + /** return true if this SelectionsBuilder contains no selections */ + val isEmpty: Boolean get() = mutableSelections.isEmpty() + + val selections: List get() = mutableSelections.toList() + + /** + * return true if the provided FieldKey can be added to this [SelectionsBuilder] without + * violating the rules of field mergeability. + * + * https://spec.graphql.org/draft/#sec-Field-Selection-Merging + */ + fun canAdd(key: FieldKey): Boolean = mergeKeyTree.canMerge(KeyTree(key)) + + /** + * return true if every field selection in [selection] could be + * added to this object without violating the rules of field mergeability. + * + * https://spec.graphql.org/draft/#sec-Field-Selection-Merging + */ + fun canAdd(selection: Selection): Boolean = + selection.flatten().all { s -> + mergeKeyTree.canMerge(s.key, s.mergeKeyTree) + } + + /** + * Return the fields that are locally selected by this [SelectionsBuilder]. + * This effectively flattens all inline fragments and fragment spreads. + */ + fun fieldSelections(): List = selections.flatMap { it.flatten() } + + /** + * Add the provided [Selection] to this [SelectionsBuilder]. + * This will throw if [selection] contains fields that are not addable. + * @see canAdd + */ + fun add(selection: Selection) { + // sanity check + require(canAdd(selection)) { + "add called with an un-addable selection" + } + + // add all fields within selection to the merge scope + selection.flatten().forEach { field -> + mergeKeyTree.merge(field.key, field.mergeKeyTree) + } + mergeKeyTree.checkCycles() + mutableSelections += selection + } + + /** + * Create a new [SelectionsBuilder] that models an InlineFragment or FragmentSpread. + * The new builder will retain a view of this builder's view on merging. + * @see canAdd + */ + fun newSpreadScope(): SelectionsBuilder = + SelectionsBuilder( + mutableSelections = mutableListOf(), + mergeKeyTree, + ) + + /** create a new scope suitable for building a fragment definition */ + fun newFragmentScope(): SelectionsBuilder = SelectionsBuilder(mutableSelections = mutableListOf(), mergeKeyTree.clone()) + + /** + * Create a new [SelectionsBuilder] that models the field selections for the provided [FieldKey] + * The new builder will retain a view of this builder's view on field merging. + * @see canAdd + */ + fun newFieldScope(key: FieldKey): SelectionsBuilder = + SelectionsBuilder( + mutableSelections = mutableListOf(), + mergeKeyTree = mergeKeyTree[key] ?: KeyTree(key) + ) + + fun build(): SelectionSet = + SelectionSet(selections.map { it.gjSelection }) + .also { + if (it.selections.isEmpty()) { + throw AssertionError("Sanity check failed: generated an empty selection set") + } + } +} diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt new file mode 100644 index 000000000..97a140dce --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt @@ -0,0 +1,123 @@ +package graphql.schema.property + +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.arbitrary.constant +import io.kotest.property.arbitrary.flatMap +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.pair +import io.kotest.property.arbitrary.shuffle +import kotlin.math.max +import kotlin.math.min + +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 true with probability [weight]. */ +internal fun RandomSource.sampleWeight(weight: Double): Boolean = + when (weight) { + 0.0 -> false + 1.0 -> true + else -> random.nextDouble(0.0, 1.0) <= weight + } + +/** Return how many times [weight] sampled true before reaching its configured maximum. */ +internal 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) +} + +/** Convert a generator into an infinite sequence using [randomSource]. */ +internal fun Gen.asSequence(randomSource: RandomSource): Sequence = + generate(randomSource).map { it.value } + +/** + * Generate subsets of this set. The subset size is constrained by [range], or ranges from zero + * through the input set size when no range is provided. + */ +internal fun Set.arbSubset(range: IntRange? = null): Arb> = + Arb.constant(this).flatMap { values -> + val first = min(max(range?.first ?: 0, 0), values.size) + val last = min(max(range?.last ?: values.size, 0), values.size) + Arb.pair(Arb.shuffle(values.toList()), Arb.int(first..last)) + .map { (shuffled, count) -> shuffled.take(count).toSet() } + } + +/** + * 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/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt new file mode 100644 index 000000000..4faccfaf1 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt @@ -0,0 +1,42 @@ +package graphql.schema.property + +import graphql.language.ListType +import graphql.language.Node +import graphql.language.NonNullType +import graphql.language.Type +import graphql.language.TypeName +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNamedType +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLSchema +import graphql.schema.GraphQLType +import graphql.schema.GraphQLTypeUtil + +/** All descendants of this AST node, excluding the node itself. */ +internal val Node<*>.allChildren: List> + get() = children.fold(children.toList()) { descendants, child -> + descendants + child.allChildren + } + +/** All descendants of this AST node that have type [T]. */ +internal inline fun > Node<*>.allChildrenOfType(): List = + allChildren.filterIsInstance() + +/** Convert a language AST type into its schema type counterpart. */ +internal fun Type<*>.asSchemaType(schema: GraphQLSchema): GraphQLType = + when (this) { + is ListType -> GraphQLList.list(requireNotNull(type).asSchemaType(schema)) + is NonNullType -> GraphQLNonNull.nonNull(requireNotNull(type).asSchemaType(schema)) + is TypeName -> requireNotNull(schema.getTypeAs(requireNotNull(name))) + else -> throw UnsupportedOperationException("Unsupported language type: $this") + } + +/** Convert a schema type into its language AST counterpart. */ +internal fun GraphQLType.asAstType(): Type<*> = + when (this) { + is GraphQLList -> ListType(GraphQLTypeUtil.unwrapOneAs(this).asAstType()) + is GraphQLNonNull -> NonNullType(GraphQLTypeUtil.unwrapOneAs(this).asAstType()) + is GraphQLNamedType -> TypeName(name) + else -> throw UnsupportedOperationException("Unsupported schema type: $this") + } diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLDocuments.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLDocuments.kt new file mode 100644 index 000000000..6aa5f8f4e --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLDocuments.kt @@ -0,0 +1,681 @@ +package graphql.schema.property + +import graphql.introspection.Introspection.DirectiveLocation +import graphql.introspection.Introspection.TypeNameMetaFieldDef +import graphql.language.Argument +import graphql.language.Directive +import graphql.language.Document +import graphql.language.FragmentDefinition +import graphql.language.FragmentSpread +import graphql.language.Node +import graphql.language.NullValue +import graphql.language.OperationDefinition +import graphql.language.SelectionSet +import graphql.language.StringValue +import graphql.language.TypeName +import graphql.language.Value +import graphql.language.VariableDefinition +import graphql.language.VariableReference +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLCompositeType +import graphql.schema.GraphQLDirective +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLImplementingType +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLOutputType +import graphql.schema.GraphQLSchema +import graphql.schema.GraphQLTypeUtil +import graphql.schema.GraphQLUnionType +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.filterNot +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.withEdgecases +import java.util.LinkedList +import kotlin.math.max + +/** + * Generate an arbitrary GraphQL [Document] for the provided [GraphQLSchema] and config. + * The returned Document is guaranteed to be valid according to the rules at + * https://spec.graphql.org/draft/#sec-Documents + */ +fun Arb.Companion.graphQLDocument( + schema: GraphQLSchema, + cfg: Config = Config.default, +): Arb = + arbitrary { rs -> + DocumentGenEnv(schema, cfg, rs).documentGen.gen() + } + +/** + * [DocumentGenEnv] provides an environment for component wiring, allowing for dependency cycles + * This is declared as an interface to allow for kotlin-delegation inheritance + */ +internal interface DocumentGenEnv { + val schemas: Schemas + val cfg: Config + val rs: RandomSource + val documentGen: GraphQLDocumentGen + val operationGen: GraphQLOperationGen + val selectionSetGen: GraphQLSelectionSetGen + val directiveGen: GraphQLDirectivesGen + val argumentsGen: GraphQLArgumentsGen + val inputValueGenerator: GraphQLInputValueGenerator + + fun checkDepth(depth: Int): Boolean = depth <= cfg[MaxSelectionSetDepth] + + companion object { + 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) + } +} + +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, + val typeCondition: GraphQLCompositeType, + val fragments: Fragments, + val variables: Variables, + val incrementalLabels: IncrementalLabels, + val isSubscriptionOperation: Boolean, + val depth: Int = 0, +) { + val isSubscriptionSelection: Boolean get() = typeCondition == schemas.schema.subscriptionType + val isMutationSelection: Boolean get() = typeCondition == schemas.schema.mutationType + + val typeAsImplementingType: GraphQLImplementingType get() = typeCondition as GraphQLImplementingType + + fun selectableFields(cfg: Config): List = + when { + typeCondition is GraphQLUnionType -> listOf(TypeNameMetaFieldDef) + else -> + typeAsImplementingType.fields + .let { + if (!isSubscriptionSelection) { + it + TypeNameMetaFieldDef + } else { + it + } + } + .filter { field -> + val coord = typeCondition.name to field.name + coord !in cfg[BanSelectionCoordinates] + } + } + + val keepGenerating: Boolean get() = + if (isSubscriptionSelection) sb.isEmpty else true + + /** + * Create a new context with the provided [SelectionsBuilder] and [typeCondition], and + * a depth counter incremented by 1 + */ + fun push( + sb: SelectionsBuilder, + typeCondition: GraphQLCompositeType + ): DocumentGenCtx = + copy( + sb = sb, + typeCondition = typeCondition, + depth = depth + 1 + ) + + companion object { + operator fun invoke( + schemas: Schemas, + typeCondition: GraphQLCompositeType, + isSubscriptionOperation: Boolean = false + ): DocumentGenCtx = + DocumentGenCtx( + schemas, + SelectionsBuilder(), + typeCondition, + Fragments(schemas), + Variables(schemas), + IncrementalLabels(), + isSubscriptionOperation = isSubscriptionOperation, + depth = 0 + ) + } +} + +internal class GraphQLDocumentGen( + env: DocumentGenEnv +) : DocumentGenEnv by env { + fun gen(): Document { + val db = DocumentBuilder(schemas) + + val arbOperation = Arb.of( + listOfNotNull( + schemas.schema.queryType to OperationDefinition.Operation.QUERY, + schemas.schema.mutationType?.let { it to OperationDefinition.Operation.MUTATION }, + schemas.schema.subscriptionType?.let { it to OperationDefinition.Operation.SUBSCRIPTION } + ) + ) + + Arb + .list(arbOperation, cfg[OperationCount]) + .next(rs) + .also { list -> + val count = list.size + list.mapIndexed { i, (objectType, operationType) -> + val canBeAnonymous = i == 0 && count == 1 + db.add( + operationGen.gen(db, objectType, operationType, canBeAnonymous) + ) + } + } + + return db.build() + } +} + +internal class GraphQLOperationGen( + env: DocumentGenEnv +) : DocumentGenEnv by env { + /** generate a document that contains exactly 1 operation definition and any number of fragment definitions */ + fun gen( + db: DocumentBuilder, + objectType: GraphQLObjectType, + operationType: OperationDefinition.Operation, + canBeAnonymous: Boolean + ): OperationDefinition { + val variables = Variables(schemas) + val name = + if (canBeAnonymous && rs.sampleWeight(cfg[AnonymousOperationWeight])) { + null + } else { + Arb + .graphQLName(cfg[TypeNameLength]) + .filterNot { name -> db.operations.any { it.name == name } } + .next(rs) + } + + val directiveLocation = when (operationType) { + OperationDefinition.Operation.QUERY -> DirectiveLocation.QUERY + OperationDefinition.Operation.MUTATION -> DirectiveLocation.MUTATION + OperationDefinition.Operation.SUBSCRIPTION -> DirectiveLocation.SUBSCRIPTION + } + + val ctx = DocumentGenCtx(schemas, SelectionsBuilder(), objectType, db.fragments, variables, db.incrementalLabels, isSubscriptionOperation = objectType == schemas.schema.subscriptionType) + + return OperationDefinition + .newOperationDefinition() + .name(name) + .operation(operationType) + .selectionSet(selectionSetGen.gen(ctx)) + .directives(directiveGen.gen(directiveLocation, ctx)) + .variableDefinitions(variables.variables) + .build() + } +} + +internal class GraphQLSelectionSetGen( + env: DocumentGenEnv +) : DocumentGenEnv by env { + /** generate a SelectionSet for the given [DocumentGenCtx] */ + fun gen(ctx: DocumentGenCtx): SelectionSet { + // check depth and return early + if (checkDepth(ctx.depth)) { + // fragments are more likely to be spreadable when applied to an empty selection set. + // Add fragment spreads before generating other selections + repeat(depthAdjustedCount(ctx, cfg[FragmentSpreadWeight])) { + if (ctx.keepGenerating) { + genFragmentSpread(ctx) + } + } + + // add unfragmented field selections + repeat(depthAdjustedCount(ctx, cfg[FieldSelectionWeight])) { + if (ctx.keepGenerating) { + val field = Arb.of(ctx.selectableFields(cfg)).next(rs) + genField(ctx, field) + } + } + + // add inline fragments selections + repeat(depthAdjustedCount(ctx, cfg[InlineFragmentWeight])) { + if (ctx.keepGenerating) { + genInlineFragment(ctx) + } + } + } + + // if no selections were added, add a default selection to ensure that the selection set is syntactically valid + if (ctx.sb.isEmpty) { + val fallbackField = Arb.of(fallbackFields(ctx)).next(rs) + genField(ctx, fallbackField) + } + + return ctx.sb.build() + } + + private fun depthAdjustedCount( + ctx: DocumentGenCtx, + cw: CompoundingWeight + ): Int { + val adjustedWeight = cw.copy(max = max(cw.max - ctx.depth, 0)) + return rs.count(adjustedWeight) + } + + private fun fallbackFields(ctx: DocumentGenCtx): List = + ctx.selectableFields(cfg) + .filter { GraphQLTypeUtil.unwrapAll(it.type) !is GraphQLCompositeType } + + private fun genField( + ctx: DocumentGenCtx, + field: GraphQLFieldDefinition + ) { + val arguments = argumentsGen.gen(field.arguments, ctx) + val unwrappedFieldType = GraphQLTypeUtil.unwrapAllAs(field.type) + var key = FieldKey( + fieldName = field.name, + alias = null, + arguments = arguments.map(::ArgumentKey).toSet(), + fieldType = TypeExpr(field.type) + ) + + val fieldCompositeType = unwrappedFieldType as? GraphQLCompositeType + + if (!ctx.sb.canAdd(key) || rs.sampleWeight(cfg[AliasWeight])) { + key = withAlias(ctx, key, cfg[FieldNameLength]) + } + + val directives = genDirectives(ctx, DirectiveLocation.FIELD) + + val fieldSelections = fieldCompositeType?.let { ctx.sb.newFieldScope(key) } + if (fieldSelections != null) { + gen(ctx.push(fieldSelections, fieldCompositeType)) + } + + val fieldSelection = FieldSelection(key, fieldSelections, directives) + ctx.sb.add(fieldSelection) + } + + private fun withAlias( + ctx: DocumentGenCtx, + key: FieldKey, + aliasNameSize: IntRange + ): FieldKey { + // when generating an alias name, generate aliases that can collide with + // field names. These values are read using Arb.generate (rather than Arb.next), + // which is one of a small number of Arb methods that do not ignore edge cases + val keyWithAlias = Arb + .graphQLName(aliasNameSize) + .withEdgecases(ctx.selectableFields(cfg).map { it.name }) + .generate(rs) + .take(100) + .map { key.copy(alias = it.value) } + .filter(ctx.sb::canAdd) + .firstOrNull() + + if (keyWithAlias !== null) return keyWithAlias + + // For low values of aliasNameSize, it's possible that all possible alias names + // are already in use and that we cannot generate a non-conflicting alias name. + // Try to generate an alias with the requested length, but expand + // the length if we don't find a value in the first couple iterations + val newAliasNameSize = (aliasNameSize.first + 1)..(aliasNameSize.last + 1) + return withAlias(ctx, key, newAliasNameSize) + } + + private fun genInlineFragment(ctx: DocumentGenCtx) { + val spreadableTypes = schemas.rels.spreadableTypes(ctx.typeCondition) + if (spreadableTypes.isEmpty()) return + + val typeCondition = Arb.of(spreadableTypes).next(rs) + val spreadScope = ctx.sb.newSpreadScope() + val spreadCtx = ctx.push(spreadScope, typeCondition) + gen(spreadCtx) + + val isUntyped = typeCondition == ctx.typeCondition && rs.sampleWeight(cfg[UntypedInlineFragmentWeight]) + val sdlTypeCondition = if (isUntyped) null else typeCondition.name + + val directives = directiveGen.gen(DirectiveLocation.INLINE_FRAGMENT, ctx) + + val selection = InlineFragmentSelection( + sdlTypeCondition, + spreadCtx.sb.selections, + directives + ) + ctx.sb.add(selection) + } + + private fun genFragmentSpread(ctx: DocumentGenCtx) { + val spreadableFragments = ctx.fragments + .spreadableFragments(ctx.sb, ctx.typeCondition) + .filter { frag -> + // a fragment may have been generated in the context of another operation, and used variables + // that are incompatible with the variables defined for this operation. Filter them out + frag.variables.all { fv -> + when (val extant = ctx.variables[requireNotNull(fv.name)]) { + null -> true + else -> extant == fv + } + } + } + + var fragment: FragmentDef? = null + + if (spreadableFragments.isEmpty() || rs.sampleWeight(cfg[FragmentDefinitionWeight])) { + val spreadableTypes = schemas.rels.spreadableTypes(ctx.typeCondition) + if (spreadableTypes.isNotEmpty()) { + val fragmentTypeCondition = Arb.of(spreadableTypes).next(rs) + fragment = genFragmentDef(ctx, fragmentTypeCondition) + } + } + + if (fragment == null && spreadableFragments.isNotEmpty()) { + fragment = Arb.of(spreadableFragments).next(rs) + } + + if (fragment == null) return + + // if the fragment was generated for a different operation, we may need to install any new variables + // required by the fragment into the current operation's variables + fragment.variables.forEach { fv -> + if (ctx.variables[requireNotNull(fv.name)] == null) { + ctx.variables.add(fv) + } + } + + val selection = FragmentSpreadSelection( + fragment.name, + fragment.sb.selections, + genDirectives(ctx, DirectiveLocation.FRAGMENT_SPREAD) + ) + + ctx.sb.add(selection) + } + + /** generate a fragment definition that is spreadable in the selections of Ctx */ + private fun genFragmentDef( + ctx: DocumentGenCtx, + typeCondition: GraphQLCompositeType + ): FragmentDef { + val fragmentScope = ctx.sb.newFragmentScope() + + val fragCtx = ctx.push(fragmentScope, typeCondition).also(::gen) + + val name = Arb + .graphQLName(cfg[TypeNameLength]) + // prefix fragment names to help with readability when debugging + .map { "Fragment_$it" } + .filter { ctx.fragments[it] == null } + .next(rs) + + val directives = genDirectives(fragCtx, DirectiveLocation.FRAGMENT_DEFINITION) + val def = FragmentDefinition + .newFragmentDefinition() + .name(name) + .typeCondition(TypeName(typeCondition.name)) + .directives(directives) + .selectionSet(fragCtx.sb.build()) + .build() + + val variables = extractVariables(ctx, def) + return FragmentDef(name, typeCondition, def, fragCtx.sb, variables) + .also(fragCtx.fragments::add) + } + + /** + * Extract any variable definitions that are used in a nodes tree, inclusive + * of any referenced fragments or variables (which can also refer to other variables) + */ + private fun extractVariables( + ctx: DocumentGenCtx, + node: Node<*> + ): List { + val seen = mutableSetOf>() + val vars = mutableSetOf() + val queue = LinkedList(listOf(node)) + + while (queue.isNotEmpty()) { + val headNode = queue.remove().also(seen::add) + + headNode.allChildren.forEach { child -> + if (child is FragmentSpread && child !in seen) { + seen.add(child) + queue.push(requireNotNull(ctx.fragments[requireNotNull(child.name)]).def) + } + if (child is VariableReference && child !in seen) { + val vdef = requireNotNull(ctx.variables[requireNotNull(child.name)]) + seen.add(child) + vars.add(vdef) + } + } + } + return vars.toList() + } + + private fun genDirectives( + ctx: DocumentGenCtx, + location: DirectiveLocation + ): List = directiveGen.gen(location, ctx) +} + +internal class GraphQLArgumentsGen( + env: DocumentGenEnv +) : DocumentGenEnv by env { + fun gen( + args: List, + ctx: DocumentGenCtx, + canUseVariables: Boolean = true + ): List = args.mapNotNull { gen(it, ctx, canUseVariables) } + + /** + * generate a single Argument value for the provided GraphQLArgument. + * A null value indicates that the Argument should be omitted + */ + fun gen( + arg: GraphQLArgument, + ctx: DocumentGenCtx, + canUseVariables: Boolean = true + ): Argument? { + val canInull = arg.hasSetDefaultValue() || GraphQLTypeUtil.isNullable(arg.type) + + return if (canInull && rs.sampleWeight(cfg[ImplicitNullValueWeight])) { + null + } else { + val argDefault = arg.argumentDefaultValue + .takeIf { it.isLiteral } + ?.let { + it.value as Value<*> + } + + Argument(arg.name, genValue(arg.type, ctx, argDefault, canUseVariables)) + } + } + + private fun genValue( + type: GraphQLInputType, + ctx: DocumentGenCtx, + locationDefault: Value<*>?, + canUseVariables: Boolean + ): Value<*> { + // TODO: deeply insert variable values into values (ie inside list items, or input fields) + return if (canUseVariables && rs.sampleWeight(cfg[VariableWeight])) { + val possibleVariables = ctx.variables.variablesForType(type, locationDefault) + val variable = if (possibleVariables.isEmpty()) { + genVariable(type, ctx) + } else { + Arb.of(possibleVariables).next(rs) + } + VariableReference(requireNotNull(variable.name)) + } else { + inputValueGenerator.generate(type) + } + } + + private fun genVariable( + forType: GraphQLInputType, + ctx: DocumentGenCtx, + ): VariableDefinition { + val default = + if (rs.sampleWeight(cfg[DefaultValueWeight])) { + inputValueGenerator.generate(forType) + } else { + null + } + + val name = Arb + .graphQLFieldName(cfg) + .filter { ctx.variables[it] == null } + .next(rs) + + return VariableDefinition + .newVariableDefinition() + .name(name) + .type(forType.asAstType()) + .defaultValue(default) + .directives( + // A nuance of the spec grammar that isn't spelled out in the prose is that + // directives applied to variable definitions may not themselves use variables + directiveGen.gen(DirectiveLocation.VARIABLE_DEFINITION, ctx, canUseVariables = false) + ).build() + .also(ctx.variables::add) + } +} + +internal class GraphQLDirectivesGen( + env: DocumentGenEnv +) : DocumentGenEnv by env { + // a set of directives that are known to require const argument values + private val incrementalDirs = setOf("stream", "defer") + private val conditionalDirs = setOf("skip", "include") + + fun gen( + location: DirectiveLocation, + ctx: DocumentGenCtx, + canUseVariables: Boolean = true, + ): List { + val directiveWeight = cfg[AppliedDirectiveWeight] + + tailrec fun loop( + acc: List, + pool: Set + ): List = + if (pool.isEmpty() || directiveWeight.max == acc.size || !rs.sampleWeight(directiveWeight.weight)) { + acc + } else { + val def = Arb.of(pool).next(rs) + val useVariables = canUseVariables && def.name !in incrementalDirs + val newPool = if (def.isRepeatable) pool else pool - def + val arguments = genArguments(def.arguments, ctx, useVariables, isIncremental = def.name in incrementalDirs) + val dir = Directive + .newDirective() + .name(def.name) + .arguments(arguments) + .build() + loop(acc + dir, newPool) + } + val pool = schemas.directivesByLocation[location]?.let { pool -> + val bannedDirectives = buildSet { + addAll(cfg[BanDirectiveNames]) + + if (ctx.isSubscriptionOperation || ctx.isMutationSelection) { + // incremental directives are not allowed anywhere in a subscription operation or on a + // mutation root field + addAll(incrementalDirs) + } + if (ctx.isSubscriptionSelection) { + // subscriptions have a single-root-field rule that makes it difficult to sanely apply skip/include + // directives. If we're evaluating directives for a subscription selection, remove these directives + // from the pool + // see also: https://spec.graphql.org/draft/#sec-Single-Root-Field + addAll(conditionalDirs) + } + } + if (bannedDirectives.isNotEmpty()) { + pool.filter { it.name !in bannedDirectives } + } else { + pool + } + } ?: emptySet() + return loop(emptyList(), pool.toSet()) + } + + private fun genArguments( + defs: List, + ctx: DocumentGenCtx, + canUseVariables: Boolean, + isIncremental: Boolean + ): List { + // check that the argument is valid. Invalid arguments may be regenerated + fun checkArgument( + def: GraphQLArgument, + arg: Argument? + ): Boolean { + if (!isIncremental || def.name != "label") return true + + val label = when (val v = arg?.value) { + is StringValue -> v.value + null, is NullValue -> null + else -> throw IllegalStateException("Unexpected value type: $v") + } + + return when { + label == null -> true + ctx.incrementalLabels.contains(label) -> false + else -> { + ctx.incrementalLabels.add(label) + true + } + } + } + + // iteratively generate Argument values for the given definitions + tailrec fun loop( + acc: List, + pending: List + ): List { + if (pending.isEmpty()) return acc + val def = pending[0] + val arg = argumentsGen.gen(def, ctx, canUseVariables) + return if (checkArgument(def, arg)) { + val newAcc = arg?.let { acc + it } ?: acc + loop(newAcc, pending.drop(1)) + } else { + // if checkArgument rejected the generated arg, do not dequeue and keep looping until we get an acceptable value + loop(acc, pending) + } + } + + return loop(emptyList(), defs) + } +} diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExecutionInputs.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExecutionInputs.kt new file mode 100644 index 000000000..3d72b9412 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExecutionInputs.kt @@ -0,0 +1,79 @@ +package graphql.schema.property + +import graphql.ExecutionInput +import graphql.language.AstPrinter +import graphql.language.Document +import graphql.language.OperationDefinition +import graphql.schema.GraphQLInputType +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.flatMap +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.of + +/** + * Generate an arbitrary GraphQL [ExecutionInput] for the provided schema and config. + * The returned input contains an executable document and valid external variable values. + */ +fun Arb.Companion.graphQLExecutionInput( + schema: GraphQLSchema, + config: Config = Config.default +): Arb = + graphQLDocument(schema, config).flatMap { document -> + graphQLExecutionInput(schema, document, config) + } + +/** + * Generate an arbitrary GraphQL [ExecutionInput] for the provided document and schema. + * The returned input contains valid external values for the selected operation's variables. + */ +fun Arb.Companion.graphQLExecutionInput( + schema: GraphQLSchema, + document: Document, + config: Config = Config.default +): Arb = + arbitrary { randomSource -> + GraphQLExecutionInputGen(schema, config, randomSource).generate(document) + } + +internal class GraphQLExecutionInputGen( + private val schema: GraphQLSchema, + private val config: Config, + private val randomSource: RandomSource +) { + private val valueGenerator = GraphQLExternalInputValueGenerator(schema, config, randomSource) + + fun generate(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) + val operationName = operationName(operation, operations.size) + val variables = operation.variableDefinitions.fold(linkedMapOf()) { values, definition -> + 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 + } + + return ExecutionInput.newExecutionInput() + .query(AstPrinter.printAst(document)) + .apply { operationName?.let(::operationName) } + .variables(variables) + .build() + } + + private fun operationName( + operation: OperationDefinition, + operationCount: Int + ): String? { + if (operationCount == 1 && randomSource.sampleWeight(config[AnonymousOperationWeight])) { + return null + } + return operation.name + } +} diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExternalInputValueGenerator.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExternalInputValueGenerator.kt new file mode 100644 index 000000000..d007f9a03 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLExternalInputValueGenerator.kt @@ -0,0 +1,160 @@ +package graphql.schema.property + +import graphql.schema.GraphQLEnumType +import graphql.schema.GraphQLInputObjectField +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +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.int +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.of +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]. + */ +internal class GraphQLExternalInputValueGenerator( + schema: GraphQLSchema, + private val config: Config, + private val randomSource: RandomSource, + private val uncoercedValueWeight: Double = 0.0, + 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 fun generate(context: ExternalValueGenerationContext): Any? { + val type = context.type + if (type is GraphQLNonNull) { + return generate(context.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 (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) + throw UnsupportedOperationException("Unsupported input type: $type") + } + + private fun shouldGenerateNull(context: ExternalValueGenerationContext): Boolean = + context.overBudget || randomSource.sampleWeight(config[ExplicitNullValueWeight]) + + private fun generateList( + listType: GraphQLList, + context: ExternalValueGenerationContext + ): Any? { + val wrappedType = listType.wrappedType as GraphQLInputType + val itemContext = context.push(wrappedType) + val canUseSingleValueCoercion = GraphQLTypeUtil.unwrapNonNull(wrappedType) !is GraphQLList + if (canUseSingleValueCoercion && !itemContext.overBudget && randomSource.sampleWeight(uncoercedValueWeight)) { + return generate(context.push(wrappedType, nullable = false)) + } + val size = if (itemContext.overBudget) 0 else Arb.int(config[ListValueSize]).next(randomSource) + return List(size) { generate(itemContext) } + } + + private fun generateScalar(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() + else -> throw UnsupportedOperationException( + "No external value generator configured for scalar `${type.name}`" + ) + } + } + + private fun generateFloat(): Number = + if (randomSource.sampleWeight(uncoercedValueWeight)) { + randomSource.random.nextInt() + } else { + randomSource.random.nextDouble(-1_000_000.0, 1_000_000.0) + } + + private fun generateId(): Any = + if (randomSource.sampleWeight(uncoercedValueWeight)) { + randomSource.random.nextInt() + } else { + generateString() + } + + private fun generateString(): String = Arb.string(config[StringValueSize]).next(randomSource) + + private fun generateOneOf( + type: GraphQLInputObjectType, + context: ExternalValueGenerationContext + ): Map { + val field = Arb.of(oneOfCandidates(type, context)).next(randomSource) + return mapOf(field.name to generate(context.push(field.type, nullable = false))) + } + + private fun oneOfCandidates( + type: GraphQLInputObjectType, + context: ExternalValueGenerationContext + ): List { + if (!context.overBudget) return type.fields + val cycleGroup = mandatoryEdgesGraph[type.name] + return type.fields.filter { field -> + val unwrapped = GraphQLTypeUtil.unwrapAll(field.type) + unwrapped !is GraphQLInputObjectType || + GraphQLTypeUtil.unwrapNonNull(field.type) is GraphQLList || + unwrapped.name !in cycleGroup + }.ifEmpty { type.fields } + } + + private fun generateInputObject( + type: GraphQLInputObjectType, + context: ExternalValueGenerationContext + ): Map { + val cycleGroup = allEdgesGraph[type.name] + return type.fields + .filter { shouldIncludeField(it, cycleGroup, context) } + .associate { it.name to generate(context.push(it.type)) } + } + + private fun shouldIncludeField( + field: GraphQLInputObjectField, + cycleGroup: Set, + context: ExternalValueGenerationContext + ): 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]) + } +} + +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/GraphQLGeneratorConfig.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLGeneratorConfig.kt new file mode 100644 index 000000000..4d34bdc02 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLGeneratorConfig.kt @@ -0,0 +1,305 @@ +package graphql.schema.property + +import graphql.language.Value +import io.kotest.property.Arb + +/** + * The approximate number of types and directives that a generated schema will define. + * Due to name collisions and the potential addition of built-in scalars, the generated + * schemas may contain slightly more or less than the configured amount. + */ +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. + * 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 } +} + +/** The probability that an Object type will implement any interface */ +object ObjectImplementsInterface : ConfigKey( + CompoundingWeight(.1, 10), + CompoundingWeightValidator +) + +/** The probability that an Interface type will implement another interface */ +object InterfaceImplementsInterface : ConfigKey( + CompoundingWeight(.1, 3), + CompoundingWeightValidator +) + +/** + * The probability that an interface or object field has arguments. + * This is a compounding probability, meaning that if configured with value .1, then 10% of + * fields will have at least 1 argument, 1% will have at least 2 arguments, + * .1% at least 3, and so on. + */ +object FieldArgumentWeight : ConfigKey( + CompoundingWeight(.1, 3), + CompoundingWeightValidator +) + +/** + * The probability that a GraphQL element will define a default value. This applies to + * directive args, field args, input fields, variables, etc + */ +object DefaultValueWeight : ConfigKey(.2, WeightValidator) + +/** Should the Schema define and use builtin scalar types (String, Int, etc) */ +object IncludeBuiltinScalars : ConfigKey(true, Unvalidated) + +/** Should the Schema generate and use arbitrary scalar types */ +object GenCustomScalars : ConfigKey(false, Unvalidated) + +/** Should the Schema define and use builtin directives (@oneOf, @deprecated, etc) */ +object IncludeBuiltinDirectives : ConfigKey(true, Unvalidated) + +/** The probability that a Directive will be repeatable */ +object DirectiveIsRepeatable : ConfigKey(.1, WeightValidator) + +/** The probability that a Directive will have arguments */ +object DirectiveHasArgs : ConfigKey( + CompoundingWeight(.1, 3), + CompoundingWeightValidator +) + +/** The probability that a schema element or document node will have an applied directive */ +object AppliedDirectiveWeight : ConfigKey( + CompoundingWeight(.1, 3), + CompoundingWeightValidator +) + +/** + * Include the provided types in a generated schema. The included types will + * be used in the type pool and may be used by other generated types in the schema. + */ +object IncludeTypes : ConfigKey(GraphQLTypes.empty, Unvalidated) + +/** + * The range of possible GraphQL name lengths for type-like GraphQL elements. + * This includes schema type names, operation names, fragment names, etc. + */ +object TypeNameLength : ConfigKey(1..10, IntRangeValidator(1..Int.MAX_VALUE)) + +/** + * The range of possible GraphQL name lengths for field-like GraphQL elements. + * This includes object field names, input object field names, enum value names, + * argument names, variable names, alias names, etc + */ +object FieldNameLength : ConfigKey(1..10, IntRangeValidator(1..Int.MAX_VALUE)) + +/** + * The range of possible description string lengths. + * As nearly all GraphQL types support descriptions, longer values + * can significantly slow down tests. + */ +object DescriptionLength : ConfigKey(0..10, IntRangeValidator(0..Int.MAX_VALUE)) + +/** The probability that a field type will be wrapped in a List type */ +object ListTypeWeight : ConfigKey( + CompoundingWeight(.1, 2), + CompoundingWeightValidator +) + +/** The probability that a field- or argument type will be non-nullable */ +object NonNullableTypeWeight : ConfigKey(.2, WeightValidator) + +/** The number of input fields that an input object type will define */ +object InputObjectTypeSize : ConfigKey(1..3, IntRangeValidator(1..Int.MAX_VALUE)) + +/** The probability that a generated input object will be a OneOf type */ +object OneOfTypeWeight : ConfigKey(.25, WeightValidator) + +/** + * The number of fields that an interface type will define. + * Interfaces that implement other interfaces may define more than the maximum configured amount. + */ +object InterfaceTypeSize : ConfigKey(1..3, IntRangeValidator(1..Int.MAX_VALUE)) + +/** + * The number of fields that an object type will define + * Objects that implement interfaces may define more than the maximum configured amount + */ +object ObjectTypeSize : ConfigKey(1..3, IntRangeValidator(1..Int.MAX_VALUE)) + +/** The number of members that a union type will include */ +object UnionTypeSize : ConfigKey(1..3, IntRangeValidator(1..Int.MAX_VALUE)) + +/** The number of values that an enum type will define */ +object EnumTypeSize : ConfigKey(1..3, IntRangeValidator(1..Int.MAX_VALUE)) + +/** + * For fields that support it, the probability that a generated GraphQL value will be + * implicitly null (ie the field key will not be included in the value map). + * + * For input fields and arguments, this is applicable when a field type is nullable + * or has a default value. + * For output fields, this is applicable for all field types. + */ +object ImplicitNullValueWeight : ConfigKey(.1, WeightValidator) + +/** + * For types that support it, the probability that a generated GraphQL value will + * be explicitly null (ie value == `null`). + * + * This is applicable for any type that is nullable. + */ +object ExplicitNullValueWeight : ConfigKey(.1, WeightValidator) + +/** The range of lengths of generated GraphQL list values */ +object ListValueSize : ConfigKey(0..3, IntRangeValidator(0..Int.MAX_VALUE)) + +/** + * The probability that a value literal in a schema, when allowed, will require coercion to match the type at its location. + * + * Examples of allowed uncoerced values include: + * - ID-typed values may be provided as Int literals + * - Float-typed values may be provided as Int literals + * - Some List-typed values may be provided as the unwrapped list type + */ +object SchemaUncoercedValueWeight : ConfigKey(.25, WeightValidator) + +/** + * The probability that a value literal in a document, when allowed, will require coercion to match the type at its location. + * + * Examples of allowed uncoerced values include: + * - ID-typed values may be provided as Int literals + * - Float-typed values may be provided as Int literals + * - Some List-typed values may be provided as the unwrapped list type + */ +object DocumentUncoercedValueWeight : ConfigKey(.25, WeightValidator) + +/** + * The approximate maximum depth of attempted value generation. When generating + * values past this depth, the value generator will return null or empty values + * when possible. + */ +object MaxValueDepth : ConfigKey(3, IntValidator(0..Int.MAX_VALUE)) + +/** The range of lengths of generated GraphQL string values */ +object StringValueSize : ConfigKey(0..3, IntRangeValidator(0..Int.MAX_VALUE)) + +/** + * If enabled, all interface definitions will be guaranteed to have at least one + * implementing type in the schema. + * + * This can be useful for systems that want to require that a value can be produced + * for every output type in a schema. + */ +object GenInterfaceStubsIfNeeded : ConfigKey(false, Unvalidated) + +/** + * Reject the configured names from being used in the generated schema as + * input fields, output fields, argument names, enum values, and other field-ish + * contexts. + * + * This can be useful for ensuring that a schema is compatible with code generators + * that may not use language keywords as identifiers. + */ +object BanFieldNames : ConfigKey>(emptySet(), Unvalidated) + +/** ban the configured directives from generated schemas and documents */ +object BanDirectiveNames : ConfigKey>(emptySet(), Unvalidated) + +/** + * Use the provided mappings for generating scalar value literals, + * on top of the generators for builtin GraphQL scalar types. + */ +object ScalarLiteralOverrides : ConfigKey>>>( + emptyMap(), + Unvalidated +) + +/** + * Use the provided mappings for generating external scalar values, + * on top of the generators for builtin GraphQL scalar types. + */ +object ScalarValueOverrides : ConfigKey>>(emptyMap(), 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), + CompoundingWeightValidator +) + +/** probability that a selection set will contain an inline fragment */ +object InlineFragmentWeight : ConfigKey( + CompoundingWeight(.4, 3), + CompoundingWeightValidator +) + +/** probability that a selection set will spread a named fragment */ +object FragmentSpreadWeight : ConfigKey( + CompoundingWeight(.4, 3), + CompoundingWeightValidator +) + +/** probability that a fragment spread will spread a new fragment definition, rather than an existing one */ +object FragmentDefinitionWeight : ConfigKey(.2, WeightValidator) + +/** probability that, where possible, an inline fragment will have no type condition */ +object UntypedInlineFragmentWeight : ConfigKey(.2, WeightValidator) + +/** + * The maximum depth of a selection set. + * Each inline fragment, fragment spread, and field subselections will increment depth by 1 + */ +object MaxSelectionSetDepth : ConfigKey(10, IntValidator(0..100)) + +/** probability that any given selection will be aliased */ +object AliasWeight : ConfigKey(.2, WeightValidator) + +/** The range of how many operation definitions may be generated in a Document */ +object OperationCount : ConfigKey(1..2, IntRangeValidator(1..Int.MAX_VALUE)) + +/** + * Where possible, the probability that an operation definition will not have a name, + * or that an ExecutionInput will omit an operation name. + */ +object AnonymousOperationWeight : ConfigKey(.5, WeightValidator) + +/** + * probability that any given input value or part of an input value will be + * replaced by a variable. + */ +object VariableWeight : ConfigKey(.3, WeightValidator) + +/** probability that a graphql-java DataFetcher will return null for a non-nullable field */ +object NullNonNullableWeight : ConfigKey(0.0, WeightValidator) + +/** + * probability that a graphql-java DataFetcher or TypeResolver will throw a + * RuntimeException during execution. + */ +object ResolverExceptionWeight : ConfigKey(0.0, WeightValidator) + +/** + * The likelihood that when generating a concrete value for an abstract type, that + * the generator will pick a type that is selected in the selection set rather than + * any possible implementing type. + */ +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/GraphQLInputValueGenerator.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLInputValueGenerator.kt new file mode 100644 index 000000000..35834f1f3 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLInputValueGenerator.kt @@ -0,0 +1,198 @@ +package graphql.schema.property + +import graphql.language.ArrayValue +import graphql.language.BooleanValue +import graphql.language.EnumValue +import graphql.language.FloatValue +import graphql.language.IntValue +import graphql.language.NullValue +import graphql.language.ObjectField +import graphql.language.ObjectValue +import graphql.language.StringValue +import graphql.language.Value +import graphql.schema.GraphQLEnumType +import graphql.schema.GraphQLInputObjectField +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +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.int +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.of +import io.kotest.property.arbitrary.string +import java.math.BigDecimal +import java.math.BigInteger + +/** + * 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. + */ +internal class GraphQLInputValueGenerator( + schema: GraphQLSchema, + private val config: Config, + private val randomSource: RandomSource, + private val uncoercedValueWeight: Double = 0.0, + 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 fun generate(context: ValueGenerationContext): Value<*> { + val type = context.type + if (type is GraphQLNonNull) { + return generate(context.unwrap(type.wrappedType as GraphQLInputType, nullable = false)) + } + if (context.nullable && shouldGenerateNull(context)) { + 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 GraphQLInputObjectType && type.isOneOf) { + return generateOneOf(type, context) + } + if (type is GraphQLInputObjectType) return generateInputObject(type, context) + throw UnsupportedOperationException("Unsupported input type: $type") + } + + private fun shouldGenerateNull(context: ValueGenerationContext): Boolean = + context.overBudget || randomSource.sampleWeight(config[ExplicitNullValueWeight]) + + private fun generateList( + listType: GraphQLList, + context: ValueGenerationContext + ): Value<*> { + val wrappedType = listType.wrappedType as GraphQLInputType + val itemContext = context.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)) + } + val size = if (itemContext.overBudget) 0 else Arb.int(config[ListValueSize]).next(randomSource) + val values = List(size) { generate(itemContext) } + return ArrayValue.newArrayValue().values(values).build() + } + + private fun generateScalar(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() + } + } + + private fun generateFloat(): Value<*> { + if (randomSource.sampleWeight(uncoercedValueWeight)) { + return IntValue.newIntValue(BigInteger.valueOf(randomSource.random.nextInt().toLong())).build() + } + val value = randomSource.random.nextDouble(-1_000_000.0, 1_000_000.0) + return FloatValue.newFloatValue(BigDecimal.valueOf(value)).build() + } + + private fun generateId(): Value<*> { + if (randomSource.sampleWeight(uncoercedValueWeight)) { + return IntValue.newIntValue(BigInteger.valueOf(randomSource.random.nextInt().toLong())).build() + } + return generateString() + } + + private fun generateString(): StringValue = + StringValue.newStringValue(Arb.string(config[StringValueSize]).next(randomSource)).build() + + private fun generateEnum(type: GraphQLEnumType): EnumValue { + val enumValue = Arb.of(type.values).next(randomSource) + return EnumValue.newEnumValue(enumValue.name).build() + } + + private fun generateOneOf( + type: GraphQLInputObjectType, + context: ValueGenerationContext + ): ObjectValue { + val candidates = oneOfCandidates(type, context) + val field = Arb.of(candidates).next(randomSource) + val value = generate(context.push(field.type, nullable = false)) + return objectValue(field.name, value) + } + + private fun oneOfCandidates( + type: GraphQLInputObjectType, + context: ValueGenerationContext + ): List { + if (!context.overBudget) return type.fields + val cycleGroup = mandatoryEdgesGraph[type.name] + val exits = type.fields.filter { field -> + val unwrapped = GraphQLTypeUtil.unwrapAll(field.type) + unwrapped !is GraphQLInputObjectType || + GraphQLTypeUtil.unwrapNonNull(field.type) is GraphQLList || + unwrapped.name !in cycleGroup + } + return exits.ifEmpty { type.fields } + } + + private fun generateInputObject( + type: GraphQLInputObjectType, + context: ValueGenerationContext + ): ObjectValue { + val cycleGroup = allEdgesGraph[type.name] + val fields = type.fields.filter { field -> + shouldIncludeField(field, cycleGroup, context) + }.map { field -> + ObjectField.newObjectField() + .name(field.name) + .value(generate(context.push(field.type))) + .build() + } + return ObjectValue.newObjectValue().objectFields(fields).build() + } + + private fun shouldIncludeField( + field: GraphQLInputObjectField, + cycleGroup: Set, + context: ValueGenerationContext + ): 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]) + } + + private fun objectValue(name: String, value: Value<*>): ObjectValue = + ObjectValue.newObjectValue() + .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/GraphQLNames.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLNames.kt new file mode 100644 index 000000000..5d22737d3 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLNames.kt @@ -0,0 +1,167 @@ +package graphql.schema.property + +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> +) { + operator fun plus(other: GraphQLNames): GraphQLNames = + GraphQLNames( + TypeType + .values() + .associateWith { nt -> ((names[nt] ?: emptySet()) + (other.names[nt] ?: emptySet())) } + ) + + val interfaces: Set + get() = names[TypeType.Interface] ?: emptySet() + + val objects: Set + get() = names[TypeType.Object] ?: emptySet() + + val inputs: Set + get() = names[TypeType.Input] ?: emptySet() + + val unions: Set + get() = names[TypeType.Union] ?: emptySet() + + val scalars: Set + get() = names[TypeType.Scalar] ?: emptySet() + + val enums: Set + get() = names[TypeType.Enum] ?: emptySet() + + val directives: Set + get() = names[TypeType.Directive] ?: emptySet() + + val allNames: Set + get() = names.values.flatten().toSet() + + fun filter(fn: (String) -> Boolean): GraphQLNames { + val filtered = names.mapValues { (_, values) -> values.filter(fn).toSet() } + if (filtered.values.all { it.isEmpty() }) return empty + return GraphQLNames(filtered) + } + + override fun equals(other: Any?): Boolean = (other as? GraphQLNames)?.names == names + + override fun hashCode(): Int = names.hashCode() + + override fun toString(): String = names.toString() + + companion object { + val empty: GraphQLNames = GraphQLNames(emptyMap()) + + private fun List.prefix(nameType: TypeType): List = + this.map { + buildString { + append(nameType.name) + append('_') + append(it) + } + } + + /** + * Generate a GraphQLNames from a pool of raw names, like ["Foo", "Bar"] + * The returned GraphQLNames will transform the names to include a prefix, like + * ["Object_Foo", "Scalar_Bar"] + */ + fun fromRawNames( + names: List, + cfg: Config = Config.default + ): GraphQLNames { + tailrec fun loop( + acc: Map>, + pool: List, + typeTypeCounts: List> + ): GraphQLNames = + if (typeTypeCounts.isNotEmpty()) { + val (tt, count) = typeTypeCounts.first() + val entry = tt to pool.take(count).prefix(tt).toSet() + loop( + acc = acc + entry, + pool = pool.drop(count), + typeTypeCounts = typeTypeCounts.drop(1), + ) + } else { + GraphQLNames(acc) + } + + val typeTypes = + TypeType.values().let { tts -> + tts.filter { + when (it) { + TypeType.Scalar -> cfg[GenCustomScalars] + else -> true + } + } + } + val typeTypeCounts = typeTypes + .associateWith { tt -> + cfg[TypeTypeWeights][tt] ?: 1.0 + }.let { ttWeights -> + // normalize + val total = ttWeights.values.sum() + ttWeights.mapValues { (_, weight) -> + val normWeight = weight / total + (names.size * normWeight).toInt() + } + } + return loop( + acc = emptyMap(), + pool = names, + typeTypeCounts = typeTypeCounts.toList() + ) + } + } +} + +/** + * Generate a [GraphQLNames] from a provided Config + * The generated GraphQLNames will have a length close to the value of [SchemaSize] + */ +fun Arb.Companion.graphQLNames(cfg: Config = Config.default): Arb = + Arb + .set( + Arb.graphQLName(cfg[TypeNameLength]), + cfg[SchemaSize] + ).map { + GraphQLNames.fromRawNames(it.toList(), cfg) + }.withEdgecases(GraphQLNames.empty) + .map { names -> + if (cfg[IncludeBuiltinScalars]) { + names + GraphQLNames(mapOf(TypeType.Scalar to builtinScalars.keys)) + } else { + names + } + }.map { names -> + if (cfg[IncludeBuiltinDirectives]) { + names + GraphQLNames(mapOf(TypeType.Directive to builtinDirectives.keys)) + } else { + names + } + }.map { names -> + val ban = cfg[BanDirectiveNames] + if (ban.isNotEmpty()) { + val filteredDirNames = names.names[TypeType.Directive] + ?.filter { !ban.contains(it) } + ?.toSet() + ?: emptySet() + GraphQLNames(names.names + (TypeType.Directive to filteredDirNames)) + } else { + names + } + }.map { names -> + val extant = cfg[IncludeTypes].names + if (extant.isNotEmpty()) { + names.filter { !extant.contains(it) } + } else { + names + } + } + +fun Arb.Companion.graphQLNames(cfg: Arb): Arb = cfg.flatMap(::graphQLNames) diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLOutputValues.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLOutputValues.kt new file mode 100644 index 000000000..a592e25fb --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLOutputValues.kt @@ -0,0 +1,244 @@ +package graphql.schema.property + +import graphql.language.Document +import graphql.language.Field +import graphql.language.FragmentDefinition +import graphql.language.FragmentSpread +import graphql.language.InlineFragment +import graphql.language.OperationDefinition +import graphql.language.Selection +import graphql.language.SelectionSet +import graphql.schema.GraphQLCompositeType +import graphql.schema.GraphQLEnumType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +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.of +import io.kotest.property.arbitrary.string + +/** A generated GraphQL object result and the concrete type represented by that result. */ +data class GeneratedObjectValue( + val typeName: String, + val fields: Map +) + +/** + * Generate a native result value for [type] containing exactly the requested [selections]. + * + * Supported configuration keys are [ExplicitNullValueWeight], [ListValueSize], + * [ScalarValueOverrides], [SelectedTypeBias], and [StringValueSize]. + */ +fun Arb.Companion.graphQLOutputValue( + schema: GraphQLSchema, + type: GraphQLOutputType, + selections: SelectionSet?, + fragments: Map = emptyMap(), + config: Config = Config.default +): Arb = + arbitrary { randomSource -> + GraphQLOutputValueGenerator(schema, config, randomSource) + .generate(type, selections, fragments) + } + +/** Generate a native result value for one operation in [document]. */ +fun Arb.Companion.graphQLOutputValue( + schema: GraphQLSchema, + document: Document, + config: Config = Config.default +): Arb = + arbitrary { randomSource -> + val fragments = document.getDefinitionsOfType(FragmentDefinition::class.java) + .associateBy { requireNotNull(it.name) } + val operations = document.getDefinitionsOfType(OperationDefinition::class.java) + 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 + } + +private class GraphQLOutputValueGenerator( + private val schema: GraphQLSchema, + private val config: Config, + private val randomSource: RandomSource +) { + private val relationships = SchemaRelationships(schema) + + fun generate( + type: GraphQLOutputType, + selections: SelectionSet?, + fragments: Map + ): Any? = generate(type, selections, fragments, nullable = true) + + private fun generate( + type: GraphQLOutputType, + selections: SelectionSet?, + fragments: Map, + nullable: Boolean + ): Any? { + if (type is GraphQLNonNull) { + return generate(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 GraphQLCompositeType) { + val concreteType = concretize(type, requireNotNull(selections), fragments) + return generateObject(concreteType, selections, fragments) + } + if (type is GraphQLEnumType) return Arb.of(type.values).next(randomSource).value + if (type is GraphQLScalarType) return generateScalar(type) + throw UnsupportedOperationException("Unsupported output type: $type") + } + + private fun generateList( + 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) } + } + + private fun generateObject( + type: GraphQLObjectType, + selections: SelectionSet, + fragments: Map + ): GeneratedObjectValue { + val fields = linkedMapOf() + selections.selections.forEach { selection -> + fields.putAll(generateSelection(type, selection, fragments)) + } + return GeneratedObjectValue(type.name, fields) + } + + private fun generateSelection( + type: GraphQLObjectType, + selection: Selection<*>, + fragments: Map + ): Map = + when (selection) { + is Field -> generateField(type, selection, fragments) + is FragmentSpread -> generateFragment(type, fragments.requireFragment(selection.name), fragments) + is InlineFragment -> generateInlineFragment(type, selection, fragments) + else -> throw IllegalArgumentException("Unexpected selection type: $selection") + } + + private fun generateField( + type: GraphQLObjectType, + field: Field, + fragments: Map + ): Map { + if (field.name == "__typename") return mapOf(field.resultKey to type.name) + val definition = requireNotNull(type.getFieldDefinition(field.name)) { + "Unexpected field: ${type.name}.${field.name}" + } + val value = generate(definition.type, field.selectionSet, fragments, nullable = true) + return mapOf(field.resultKey to value) + } + + private fun generateFragment( + type: GraphQLObjectType, + fragment: FragmentDefinition, + 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 + } + + private fun generateInlineFragment( + type: GraphQLObjectType, + fragment: InlineFragment, + 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 + } + + private fun concretize( + type: GraphQLCompositeType, + selections: SelectionSet, + fragments: Map + ): GraphQLObjectType { + if (type is GraphQLObjectType) return type + val possibleTypes = relationships.possibleObjectTypes(type) + require(possibleTypes.isNotEmpty()) { + "Cannot generate a value for abstract type `${type.name}`: no implementations found" + } + if (randomSource.sampleWeight(config[SelectedTypeBias])) { + val selectedTypes = selectedObjectTypes(selections, fragments).intersect(possibleTypes) + if (selectedTypes.isNotEmpty()) return Arb.of(selectedTypes.toList()).next(randomSource) + } + return Arb.of(possibleTypes.toList()).next(randomSource) + } + + private fun selectedObjectTypes( + selections: SelectionSet, + fragments: Map + ): Set { + val selectedTypes = linkedSetOf() + val pending = ArrayDeque>() + pending.addAll(selections.selections) + while (pending.isNotEmpty()) { + when (val selection = pending.removeFirst()) { + is Field -> Unit + is InlineFragment -> { + val selectedType = selection.typeCondition?.name + ?.let(schema::requireCompositeType) as? GraphQLObjectType + selectedType?.let(selectedTypes::add) + pending.addAll(selection.selectionSet.selections) + } + is FragmentSpread -> { + val fragment = fragments.requireFragment(selection.name) + val selectedType = schema.requireCompositeType(requireNotNull(fragment.typeCondition.name)) + if (selectedType is GraphQLObjectType) selectedTypes.add(selectedType) + pending.addAll(fragment.selectionSet.selections) + } + } + } + return selectedTypes + } + + private fun generateScalar(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) + "String" -> Arb.string(config[StringValueSize]).next(randomSource) + else -> throw UnsupportedOperationException( + "No external value generator configured for scalar `${type.name}`" + ) + } + } +} + +private fun Map.requireFragment(name: String): FragmentDefinition = + requireNotNull(this[name]) { "Missing fragment `$name`" } + +private fun GraphQLSchema.requireCompositeType(name: String): GraphQLCompositeType = + requireNotNull(getType(name) as? GraphQLCompositeType) { "Missing composite type `$name`" } + +private fun OperationDefinition.rootType(schema: GraphQLSchema): GraphQLObjectType = + when (operation) { + OperationDefinition.Operation.QUERY -> schema.queryType + OperationDefinition.Operation.MUTATION -> requireNotNull(schema.mutationType) { + "Mutation operation requested but schema does not define a mutation type" + } + OperationDefinition.Operation.SUBSCRIPTION -> requireNotNull(schema.subscriptionType) { + "Subscription operation requested but schema does not define a subscription type" + } + } diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt new file mode 100644 index 000000000..af219792e --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt @@ -0,0 +1,169 @@ +package graphql.schema.property + +import graphql.GraphQL +import graphql.TypeResolutionEnvironment +import graphql.execution.MergedField +import graphql.execution.ResultPath +import graphql.language.Field +import graphql.language.SelectionSet +import graphql.schema.DataFetchingEnvironment +import graphql.schema.DataFetcher +import graphql.schema.FieldCoordinates +import graphql.schema.GraphQLCodeRegistry +import graphql.schema.GraphQLCompositeType +import graphql.schema.GraphQLEnumType +import graphql.schema.GraphQLInterfaceType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLOutputType +import graphql.schema.GraphQLScalarType +import graphql.schema.GraphQLSchema +import graphql.schema.GraphQLTypeUtil +import graphql.schema.GraphQLUnionType +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.of +import io.kotest.property.arbitrary.string + +/** + * Create a code registry that serves arbitrary data for [schema]. + * + * 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]. + */ +fun arbitraryCodeRegistry( + schema: GraphQLSchema, + seed: Long, + config: Config = Config.default +): GraphQLCodeRegistry = ArbitraryCodeRegistryGenerator(schema, seed, config).generate() + +/** Create an executable [GraphQL] instance backed by deterministic arbitrary data. */ +fun arbitraryGraphQL( + schema: GraphQLSchema, + seed: Long, + config: Config = Config.default +): GraphQL { + val executableSchema = schema.transform { builder -> + builder.codeRegistry(arbitraryCodeRegistry(schema, seed, config)) + } + return GraphQL.newGraphQL(executableSchema).build() +} + +private class ArbitraryCodeRegistryGenerator( + private val schema: GraphQLSchema, + private val seed: Long, + private val config: Config +) { + private val relationships = SchemaRelationships(schema) + + fun generate(): GraphQLCodeRegistry { + val builder = GraphQLCodeRegistry.newCodeRegistry(schema.codeRegistry) + schema.allTypesAsList.forEach { type -> + if (type.name.startsWith("__")) return@forEach + when (type) { + is GraphQLObjectType -> registerDataFetchers(builder, type) + is GraphQLInterfaceType -> registerTypeResolver(builder, type) + is GraphQLUnionType -> registerTypeResolver(builder, type) + } + } + return builder.build() + } + + private fun registerDataFetchers( + builder: GraphQLCodeRegistry.Builder, + type: GraphQLObjectType + ) { + type.fieldDefinitions.forEach { field -> + val coordinates = FieldCoordinates.coordinates(type.name, field.name) + builder.dataFetcher(coordinates, DataFetcher { environment: DataFetchingEnvironment -> + val randomSource = saltedRandom(environment.stableHash) + maybeThrowResolverException(randomSource) + generateValue(environment.fieldType, randomSource) + }) + } + } + + private fun registerTypeResolver( + builder: GraphQLCodeRegistry.Builder, + type: GraphQLCompositeType + ) { + builder.typeResolver(type.name) { environment -> + val randomSource = saltedRandom(environment.stableHash) + maybeThrowResolverException(randomSource) + val possibleTypes = relationships.possibleObjectTypes(type).toList() + val selectedType = Arb.of(possibleTypes).next(randomSource) + environment.schema.getObjectType(selectedType.name) + } + } + + private fun maybeThrowResolverException(randomSource: RandomSource) { + if (randomSource.sampleWeight(config[ResolverExceptionWeight])) { + throw ResolverInjectedException() + } + } + + private fun generateValue(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 GraphQLEnumType -> Arb.of(unwrappedType.values).next(randomSource).value + else -> emptyMap() + } + } + + private fun generateList(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) } + } + + private fun generateScalar(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) + "String" -> Arb.string(config[StringValueSize]).next(randomSource) + else -> throw UnsupportedOperationException( + "No external value generator configured for scalar `${type.name}`" + ) + } + } + + private fun saltedRandom(salt: Long): RandomSource = RandomSource.seeded(seed xor salt) +} + +private val MergedField.stableHash: Long + get() = singleField.stableHash + +private val Field.stableHash: Long + get() = resultKey.hashCode().toLong() + +private val SelectionSet.stableHash: Long + get() = selections.filterIsInstance().fold(0L) { hash, field -> hash xor field.stableHash } + +private val ResultPath.stableHash: Long + get() = toString().hashCode().toLong() + +private val DataFetchingEnvironment.stableHash: Long + get() = arguments.hashCode().toLong() xor + (field.selectionSet?.stableHash ?: 0L) xor + executionStepInfo.path.stableHash + +private val TypeResolutionEnvironment.stableHash: Long + get() = arguments.hashCode().toLong() xor field.stableHash + +/** 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-property-testing/src/main/kotlin/graphql/schema/property/GraphQLSchemas.kt new file mode 100644 index 000000000..cdc17f8db --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLSchemas.kt @@ -0,0 +1,610 @@ +package graphql.schema.property + +import graphql.Directives +import graphql.Scalars +import graphql.introspection.Introspection.DirectiveLocation +import graphql.language.Value +import graphql.schema.GraphQLAppliedDirective +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLCodeRegistry +import graphql.schema.GraphQLDirective +import graphql.schema.GraphQLEnumType +import graphql.schema.GraphQLEnumValueDefinition +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLInputObjectField +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLInterfaceType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNamedType +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLSchema +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 +import graphql.util.TraverserVisitorStub +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 io.kotest.property.arbitrary.of + +/** Generate arbitrary [GraphQLSchema]s from a static [Config] */ +fun Arb.Companion.graphQLSchema(cfg: Config = Config.default): Arb = + Arb.graphQLTypes(cfg).flatMap { types -> + graphQLSchema(types, cfg) + } + +/** Generate arbitrary [GraphQLSchema]s from a [GraphQLTypes] */ +@JvmName("arbGraphQLSchema") +fun Arb.Companion.graphQLSchema( + types: GraphQLTypes, + cfg: Config = Config.default +): Arb = + arbitrary { rs -> + SchemaGenerator(cfg, rs).createSchema(types) + } + +/** Methods for generating GraphQLSchema instances */ +internal class SchemaGenerator(val cfg: Config, val rs: RandomSource) { + private val emptyQuery: GraphQLObjectType = + GraphQLObjectType + .newObject() + .name("EmptyQuery") + .field( + GraphQLFieldDefinition + .newFieldDefinition() + .name("placeholder") + .type(Scalars.GraphQLInt) + .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 { + val queryType = types.objects.entries.firstOrNull()?.value ?: emptyQuery + return GraphQLSchema + .newSchema() + .query(queryType) + .additionalTypes(types.interfaces.values.toSet()) + .additionalTypes((types.objects.values.toSet() - queryType)) + .additionalTypes(types.inputs.values.toSet()) + .additionalTypes(types.unions.values.toSet()) + .additionalTypes(types.scalars.values.toSet()) + .additionalTypes(types.enums.values.toSet()) + .additionalDirectives(types.directives.values) + .codeRegistry(codeRegistry(types)) + .build() + .let(::finalize) + } + + fun createSchema(sdl: String): GraphQLSchema { + val tdr = SchemaParser().parse(sdl) + return FastSchemaGenerator() + .makeExecutableSchema(tdr, RuntimeWiring.MOCKED_WIRING) + } + + fun finalize(schema: GraphQLSchema): GraphQLSchema { + val withDefaults = addDefaults(schema) + val valueGenerator = inputValueGenerator(withDefaults) + return SchemaTransformer.transformSchema( + withDefaults, + AddAppliedDirectives(withDefaults, valueGenerator, cfg, rs) + ) + } + + private fun addDefaults(schema: GraphQLSchema): GraphQLSchema { + if (cfg[DefaultValueWeight] == 0.0) return schema + val valueGenerator = inputValueGenerator(schema) + return SchemaTransformer.transformSchema(schema, AddDefaults(valueGenerator, cfg, rs)) + } + + private fun inputValueGenerator(schema: GraphQLSchema): GraphQLInputValueGenerator = + GraphQLInputValueGenerator( + schema = schema, + config = cfg, + randomSource = rs, + uncoercedValueWeight = cfg[SchemaUncoercedValueWeight], + allEdgesGraph = CycleGroups.allInputCycles(schema), + mandatoryEdgesGraph = CycleGroups.mandatoryInputCycles(schema) + ) +} + +/** Adds generated literal defaults to arguments and non-oneOf input fields. */ +internal class AddDefaults( + private val inputValueGenerator: GraphQLInputValueGenerator, + private val cfg: Config, + private val rs: RandomSource +) : GraphQLTypeVisitorStub() { + // don't traverse into built-in directives + override fun visitGraphQLDirective( + node: GraphQLDirective, + context: TraverserContext + ): TraversalControl = + if (node.name in builtinDirectives) { + TraversalControl.ABORT + } else { + TraversalControl.CONTINUE + } + + override fun visitGraphQLInputObjectField( + field: GraphQLInputObjectField, + context: TraverserContext + ): TraversalControl { + // don't add default values for one of fields + val parent = context.parentNode + if (parent is GraphQLInputObjectType && parent.isOneOf) { + return TraversalControl.CONTINUE + } + + if (rs.sampleWeight(cfg[DefaultValueWeight])) { + changeNode( + context, + field.transform { + it.defaultValueLiteral(genDefaultValue(field.type)) + } + ) + } + + return TraversalControl.CONTINUE + } + + override fun visitGraphQLArgument( + arg: GraphQLArgument, + context: TraverserContext + ): TraversalControl { + if (rs.sampleWeight(cfg[DefaultValueWeight])) { + changeNode( + context, + arg.transform { + it.defaultValueLiteral(genDefaultValue(arg.type)) + } + ) + } + + return TraversalControl.CONTINUE + } + + private fun genDefaultValue(type: GraphQLInputType): Value<*> { + return inputValueGenerator.generate(type) + } +} + +private data class DirectiveDependencyInfo( + val directives: Set, + val referencedInputLikeTypes: Set +) + +/** Adds schema-valid directive applications while avoiding directive dependency cycles. */ +internal class AddAppliedDirectives( + private val schema: GraphQLSchema, + private val inputValueGenerator: GraphQLInputValueGenerator, + private val cfg: Config, + private val rs: RandomSource +) : GraphQLTypeVisitorStub() { + private val directivesByLocation = + schema.directives.fold(emptyMap>()) { acc, dir -> + if (dir.name in cfg[BanDirectiveNames]) { + acc + } else if (dir.name == Directives.OneOfDirective.name) { + // @oneOf is applied during type generation, we can skip applying it here + acc + } else { + dir.validLocations().fold(acc) { innerAcc, loc -> + val newDirs = (innerAcc[loc] ?: emptySet()) + dir + innerAcc + (loc to newDirs) + } + } + } + + private val directivesByName = + schema.directives + .filter { it.name !in builtinDirectives && it.name !in cfg[BanDirectiveNames] } + .associateBy { it.name } + + private val directiveDependencyInfoByName = + directivesByName.mapValues { (_, directive) -> + buildDirectiveDependencyInfo(directive) + } + + private val directivesByReferencedInputLikeType: Map> = + directiveDependencyInfoByName.entries + .fold(mutableMapOf>()) { acc, (directiveName, info) -> + info.referencedInputLikeTypes.forEach { typeName -> + acc.getOrPut(typeName) { mutableSetOf() }.add(directiveName) + } + acc + } + + private val directiveDependencies: MutableMap> = + directiveDependencyInfoByName.mapValuesTo(mutableMapOf()) { (_, info) -> + info.directives.toMutableSet() + } + + // don't traverse into built-in directives + override fun visitGraphQLDirective( + node: GraphQLDirective, + context: TraverserContext + ): TraversalControl = + if (node.name in builtinDirectives) { + TraversalControl.ABORT + } else { + TraversalControl.CONTINUE + } + + override fun visitGraphQLArgument( + node: GraphQLArgument, + context: TraverserContext + ): TraversalControl = + replaceAppliedDirectives( + context, + DirectiveLocation.ARGUMENT_DEFINITION, + // non-nullable arguments may not be deprecated + allowDeprecated = node.type !is GraphQLNonNull + ) { dirs -> + node.transform { + it.replaceAppliedDirectives(dirs) + } + } + + override fun visitGraphQLObjectType( + node: GraphQLObjectType, + context: TraverserContext + ): TraversalControl = + replaceAppliedDirectives(context, DirectiveLocation.OBJECT) { dirs -> + node.transform { + it.replaceAppliedDirectives(dirs) + } + } + + override fun visitGraphQLFieldDefinition( + node: GraphQLFieldDefinition, + context: TraverserContext + ): TraversalControl = + replaceAppliedDirectives(context, DirectiveLocation.FIELD_DEFINITION) { dirs -> + node.transform { + it.replaceAppliedDirectives(dirs) + } + } + + override fun visitGraphQLInterfaceType( + node: GraphQLInterfaceType, + context: TraverserContext + ): TraversalControl = + replaceAppliedDirectives(context, DirectiveLocation.INTERFACE) { dirs -> + node.transform { + it.replaceAppliedDirectives(dirs) + } + } + + override fun visitGraphQLUnionType( + node: GraphQLUnionType, + context: TraverserContext + ): TraversalControl = + replaceAppliedDirectives(context, DirectiveLocation.UNION) { dirs -> + node.transform { + it.replaceAppliedDirectives(dirs) + } + } + + override fun visitGraphQLEnumType( + node: GraphQLEnumType, + context: TraverserContext + ): TraversalControl = + replaceAppliedDirectives(context, DirectiveLocation.ENUM) { dirs -> + node.transform { + it.replaceAppliedDirectives(dirs) + } + } + + override fun visitGraphQLEnumValueDefinition( + node: GraphQLEnumValueDefinition, + context: TraverserContext + ): TraversalControl = + replaceAppliedDirectives(context, DirectiveLocation.ENUM_VALUE) { dirs -> + node.transform { + it.replaceAppliedDirectives(dirs) + } + } + + override fun visitGraphQLInputObjectType( + node: GraphQLInputObjectType, + context: TraverserContext + ): TraversalControl = + replaceAppliedDirectives(context, DirectiveLocation.INPUT_OBJECT) { dirs -> + node.transform { + it.replaceAppliedDirectives(dirs) + } + } + + override fun visitGraphQLInputObjectField( + node: GraphQLInputObjectField, + context: TraverserContext + ): TraversalControl = + replaceAppliedDirectives( + context, + DirectiveLocation.INPUT_FIELD_DEFINITION, + // non-nullable input fields may not be deprecated + allowDeprecated = node.type !is GraphQLNonNull + ) { dirs -> + node.transform { + it.replaceAppliedDirectives(dirs) + } + } + + private fun replaceAppliedDirectives( + ctx: TraverserContext, + loc: DirectiveLocation, + allowDeprecated: Boolean = true, + doReplace: (dirs: List) -> GraphQLSchemaElement + ): TraversalControl = + ctx.unlessIntrospection { + val ownerDirectives = ctx.ownerDirectiveNames() + val dirs = genAppliedDirectives( + loc = loc, + traversedDirectives = ctx.collectTraversedDirectives(), + allowDeprecated = allowDeprecated, + ownerDirectives = ownerDirectives + ) + if (dirs.isEmpty()) { + TraversalControl.CONTINUE + } else { + if (ownerDirectives.isNotEmpty()) { + val newDependencies = dirs.map { it.name } + ownerDirectives.forEach { ownerDirective -> + directiveDependencies + .getOrPut(ownerDirective) { mutableSetOf() } + .addAll(newDependencies) + } + } + changeNode(ctx, doReplace(dirs)) + } + } + + private fun genAppliedDirectives( + loc: DirectiveLocation, + traversedDirectives: Set, + allowDeprecated: Boolean, + ownerDirectives: Set + ): List { + tailrec fun loop( + acc: List, + pool: Set + ): List = + if (pool.isNotEmpty() && acc.size != cfg[AppliedDirectiveWeight].max && rs.sampleWeight(cfg[AppliedDirectiveWeight].weight)) { + val dir = Arb.of(pool).next(rs) + val applied = dir + .toAppliedDirective() + .transform { + val args = dir.arguments.map { arg -> + arg.toAppliedArgument().transform { b -> + b.valueLiteral(inputValueGenerator.generate(arg.type)) + // to placate graphql-java's type-consistency checker, set the type to a GraphQLTypeReference + b.type(arg.type.asReference) + } + } + it.replaceArguments(args) + } + loop( + acc = acc + applied, + pool = if (dir.isRepeatable) pool else (pool - dir) + ) + } else { + acc + } + + val pool = candidateDirectives(loc, traversedDirectives, allowDeprecated, ownerDirectives) + return loop(emptyList(), pool.toSet()) + } + + private fun candidateDirectives( + loc: DirectiveLocation, + traversedDirectives: Set, + allowDeprecated: Boolean, + ownerDirectives: Set + ): Set = + (directivesByLocation[loc] ?: emptySet()) + .let { pool -> + // some locations don't allow application of @deprecated. + // Remove it from the pool if necessary + if (!allowDeprecated) { + pool.filterNot { it.name == Directives.DeprecatedDirective.name }.toSet() + } else { + pool + } + } + .let { pool -> + // In order to prevent cycles of directive application, remove any directives that + // have already been applied in our traversal path. + if (traversedDirectives.isNotEmpty()) { + pool.filter { it.name !in traversedDirectives }.toSet() + } else { + pool + } + }.let { pool -> + // Some input and enum nodes are shared across multiple directive definitions. + // Remove any directive that would make any owning directive depend on itself. + // This keeps the generated directive dependency graph acyclic. + if (ownerDirectives.isNotEmpty()) { + pool.filterNot { candidate -> + ownerDirectives.any { ownerDirective -> + wouldCreateDirectiveCycle(ownerDirective, candidate.name) + } + }.toSet() + } else { + pool + } + } + + private fun wouldCreateDirectiveCycle( + hostDirective: String, + candidateDirective: String + ): Boolean { + if (hostDirective == candidateDirective) { + return true + } + + val seen = mutableSetOf() + + fun canReach(current: String): Boolean { + if (!seen.add(current)) { + return false + } + if (current == hostDirective) { + return true + } + return directiveDependencies[current]?.any(::canReach) == true + } + + return canReach(candidateDirective) + } + + private fun buildDirectiveDependencyInfo(directive: GraphQLDirective): DirectiveDependencyInfo { + val dependencies = mutableSetOf() + val traversedDirectiveDefs = mutableSetOf() + val referencedInputLikeTypes = mutableSetOf() + val traversedInputLikeTypes = mutableSetOf() + val traverser = + Traverser.depthFirstWithNamedChildren( + { element -> + dependencyChildren( + element = element, + dependencies = dependencies, + referencedInputLikeTypes = referencedInputLikeTypes, + traversedDirectiveDefs = traversedDirectiveDefs, + traversedInputLikeTypes = traversedInputLikeTypes + ) + }, + null, + null + ) + val visitor = object : TraverserVisitorStub() {} + + directive.arguments.forEach { arg -> + traverser.traverse(arg, visitor) + } + + return DirectiveDependencyInfo( + directives = dependencies, + referencedInputLikeTypes = referencedInputLikeTypes + ) + } + + private fun dependencyChildren( + element: GraphQLSchemaElement, + dependencies: MutableSet, + referencedInputLikeTypes: MutableSet, + traversedDirectiveDefs: MutableSet, + traversedInputLikeTypes: MutableSet + ): Map> = + when (element) { + is GraphQLAppliedDirective -> { + dependencies.add(element.name) + val directiveDef = directivesByName[element.name] + if (directiveDef != null && traversedDirectiveDefs.add(directiveDef.name)) { + mapOf("directiveDefinition" to listOf(directiveDef)) + } else { + emptyMap() + } + } + is GraphQLInputObjectType, is GraphQLEnumType -> { + val namedType = element as GraphQLNamedType + referencedInputLikeTypes.add(namedType.name) + if (traversedInputLikeTypes.add(namedType.name)) { + resolvedChildren(element) + } else { + emptyMap() + } + } + else -> resolvedChildren(element) + } + + private fun resolvedChildren(element: GraphQLSchemaElement): Map> = + element.childrenWithTypeReferences.children.mapValues { (_, children) -> + children.mapNotNull { child -> + when (child) { + is GraphQLTypeReference -> schema.getType(child.name) + is GraphQLSchemaElement -> child + else -> null + } + } + } + + 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) + collectContainingInputLikeTypeNames().forEach { typeName -> + 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-property-testing/src/main/kotlin/graphql/schema/property/GraphQLStrings.kt new file mode 100644 index 000000000..06e66ad07 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLStrings.kt @@ -0,0 +1,90 @@ +package graphql.schema.property + +import io.kotest.property.Arb +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.char +import io.kotest.property.arbitrary.charArray +import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.string + +private val nameStartRanges: List = + listOf( + 'A'..'Z', + 'a'..'z', + '_'..'_' + ) + +private val nameContinueRanges: List = + nameStartRanges.plusElement('0'..'9') + +private val builtinTypeNames = setOf("Boolean", "Float", "ID", "Int", "String") + +// Enum values may not be "true", "false", or "null" +// https://spec.graphql.org/draft/#sec-Enum-Value +private val bannedEnumValues = setOf("true", "false", "null") + +/** + * Return a String that is a valid GraphQL name, defined by + * https://spec.graphql.org/draft/#sec-Names + * + * This Arb will not return introspection names + */ +fun Arb.Companion.graphQLName(length: IntRange = 1..10): Arb { + val nameStart: Arb = Arb.char(nameStartRanges) + val nameContinue = Arb.charArray( + Arb.int(length).map { it - 1 }, + Arb.char(nameContinueRanges) + ) + + return Arb + .bind(nameStart, nameContinue) { ns, nc -> + buildString { + append(ns.toString()) + append(nc) + } + }.filter { + // filter out introspection schema names which are reserved for spec use + !it.startsWith("__") && + // filter out names of built-in types + it !in builtinTypeNames + } +} + +private fun Arb.Companion.graphQLFieldName(length: IntRange): Arb = + graphQLName(length = length) + .map { + it.replaceFirstChar(Char::lowercase) + } + +/** + * Return a String suitable for GraphQL field names. + * This Arb will not return introspection names. + * + * Though not required by the spec, generated names always + * have lower-case first-letters to improve legibility. + */ +fun Arb.Companion.graphQLFieldName(cfg: Config = Config.default): Arb = graphQLFieldName(cfg[FieldNameLength]).filter(cfg) + +/** Generate Strings suitable for use as a GraphQL argument name */ +fun Arb.Companion.graphQLArgumentName(cfg: Config = Config.default): Arb = graphQLFieldName(cfg[FieldNameLength]).filter(cfg) + +/** Generate Strings suitable for use as GraphQL enum value */ +fun Arb.Companion.graphQLEnumValueName(cfg: Config = Config.default): Arb = + graphQLName(cfg[FieldNameLength]) + .filter(cfg) + .filter { it !in bannedEnumValues } + +/** Generate Strings suitable for use as a GraphQL description. */ +fun Arb.Companion.graphQLDescription(cfg: Config = Config.default): Arb = Arb.string(cfg[DescriptionLength]) + +private fun Arb.filter(cfg: Config): Arb { + val ban = cfg[BanFieldNames] + return if (ban.isEmpty()) { + this + } else { + filter { !ban.contains(it) } + } +} 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 new file mode 100644 index 000000000..d17a2cf30 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTestData.kt @@ -0,0 +1,203 @@ +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/main/kotlin/graphql/schema/property/GraphQLTypes.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTypes.kt new file mode 100644 index 000000000..986836635 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLTypes.kt @@ -0,0 +1,642 @@ +package graphql.schema.property + +import graphql.Directives +import graphql.Scalars +import graphql.introspection.Introspection.DirectiveLocation +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLDirective +import graphql.schema.GraphQLEnumType +import graphql.schema.GraphQLEnumValueDefinition +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLInputObjectField +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLInterfaceType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNamedType +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLOutputType +import graphql.schema.GraphQLScalarType +import graphql.schema.GraphQLType +import graphql.schema.GraphQLTypeReference +import graphql.schema.GraphQLTypeUtil +import graphql.schema.GraphQLUnionType +import graphql.schema.GraphQLUnmodifiedType +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.element +import io.kotest.property.arbitrary.flatMap +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 kotlin.math.min + +/** A bag of generated types that can be converted into a GraphQLSchema */ +data class GraphQLTypes( + val interfaces: Map, + val objects: Map, + val inputs: Map, + val unions: Map, + val scalars: Map, + val enums: Map, + val directives: Map +) { + val names: Set by lazy { + val s = mutableSetOf() + s += interfaces.keys + s += objects.keys + s += inputs.keys + s += unions.keys + s += scalars.keys + s += enums.keys + s += directives.keys + s.toSet() + } + + /** Try to resolve a GraphQLTypeReference into a GraphQLType */ + fun resolve(ref: GraphQLTypeReference): GraphQLUnmodifiedType? { + ref.name.let { name -> + listOf(interfaces, objects, inputs, unions, scalars, enums).forEach { + if (it.contains(name)) return it[name] + } + return null + } + } + + operator fun plus(directive: GraphQLDirective): GraphQLTypes = copy(directives = directives + (directive.name to directive)) + + operator fun plus(iface: GraphQLInterfaceType): GraphQLTypes = copy(interfaces = interfaces + (iface.name to iface)) + + companion object { + val empty: GraphQLTypes = GraphQLTypes( + emptyMap(), + emptyMap(), + emptyMap(), + emptyMap(), + emptyMap(), + emptyMap(), + emptyMap(), + ) + } +} + +/** Generate arbitrary GraphQLTypes from a static Config */ +fun Arb.Companion.graphQLTypes(cfg: Config = Config.default): Arb = + Arb + .graphQLNames(cfg) + .flatMap { names -> + graphQLTypes(names, cfg) + } + +/** Generate arbitrary GraphQLTypes from a static Config and static names */ +fun Arb.Companion.graphQLTypes( + names: GraphQLNames, + cfg: Config = Config.default +): Arb = + arbitrary { rs -> + GraphQLTypesGen(cfg, names, rs).gen() + } + +private class InputTypeDescriptor( + val underlyingTypeName: String, + val underlyingTypeType: TypeType, + val type: GraphQLInputType +) { + // This is used to ensure that we do not create cyclic references that graphql-java will reject. + // This matches GraphQL-Java validation rules, which will reject input + // cycles that are technically satisfiable even when they contain non-nullable wrappers, + // such as: + // input Foo { foos: [[Foo]]! } + val usedNonNullably: Boolean by lazy { + tailrec fun loop(t: GraphQLType): Boolean = + when (t) { + is GraphQLNonNull -> true + is GraphQLList -> loop(t.originalWrappedType) + else -> false + } + loop(type) + } +} + +/** Internal helper class for generating a GraphQLTypes */ +internal class GraphQLTypesGen( + private val cfg: Config, + private val names: GraphQLNames, + private val rs: RandomSource +) { + private fun sampleWeight(key: ConfigKey): Boolean = rs.sampleWeight(cfg[key]) + + @JvmName("sampleCompoundingWeight") + private fun sampleWeight(key: ConfigKey): Boolean = rs.sampleWeight(cfg[key].weight) + + fun gen(): GraphQLTypes = + Arb + .of(cfg[IncludeTypes]) + .withScalars() + .withEnums() + .withInputs() + .withDirectives() + .withInterfaces() + .withObjects() + .withUnions() + .finalize() + .next(rs) + + internal fun genDescription(): String = Arb.graphQLDescription(cfg).next(rs) + + private fun Arb.withDirectives(): Arb = + map { types -> + types.copy( + directives = types.directives + genDirectives().associateBy { it.name } + ) + } + + private fun genDirectives(): List { + val validLocations = DirectiveLocation + .values() + .toSet() + .arbSubset(1..DirectiveLocation.values().size) + + return names.directives.map { name -> + when (val d = builtinDirectives[name]) { + null -> { + GraphQLDirective + .newDirective() + .name(name) + .description(genDescription()) + .replaceArguments(genArguments(DirectiveHasArgs)) + .repeatable(sampleWeight(DirectiveIsRepeatable)) + .also { + validLocations.next(rs).forEach(it::validLocation) + }.build() + } + else -> d + } + } + } + + private fun Arb.withScalars(): Arb = + map { types -> + types.copy(scalars = types.scalars + genScalars().associateBy { it.name }) + } + + private fun genScalars(): List = + names.scalars.map { name -> + when (val s = builtinScalars[name]) { + null -> + GraphQLScalarType + .newScalar() + .name(name) + .description(genDescription()) + .coercing(Scalars.GraphQLString.coercing) + .build() + else -> s + } + } + + /** + * Wrap a provided type in GraphQLList and GraphQLNonNull wrapper types. + * This function may return a type that has been wrapped multiple times + */ + internal fun decorate( + t: GraphQLType, + allowNonNullable: Boolean = true + ): GraphQLType { + tailrec fun wrap( + type: GraphQLType, + listBudget: Int + ): GraphQLType = + if (type !is GraphQLNonNull && allowNonNullable && sampleWeight(NonNullableTypeWeight)) { + wrap(GraphQLNonNull.nonNull(type), listBudget) + } else if (listBudget > 0 && sampleWeight(ListTypeWeight)) { + wrap(GraphQLList.list(type), listBudget - 1) + } else { + type + } + + return wrap(t, cfg[ListTypeWeight].max) + } + + /** + * Generate a GraphQLOutputType that is backed by a GraphQLTypeReference and potentially wrapped + * in GraphQLNonNull and GraphQLList wrappers. + */ + private fun genOutputTypeRef(): GraphQLOutputType = + Arb + .element(names.interfaces + names.scalars + names.unions + names.objects + names.enums) + .map(GraphQLTypeReference::typeRef) + .map { + decorate(it) as GraphQLOutputType + }.next(rs) + + /** + * Generate a GraphQLInputType that is backed by a GraphQLTypeReference and potentially wrapped + * in GraphQLNonNull and GraphQLList wrappers. + */ + private fun genInputTypeDescriptor(allowNonNullable: Boolean = true): Arb { + val namePools = setOf(TypeType.Scalar, TypeType.Input, TypeType.Enum) + .mapNotNull { tt -> + names.names[tt] + ?.takeIf { it.isNotEmpty() } + ?.let { pool -> tt to pool } + } + + return Arb + .element(namePools) + .flatMap { (tt, pool) -> + Arb.element(pool).map { tt to it } + }.map { (tt, name) -> + InputTypeDescriptor( + underlyingTypeName = name, + underlyingTypeType = tt, + type = decorate(GraphQLTypeReference.typeRef(name), allowNonNullable) as GraphQLInputType + ) + } + } + + private fun Arb.withInterfaces(): Arb = + map { types -> + types.copy(interfaces = types.interfaces + genInterfaces()) + } + + private fun genInterfaces(): Map = + names.interfaces.fold(emptyMap()) { acc, name -> + val edges = genImplements(acc, InterfaceImplementsInterface) + val localFields = genFields(InterfaceTypeSize) + val allFields = (edges.flatMap { it.fields } + localFields) + .distinctBy { it.name } + + val iface = GraphQLInterfaceType + .newInterface() + .name(name) + .description(genDescription()) + .fields(allFields) + .replaceInterfaces(edges.toList()) + .build() + + acc + (name to iface) + } + + private fun genImplements( + pool: Map, + weightKey: ConfigKey, + ): Set { + tailrec fun withImplementedInterfaces( + acc: Set, + unchecked: Set + ): Set = + if (unchecked.isEmpty()) { + acc + } else { + val iface = unchecked.first() + val parents = iface.interfaces.map { it as GraphQLInterfaceType } + val toCheck = parents.filterNot(acc::contains).toSet() + withImplementedInterfaces( + acc = acc + iface, + unchecked = (unchecked - iface + toCheck) + ) + } + + tailrec fun loop( + edges: Set, + edgeBudget: Int, + pool: Map + ): Set = + if (edgeBudget > 0 && pool.isNotEmpty() && sampleWeight(weightKey)) { + val newEdge = Arb.of(pool.entries).next(rs).value + loop( + edges = edges + newEdge, + edgeBudget = edgeBudget - 1, + pool = pool - newEdge.name + ) + } else { + edges + } + + // build base edges + return loop(emptySet(), cfg[weightKey].max, pool) + .let { edges -> + // filter out conflicting edges + edges.nonConflicting() + }.let { edges -> + // then add in all interfaces-of-interfaces + withImplementedInterfaces(emptySet(), edges) + } + } + + private fun genArguments(key: ConfigKey): List { + tailrec fun loop(acc: Map): List = + if (acc.size != cfg[key].max && sampleWeight(key)) { + val itd = genInputTypeDescriptor().next(rs) + val arg = GraphQLArgument + .newArgument() + .name(Arb.graphQLArgumentName(cfg).next(rs)) + .description(genDescription()) + .type(itd.type) + .build() + + loop(acc = acc + (arg.name to arg)) + } else { + acc.values.toList() + } + + return loop(emptyMap()) + } + + private fun Arb.withObjects(): Arb = + map { types -> + types.copy(objects = types.objects + genObjects(types).associateBy { it.name }) + } + + private fun genObjects(types: GraphQLTypes): List = + names.objects.map { name -> + val implements = genImplements( + types.interfaces, + ObjectImplementsInterface + ) + genObject(name, implements) + } + + private fun genObject( + name: String, + implements: Set, + ): GraphQLObjectType { + val interfaceFields = implements.flatMap { it.fields } + val allFields = (interfaceFields + genFields(ObjectTypeSize)) + .distinctBy { it.name } + + return GraphQLObjectType + .newObject() + .name(name) + .replaceInterfaces(implements.toList()) + .description(genDescription()) + .fields(allFields) + .build() + } + + private fun genFields(sizeKey: ConfigKey): List = + Arb + .int(cfg[sizeKey]) + .map { size -> + List(size) { genField() }.distinctBy { it.name } + }.next(rs) + + private fun genField(): GraphQLFieldDefinition = + GraphQLFieldDefinition + .newFieldDefinition() + .name(Arb.graphQLFieldName(cfg).next(rs)) + .description(genDescription()) + .arguments(genArguments(FieldArgumentWeight)) + .type(genOutputTypeRef()) + .build() + + private fun Arb.withInputs(): Arb = + map { types -> + /** + * Input objects have a unique requirement in that they must be constructed in a + * way that allows for finite values. This is easiest to explain with counter-examples -- + * here are some type configurations that are invalid because they are uninhabited and + * require infinite values: + * + * ``` + * // Recursive graphs with non-nullable edges: + * input A { b:B! } + * input B { a:A! } + * + * // Uninhabited OneOf types: + * input A @oneOf { b:B } + * input B @oneOf { a:A } + * + * // Mixed OneOf and non-nullable graphs: + * input A @oneOf { b:B } + * input B { a:A! } + * ``` + * + * The property that makes a type uninhabited is that the graph of its mandatory + * fields (fields that *must* be provided a value) contains a cycle. If we construct types + * such that any mandatory dependencies are a-cyclic, then we can guarantee inhabitation. + * + * The job of this method is to guarantee that every input object is inhabited, while + * retaining the ability to generate as many interesting edge cases as possible. + * + * As a heuristic, we only allow input-typed mandatory fields where the name of the field's + * type is lexicographically smaller than the name of the host's type. This ensures that + * the graph of mandatory fields is acyclic. For example: + * + * input A { b: B } // field type "B" > host type "A" -> mandatory edge is not allowed + * input B { a: A! } // field type "A" > host type "B" -> mandatory edge is allowed + */ + val oneOfTypes = names.inputs.filter { sampleWeight(OneOfTypeWeight) }.toSet() + + val inputs = names.inputs.associateWith { name -> + val isOneOf = name in oneOfTypes + val fields = genInputFields(name, isOneOf, InputObjectTypeSize) + + GraphQLInputObjectType + .newInputObject() + .name(name) + .description(genDescription()) + .fields(fields) + .apply { + if (isOneOf) { + withDirective(Directives.OneOfDirective) + + // As a disjunctive type, if a OneOf contains no inhabited fields, then it + // is itself uninhabited. + // In these cases, we can make it inhabited by adding a single escape field + if (fields.none { isInhabitedField(name, it) }) { + field(genEscapeInputField()) + } + } + } + .build() + } + types.copy(inputs = types.inputs + inputs) + } + + private fun genInputFields( + hostType: String, + isOneOf: Boolean, + key: ConfigKey + ): List { + /** + * To generate input fields, we start by generating an InputTypeDescriptor + * and dropping the descriptors that don't meet the current nullability constraints. + * + * There are some configurations (like a small name pool or extreme configurations + * for non-nullable types) that may make it impossible to meet the nullability + * constraints. + * + * In these cases, generate up to 100x the number of requested fields + * before falling back to an escape field. + */ + val fieldCount = Arb.int(cfg[key]).next(rs) + val fields = genInputTypeDescriptor(allowNonNullable = !isOneOf) + .asSequence(rs) + .take(fieldCount * 100) + .filter { itd -> + if (itd.usedNonNullably && itd.underlyingTypeType == TypeType.Input) { + itd.underlyingTypeName.compareTo(hostType) < 0 + } else { + true + } + } + .take(fieldCount) + .toList() + .map { itd -> + GraphQLInputObjectField + .newInputObjectField() + .name(Arb.graphQLFieldName(cfg).next(rs)) + .description(genDescription()) + .type(itd.type) + .build() + } + .distinctBy { it.name } + + return fields.ifEmpty { listOf(genEscapeInputField()) } + } + + private fun genEscapeInputField(): GraphQLInputObjectField { + val escapeFieldName = Arb.graphQLFieldName(cfg) + // prefix the escape field name for better debuggability + .map { "escape_" + it } + .next(rs) + + return GraphQLInputObjectField.newInputObjectField() + .name(escapeFieldName) + .type(Scalars.GraphQLInt) + .build() + } + + /** An inhabited field is a field with a type that allows constructing a finite value. */ + private fun isInhabitedField( + hostTypeName: String, + field: GraphQLInputObjectField + ): Boolean { + // list-typed fields (even non-nullable lists) are inhabited because they can always + // be constructed with an empty array ([]) + if (GraphQLTypeUtil.unwrapNonNull(field.type) is GraphQLList) return true + + val fieldTypeName = GraphQLTypeUtil.unwrapAllAs(field.type).name + // fields with simple types are always inhabited + if (fieldTypeName in names.scalars || fieldTypeName in names.enums) return true + + /** + * As a heuristic, fieldTypeName < hostTypeName is assumed to be inhabited. + * See explanation in [withInputs]. + */ + return fieldTypeName.compareTo(hostTypeName) < 0 + } + + private fun Arb.withUnions(): Arb = + map { types -> + types.copy(unions = types.unions + genUnions().associateBy { it.name }) + } + + private fun Arb.finalize(): Arb = + if (cfg[GenInterfaceStubsIfNeeded]) { + map(::addMissingImpls) + } else { + this + } + + private fun addMissingImpls(types: GraphQLTypes): GraphQLTypes { + tailrec fun loop( + ifaces: Set, + objs: List + ): Set = + if (ifaces.isEmpty() || objs.isEmpty()) { + ifaces + } else { + loop( + ifaces - objs + .first() + .interfaces + .map { it.name } + .toSet(), + objs.drop(1) + ) + } + + val unimplemented = loop(types.interfaces.keys, types.objects.values.toList()) + val newObjects = unimplemented.map { iname -> + val ifaces = types.interfaces.getValue(iname).let { iface -> + val parents = iface.interfaces.map { types.interfaces.getValue(it.name) } + parents.toSet() + iface + } + genObject(iname + "_STUB", ifaces) + } + return types.copy( + objects = types.objects + newObjects.associateBy { it.name } + ) + } + + private fun genUnions(): List { + if (names.unions.isEmpty() || names.objects.isEmpty()) { + return emptyList() + } + + // Arb.set can be finicky with what it calls slippage, which is when it wants to + // generate a set of a certain size but has to make multiple attempts due to the + // underlying generator returning the same element multiple times. + // For unions, it's not critically important that we get the exact right size every + // time, so just use a list generator and remove duplicates + val arbMembers = Arb + .list( + gen = Arb.element(names.objects), + range = IntRange( + min(names.objects.size, cfg[UnionTypeSize].first), + min(names.objects.size, cfg[UnionTypeSize].last) + ) + ).map { it.distinct() } + + return names.unions.map { name -> + val members = arbMembers.next(rs).map(::GraphQLTypeReference) + + GraphQLUnionType + .newUnionType() + .name(name) + .description(genDescription()) + .replacePossibleTypes(members) + .build() + } + } + + private fun genEnumValues(): List = + // Generating enum values using Arb.set can throw an IllegalStateException if it can't + // generate the target size. This becomes more likely as the target size grows and collisions + // become harder to avoid. + // Use Arb.list instead which is close enough and more reliable + Arb + .list( + Arb.graphQLEnumValueName(cfg), + cfg[EnumTypeSize] + ).map { values -> + values.toSet().map { + GraphQLEnumValueDefinition + .newEnumValueDefinition() + .name(it) + .value(it) + .build() + } + }.next(rs) + + private fun Arb.withEnums(): Arb = + map { types -> + types.copy(enums = types.enums + genEnums().associateBy { it.name }) + } + + private fun genEnums(): List = + names.enums.map { name -> + GraphQLEnumType + .newEnum() + .name(name) + .description(genDescription()) + .values(genEnumValues()) + .build() + } +} diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/SchemaRelationships.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/SchemaRelationships.kt new file mode 100644 index 000000000..408c15750 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/SchemaRelationships.kt @@ -0,0 +1,66 @@ +package graphql.schema.property + +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() } +} + +/** Computes the fragment-spread relationships between composite schema types. */ +internal class SchemaRelationships(private val schema: GraphQLSchema) { + private val compositeTypes = schema.allTypesAsList.filterIsInstance() + + /** Return the types that may be used as a type condition within [parentType]. */ + fun spreadableTypes(parentType: GraphQLCompositeType): Set = + compositeTypes.filterTo(linkedSetOf()) { candidate -> + isSpreadable(parentType, candidate) + } + + /** + * Return whether [fragmentType] may be spread within [parentType], as defined by the + * Possible Fragment Spreads validation rule. + */ + fun isSpreadable( + parentType: GraphQLCompositeType, + fragmentType: GraphQLCompositeType + ): Boolean { + if (parentType == fragmentType) return true + return possibleObjectTypes(parentType).any(possibleObjectTypes(fragmentType)::contains) + } + + /** Return every concrete object type that may represent [type] at execution time. */ + fun possibleObjectTypes(type: GraphQLCompositeType): Set = + when (type) { + is GraphQLObjectType -> setOf(type) + is GraphQLInterfaceType -> schema.getImplementations(type).orEmpty().toSet() + is GraphQLUnionType -> type.types.filterIsInstance().toSet() + else -> emptySet() + } +} diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/TypeType.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/TypeType.kt new file mode 100644 index 000000000..7f761c419 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/TypeType.kt @@ -0,0 +1,12 @@ +package graphql.schema.property + +/** An enumeration of GraphQL object flavors */ +enum class TypeType { + Interface, + Object, + Input, + Union, + Scalar, + Enum, + Directive +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt new file mode 100644 index 000000000..9be73dcbf --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt @@ -0,0 +1,84 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.double +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.intRange +import io.kotest.property.arbitrary.pair +import io.kotest.property.checkAll + +class ConfigTest : FunSpec({ + test("Config validates entries") { + val valid = ConfigKey(0) { null } + val invalid = ConfigKey(0) { "invalid" } + checkAll(PropTestConfig(iterations = 100), Arb.int()) { value -> + (Config.default + (valid to value))[valid].shouldBe(value) + shouldThrow { Config.default + (invalid to value) } + .message.shouldBe("invalid") + } + } + + test("a Config overrides another Config") { + val first = ConfigKey(1, Unvalidated) + val second = ConfigKey(2, Unvalidated) + val base = Config.default + (first to 11) + (second to 22) + val combined = base + (Config.default + (first to 111)) + combined[first].shouldBe(111) + combined[second].shouldBe(22) + } + + test("missing keys return defaults and present keys return configured values") { + checkAll(PropTestConfig(iterations = 100), Arb.int(), Arb.int()) { default, configured -> + val key = ConfigKey(default, Unvalidated) + Config.default[key].shouldBe(default) + (Config.default + (key to configured))[key].shouldBe(configured) + } + } + + test("WeightValidator accepts exactly the inclusive unit interval") { + checkAll(PropTestConfig(iterations = 200), Arb.double()) { value -> + val valid = runCatching { Config.validateOrThrow(WeightValidator, value) }.isSuccess + valid.shouldBe(value in 0.0..1.0) + } + runCatching { Config.validateOrThrow(WeightValidator, 0.0) }.isSuccess.shouldBe(true) + runCatching { Config.validateOrThrow(WeightValidator, 1.0) }.isSuccess.shouldBe(true) + } + + test("CompoundingWeightValidator rejects invalid weights and maxima") { + val values = Arb.pair(Arb.double(), Arb.int()) + checkAll(PropTestConfig(iterations = 200), values) { (weight, maximum) -> + val valid = runCatching { + Config.validateOrThrow(CompoundingWeightValidator, CompoundingWeight(weight, maximum)) + }.isSuccess + valid.shouldBe(weight in 0.0..1.0 && maximum >= 0) + } + } + + test("IntValidator accepts only values in its domain") { + checkAll(PropTestConfig(iterations = 200), Arb.intRange(-10_000..10_000), Arb.int()) { domain, value -> + val valid = runCatching { Config.validateOrThrow(IntValidator(domain), value) }.isSuccess + valid.shouldBe(value in domain) + } + } + + test("IntRangeValidator accepts non-empty subranges") { + val ranges = Arb.intRange(-10_000..10_000) + checkAll(PropTestConfig(iterations = 200), ranges, ranges) { domain, range -> + val valid = runCatching { Config.validateOrThrow(IntRangeValidator(domain), range) }.isSuccess + valid.shouldBe(!range.isEmpty() && domain.first <= range.first && domain.last >= range.last) + } + } + + test("Unvalidated accepts unrelated values") { + Config.validateOrThrow(Unvalidated, 1) + Config.validateOrThrow(Unvalidated, "value") + Config.validateOrThrow(Unvalidated, Any()) + Unvalidated(Unit).shouldBe(null) + } +}) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt new file mode 100644 index 000000000..529b8fe4a --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt @@ -0,0 +1,69 @@ +package graphql.schema.property + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe + +class CycleGroupsTest : FunSpec({ + test("schemas without recursive input types have no cycle groups") { + val schemas = listOf( + "type Query { x: Int }", + "input A { x: Int } input B { a: A } type Query { x(input: B): Int }" + ) + schemas.forEach { sdl -> + val schema = parseTestSchema(sdl) + CycleGroups.allInputCycles(schema).isEmpty().shouldBe(true) + CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) + } + } + + test("nullable self loops are optional cycles") { + val schema = parseTestSchema("input Inp { inp: Inp } type Query { x(inp: Inp): Int }") + CycleGroups.allInputCycles(schema)["Inp"].shouldBe(setOf("Inp")) + CycleGroups.mandatoryInputCycles(schema)["Inp"].shouldBeEmpty() + } + + test("nullable mutually recursive types share a cycle group") { + val schema = parseTestSchema( + "input A { b: B } input B { a: A } input C { a: A } type Query { x(c: C): Int }" + ) + val all = CycleGroups.allInputCycles(schema) + all["A"].shouldBe(setOf("A", "B")) + all["B"].shouldBe(setOf("A", "B")) + all["C"].shouldBeEmpty() + CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) + } + + test("non-null lists break mandatory recursion") { + val schema = parseTestSchema( + "input A { b: [B!]! } input B { a: A! } type Query { x(a: A): Int }" + ) + CycleGroups.allInputCycles(schema)["A"].shouldBe(setOf("A", "B")) + CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) + } + + test("mixed-nullability recursion is not mandatory") { + val schema = parseTestSchema( + "input A { b: B! } input B { a: A } type Query { x(a: A): Int }" + ) + CycleGroups.allInputCycles(schema)["A"].shouldBe(setOf("A", "B")) + CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) + } + + test("nullable oneOf recursion is optional") { + val schema = parseTestSchema( + "input A @oneOf { b: B } input B { a: A } type Query { x(a: A): Int }" + ) + CycleGroups.allInputCycles(schema)["A"].shouldBe(setOf("A", "B")) + CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) + } + + test("oneOf fields participate in mandatory cycles when their host is required") { + val schema = parseTestSchema( + "input A @oneOf { b: B, escape: Int } input B { a: A! } type Query { x(a: A): Int }" + ) + val mandatory = CycleGroups.mandatoryInputCycles(schema) + mandatory["A"].shouldBe(setOf("A", "B")) + mandatory["B"].shouldBe(setOf("A", "B")) + } +}) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt new file mode 100644 index 000000000..694374e84 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt @@ -0,0 +1,212 @@ +package graphql.schema.property + +import graphql.Scalars.GraphQLInt +import graphql.introspection.Introspection +import graphql.language.Argument +import graphql.language.AstPrinter +import graphql.language.IntValue +import graphql.language.NullValue +import graphql.language.OperationDefinition +import graphql.parser.Parser +import graphql.schema.FieldCoordinates +import graphql.schema.GraphQLCompositeType +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe + +class DocumentBuilderTest : FunSpec({ + test("ArgumentKey compares the printed value instead of AST identity") { + val first = Argument("arg", NullValue.of()) + val second = Argument("arg", NullValue.of()) + first.shouldNotBe(second) + ArgumentKey(first).shouldBe(ArgumentKey(second)) + ArgumentKey(first).hashCode().shouldBe(ArgumentKey(second).hashCode()) + ArgumentKey(first).arg.shouldBe(first) + } + + test("FieldKey equality includes arguments and wrapper structure") { + DocumentBuilderFixture("type Query { x(arg: Int): Int }").run { + val first = FieldKey("x", null, setOf(ArgumentKey(Argument("arg", IntValue.of(1)))), typeExpr("Int")) + val same = FieldKey("x", null, setOf(ArgumentKey(Argument("arg", IntValue.of(1)))), typeExpr("Int")) + val different = FieldKey("x", null, setOf(ArgumentKey(Argument("arg", NullValue.of()))), typeExpr("Int")) + first.shouldBe(same) + first.shouldNotBe(different) + } + } + + test("TypeExpr hashes list and null wrappers in order") { + TypeExpr(GraphQLInt).listNullHash.shouldBe(1) + TypeExpr(GraphQLList.list(GraphQLInt)).listNullHash.shouldBe(2) + TypeExpr(GraphQLNonNull.nonNull(GraphQLInt)).listNullHash.shouldBe(3) + TypeExpr(GraphQLList.list(GraphQLList.list(GraphQLInt))).listNullHash.shouldBe(4) + TypeExpr(GraphQLList.list(GraphQLNonNull.nonNull(GraphQLInt))).listNullHash.shouldBe(5) + TypeExpr(GraphQLNonNull.nonNull(GraphQLList.list(GraphQLInt))).listNullHash.shouldBe(6) + TypeExpr(GraphQLList.list(GraphQLNonNull.nonNull(GraphQLInt))) + .shouldNotBe(TypeExpr(GraphQLNonNull.nonNull(GraphQLList.list(GraphQLInt)))) + } + + test("KeyTree creates scalar leaves and composite branches") { + DocumentBuilderFixture("type Query { x: Int, q: Query }").run { + val scalarKey = key("Query", "x") + val objectKey = key("Query", "q") + KeyTree(scalarKey)[scalarKey].shouldBe(null) + (KeyTree(objectKey)[objectKey] != null).shouldBe(true) + } + } + + test("KeyTree detects merge-compatible and conflicting response keys") { + DocumentBuilderFixture("type Query { x: Int, q: Query }").run { + val x = key("Query", "x") + val q = key("Query", "q") + KeyTree().canMerge(KeyTree()).shouldBe(true) + KeyTree(x).canMerge(KeyTree(x)).shouldBe(true) + KeyTree(x).canMerge(KeyTree(q)).shouldBe(true) + KeyTree(x).canMerge(KeyTree(key("Query", "q", "x"))).shouldBe(false) + + val first = KeyTree(q).apply { merge(q, KeyTree(x)) } + val compatible = KeyTree(q).apply { merge(q, KeyTree(key("Query", "__typename"))) } + val conflicting = KeyTree(q).apply { merge(q, KeyTree(key("Query", "q", "x"))) } + first.canMerge(compatible).shouldBe(true) + first.canMerge(conflicting).shouldBe(false) + } + } + + test("KeyTree merge recursively combines compatible branches") { + DocumentBuilderFixture("type Query { x: Int, q: Query }").run { + val x = key("Query", "x") + val q = key("Query", "q") + val typename = key("Query", "__typename") + KeyTree(x).merge(KeyTree(typename)).toMap().shouldBe(mapOf(x to null, typename to null)) + + val first = KeyTree(q).apply { merge(q, KeyTree(x)) } + val second = KeyTree(q).apply { merge(q, KeyTree(typename)) } + first.merge(second).toMap().shouldBe(mapOf(q to mapOf(x to null, typename to null))) + } + } + + test("KeyTree clone is independent") { + DocumentBuilderFixture("type Query { x: Int, y: Int }").run { + val x = key("Query", "x") + val y = key("Query", "y") + val original = KeyTree(x) + val clone = original.clone() + original.toMap().shouldBe(clone.toMap()) + original.merge(KeyTree(y)) + original.toMap().shouldNotBe(clone.toMap()) + } + } + + test("SelectionsBuilder emits scalar and aliased fields") { + DocumentBuilderFixture("type Query { x: Int }").run { + selections.add(FieldSelection(key("Query", "x"))) + assertDocument("{ x }") + } + DocumentBuilderFixture("type Query { x: Int }").run { + selections.add(FieldSelection(key("Query", "x", "alias"))) + assertDocument("{ alias: x }") + } + } + + test("SelectionsBuilder emits nested object selections") { + DocumentBuilderFixture("type Obj { y: Int } type Query { obj: Obj }").run { + val objectKey = key("Query", "obj") + val scope = selections.newFieldScope(objectKey) + scope.add(FieldSelection(key("Obj", "y"))) + selections.add(FieldSelection(objectKey, scope)) + assertDocument("{ obj { y } }") + } + } + + test("SelectionsBuilder emits typed and untyped inline fragments") { + DocumentBuilderFixture("type Query { x: Int }").run { + selections.add(InlineFragmentSelection(null, listOf(FieldSelection(key("Query", "x"))))) + assertDocument("{ ... { x } }") + } + DocumentBuilderFixture("type Query { x: Int }").run { + selections.add(InlineFragmentSelection("Query", listOf(FieldSelection(key("Query", "x"))))) + assertDocument("{ ... on Query { x } }") + } + } + + test("DocumentBuilder emits reusable fragment definitions") { + DocumentBuilderFixture("type Query { x: Int }").run { + val scope = selections.newSpreadScope() + scope.add(FieldSelection(key("Query", "x"))) + fragments += FragmentDef("Frag", schema.queryType, scope, emptyList(), emptyList()) + selections.add(FragmentSpreadSelection("Frag", scope.selections)) + selections.add(FragmentSpreadSelection("Frag", scope.selections)) + selections.add(FieldSelection(key("Query", "x"))) + assertDocument("{ ... Frag ... Frag x } fragment Frag on Query { x }") + } + } + + test("SelectionsBuilder checks aliases and arguments before adding fields") { + DocumentBuilderFixture("type Query { x(arg: Int): Int }").run { + val x = field("Query", "x").key() + selections.canAdd(x).shouldBe(true) + selections.add(FieldSelection(x)) + selections.canAdd(x.copy()).shouldBe(true) + selections.canAdd(x.copy(alias = "alias")).shouldBe(true) + val withNull = setOf(ArgumentKey(Argument("arg", NullValue.of()))) + selections.canAdd(x.copy(arguments = withNull)).shouldBe(false) + selections.canAdd(x.copy(alias = "alias", arguments = withNull)).shouldBe(true) + } + } + + test("field scopes share merge constraints without losing local selections") { + DocumentBuilderFixture("type Obj { x: Int, y: Int, z: Int } type Query { obj: Obj }").run { + val objectKey = key("Query", "obj") + val firstScope = SelectionsBuilder(listOf(FieldSelection(key("Obj", "x")))) + selections.add(FieldSelection(objectKey, firstScope)) + + val secondScope = selections.newFieldScope(objectKey) + secondScope.canAdd(key("Obj", "x", "__typename")).shouldBe(true) + secondScope.canAdd(key("Obj", "__typename", "x")).shouldBe(false) + secondScope.add(FieldSelection(key("Obj", "y"))) + selections.add(FieldSelection(objectKey, secondScope)) + + val thirdScope = selections.newFieldScope(objectKey) + thirdScope.canAdd(key("Obj", "__typename", "x")).shouldBe(false) + thirdScope.canAdd(key("Obj", "__typename", "y")).shouldBe(false) + thirdScope.add(FieldSelection(key("Obj", "z"))) + selections.add(FieldSelection(objectKey, thirdScope)) + assertDocument("{ obj { x } obj { y } obj { z } }") + } + } +}) + +private class DocumentBuilderFixture(sdl: String) { + val schema = parseTestSchema(sdl) + private val schemas = Schemas(schema) + val fragments = Fragments(schemas) + val selections = SelectionsBuilder() + + fun field(type: String, fieldName: String): GraphQLFieldDefinition { + if (fieldName == Introspection.TypeNameMetaFieldDef.name) return Introspection.TypeNameMetaFieldDef + return requireNotNull(schema.getFieldDefinition(FieldCoordinates.coordinates(type, fieldName))) + } + + fun key( + type: String, + fieldName: String, + alias: String? = null, + vararg arguments: Argument + ): FieldKey = field(type, fieldName).key(alias, arguments.toSet()) + + fun typeExpr(name: String): TypeExpr = TypeExpr(requireNotNull(schema.getType(name)) as graphql.schema.GraphQLOutputType) + + fun assertDocument(expected: String) { + val builder = DocumentBuilder(schemas, fragments) + builder.add( + OperationDefinition.newOperationDefinition() + .operation(OperationDefinition.Operation.QUERY) + .selectionSet(selections.build()) + .build() + ) + val expectedDocument = Parser().parseDocument(expected) + AstPrinter.printAst(builder.build()).shouldBe(AstPrinter.printAst(expectedDocument)) + } +} 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 new file mode 100644 index 000000000..4283531b1 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratedSchema.kt @@ -0,0 +1,22 @@ +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/GeneratorUtilsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratorUtilsTest.kt new file mode 100644 index 000000000..5d24a6fe5 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratorUtilsTest.kt @@ -0,0 +1,76 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import graphql.schema.GraphQLInterfaceType +import graphql.schema.GraphQLType +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.set +import io.kotest.property.checkAll + +class GeneratorUtilsTest : FunSpec({ + test("sampleWeight handles deterministic boundary weights") { + checkAll(PropTestConfig(iterations = 100), arbitrary { randomSource -> randomSource.sampleWeight(1.0) }) { + it.shouldBe(true) + } + checkAll(PropTestConfig(iterations = 100), arbitrary { randomSource -> randomSource.sampleWeight(0.0) }) { + it.shouldBe(false) + } + } + + test("count observes Never, Once, and configured maxima") { + checkAll(PropTestConfig(iterations = 100), Arb.int(0..100), Arb.int()) { maximum, seed -> + val randomSource = RandomSource.seeded(seed.toLong()) + randomSource.count(CompoundingWeight.Never).shouldBe(0) + randomSource.count(CompoundingWeight.Once).shouldBe(1) + randomSource.count(CompoundingWeight(1.0, maximum)).shouldBe(maximum) + } + } + + test("arbSubset returns only source elements and clamps its size range") { + checkAll(PropTestConfig(iterations = 100), Arb.set(Arb.int(), 0..30)) { values -> + val subset = values.arbSubset().bind() + values.containsAll(subset).shouldBe(true) + values.arbSubset(values.size..values.size).bind().shouldBe(values) + values.arbSubset((values.size + 1)..(values.size + 10)).bind().shouldBe(values) + } + } + + test("schema and AST type wrappers round trip") { + val schema = parseTestSchema( + "type Query { a: Int, b: [[Int]], c: [[Int!]!]! }" + ) + val types = listOf("a", "b", "c").map { name -> schema.queryType.getFieldDefinition(name).type } + types.forEach { type -> + val ast = type.asAstType() + sameType(type, ast.asSchemaType(schema)).shouldBe(true) + } + } + + test("nonConflicting keeps related interfaces together and rejects unrelated field collisions") { + val schema = parseTestSchema( + """ + interface Parent { x: Int } + interface Child implements Parent { x: Int, y: Int } + interface Conflict { x: String } + interface Independent { z: Int } + type Obj implements Parent & Child { x: Int, y: Int } + type Query { obj: Obj } + """.trimIndent() + ) + val interfaces = setOf("Parent", "Child", "Conflict", "Independent") + .mapTo(linkedSetOf()) { schema.getType(it) as GraphQLInterfaceType } + val selected = interfaces.nonConflicting() + selected.map { it.name }.contains("Independent").shouldBe(true) + (selected.map { it.name }.intersect(setOf("Parent", "Child", "Conflict")).size == 1).shouldBe(true) + } +}) + +private fun sameType(first: GraphQLType, second: GraphQLType): Boolean = + first.toString() == second.toString() diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLComparators.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLComparators.kt new file mode 100644 index 000000000..6f9f4373e --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLComparators.kt @@ -0,0 +1,13 @@ +package graphql.schema.property + +import graphql.ExecutionInput +import graphql.language.Document +import graphql.parser.Parser + +/** Order documents by their AST node count for property-failure minimization. */ +internal val DocumentComparator: Comparator = + Comparator.comparingInt { it.allChildren.size } + +/** Order execution inputs by the AST node count of their query document. */ +internal val ExecutionInputComparator: Comparator = + Comparator.comparingInt { Parser().parseDocument(it.query).allChildren.size } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt new file mode 100644 index 000000000..750ff4037 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt @@ -0,0 +1,416 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import graphql.ParseAndValidate +import graphql.language.Argument +import graphql.language.AstPrinter +import graphql.language.Directive +import graphql.language.DirectivesContainer +import graphql.language.Document +import graphql.language.Field +import graphql.language.FloatValue +import graphql.language.FragmentDefinition +import graphql.language.FragmentSpread +import graphql.language.InlineFragment +import graphql.language.IntValue +import graphql.language.NonNullType +import graphql.language.OperationDefinition +import graphql.language.VariableDefinition +import graphql.language.VariableReference +import graphql.schema.GraphQLSchema +import graphql.schema.idl.FastSchemaGenerator +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.SchemaParser +import io.kotest.assertions.withClue +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.flatMap +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.map +import io.kotest.property.checkAll + +class GraphQLDocumentGenTest : FunSpec({ + test("generates valid documents for a trivial schema with minimal config") { + assertAllDocumentsValid(schema("type Query { x: Int }"), minimalDocumentConfig) + } + + test("generates valid documents for a trivial schema with default config") { + assertAllDocumentsValid(schema("type Query { x: Int }"), Config.default) + } + + test("generates valid documents for a representative schema with minimal config") { + assertAllDocumentsValid(documentTestSchema, minimalDocumentConfig) + } + + test("generates valid documents for a representative schema with default config") { + assertAllDocumentsValid(documentTestSchema, Config.default) + } + + test("generates valid documents for schemas with default values") { + val schema = schema( + """ + directive @dir(inp: Inp = {x: 2}) repeatable on QUERY | FIELD + input Inp { x: Int = 1, y: Int! = 2, z: Int = null } + type Query { x(inp: Inp = {y: 3, z: 0}): Int } + """.trimIndent() + ) + assertAllDocumentsValid(schema, Config.default) + } + + test("generates valid documents for arbitrary generated schemas") { + val config = Config.default + + (SchemaSize to 20) + + (FragmentSpreadWeight to CompoundingWeight(.2, 1)) + + (InlineFragmentWeight to CompoundingWeight(.2, 1)) + val cases = Arb.graphQLSchema(config).flatMap { schema -> + Arb.graphQLDocument(schema, config).map { document -> schema to document } + } + checkAll(PropTestConfig(iterations = 500), cases) { (schema, document) -> + assertValid(schema, document) + } + } + + test("handles merged selections, unions, and recursive types") { + val schemas = listOf( + """ + type Obj { x: Int, next: Obj, others: [Obj] } + type Query { obj: Obj } + """.trimIndent(), + """ + interface I { i: I } + type Obj implements I { x: Int, i: I } + type Query { i: I } + """.trimIndent(), + """ + type Foo { x: Int, u: U } + type Bar { y: Int, u: U } + union U = Foo | Bar + type Query { u: U } + """.trimIndent(), + """ + input Input { x: Int, input: Input } + type Query { x(input: Input!): Int } + """.trimIndent(), + """ + type Obj { obj: Obj! } + type Query { obj: Obj! } + """.trimIndent() + ) + schemas.forEach { sdl -> assertAllDocumentsValid(schema(sdl), Config.default, 100) } + } + + test("generates valid subscription documents") { + val schema = schema( + """ + type Obj { x: Int, child: Obj } + type Subscription { x: Int, obj: Obj } + type Query { x: Int } + """.trimIndent() + ) + assertAllDocumentsValid(schema, Config.default) + } + + test("AliasWeight controls aliases") { + val schema = schema("type Query { x: Int }") + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, minimalDocumentConfig + (AliasWeight to 0.0))) { document -> + document.allChildrenOfType().all { it.alias == null }.shouldBe(true) + } + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, minimalDocumentConfig + (AliasWeight to 1.0))) { document -> + document.allChildrenOfType().all { it.alias != null }.shouldBe(true) + } + } + + test("FieldNameLength controls generated alias names") { + val schema = schema("type Query { x: Int }") + val cases = Arb.int(1..10).flatMap { length -> + val config = minimalDocumentConfig + + (AliasWeight to 1.0) + + (FieldNameLength to length..length) + Arb.graphQLDocument(schema, config).map { length to it } + } + checkAll(documentPropertyConfig, cases) { (length, document) -> + document.allChildrenOfType() + .filter { it.alias != "x" && it.alias != "__typename" } + .all { it.alias?.length == length } + .shouldBe(true) + } + } + + test("AnonymousOperationWeight controls anonymous operations") { + val schema = schema("type Query { x: Int }") + val named = minimalDocumentConfig + (AnonymousOperationWeight to 0.0) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, named)) { document -> + document.getDefinitionsOfType(OperationDefinition::class.java).all { it.name != null }.shouldBe(true) + } + + val anonymous = minimalDocumentConfig + (AnonymousOperationWeight to 1.0) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, anonymous)) { document -> + val operations = document.getDefinitionsOfType(OperationDefinition::class.java) + operations.size.shouldBe(1) + operations.single().name.shouldBe(null) + } + } + + test("BanSelectionCoordinates excludes configured fields") { + val schema = schema("type Query { x: Int, y: Int }") + val config = minimalDocumentConfig + + (BanSelectionCoordinates to setOf("Query" to "x")) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, config)) { document -> + document.allChildrenOfType().none { it.name == "x" }.shouldBe(true) + } + } + + test("AppliedDirectiveWeight and BanDirectiveNames control document directives") { + val schema = schema( + """ + directive @dir(arg: Int) repeatable on QUERY | FIELD + type Query { x: Int } + """.trimIndent() + ) + val enabled = minimalDocumentConfig + + (AppliedDirectiveWeight to CompoundingWeight.Once) + + (BanDirectiveNames to builtinDirectives.keys) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, enabled)) { document -> + document.allChildrenOfType().filter { it.name == "dir" }.shouldNotBeEmpty() + } + + val disabled = enabled + (AppliedDirectiveWeight to CompoundingWeight.Never) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, disabled)) { document -> + document.allChildrenOfType().shouldBeEmpty() + } + + val banned = enabled + (BanDirectiveNames to (builtinDirectives.keys + "dir")) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, banned)) { document -> + document.allChildrenOfType().none { it.name == "dir" }.shouldBe(true) + } + } + + test("DocumentUncoercedValueWeight controls coercible literals") { + val schema = schema("type Query { field(arg: Float!): Int }") + val base = minimalDocumentConfig + + (BanSelectionCoordinates to setOf("Query" to "__typename")) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (DocumentUncoercedValueWeight to 0.0))) { document -> + document.allChildrenOfType().all { it.value is FloatValue }.shouldBe(true) + } + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (DocumentUncoercedValueWeight to 1.0))) { document -> + document.allChildrenOfType().all { it.value is IntValue }.shouldBe(true) + } + } + + test("fragment configuration generates valid named and inline fragments") { + checkAll(documentPropertyConfig, Arb.graphQLDocument(documentTestSchema, minimalDocumentConfig)) { document -> + document.allChildrenOfType().shouldBeEmpty() + document.allChildrenOfType().shouldBeEmpty() + } + + val namedConfig = minimalDocumentConfig + + (FieldSelectionWeight to CompoundingWeight.Never) + + (FragmentSpreadWeight to CompoundingWeight.Once) + + (FragmentDefinitionWeight to 1.0) + checkAll(documentPropertyConfig, Arb.graphQLDocument(documentTestSchema, namedConfig)) { document -> + val spreads = document.allChildrenOfType() + val definitions = document.getDefinitionsOfType(FragmentDefinition::class.java) + spreads.shouldNotBeEmpty() + definitions.shouldNotBeEmpty() + spreads.size.shouldBe(definitions.size) + assertValid(documentTestSchema, document) + } + + val inlineConfig = minimalDocumentConfig + + (FieldSelectionWeight to CompoundingWeight.Never) + + (InlineFragmentWeight to CompoundingWeight.Once) + checkAll(documentPropertyConfig, Arb.graphQLDocument(documentTestSchema, inlineConfig)) { document -> + document.allChildrenOfType().shouldNotBeEmpty() + assertValid(documentTestSchema, document) + } + } + + test("UntypedInlineFragmentWeight controls type conditions") { + val schema = schema("type Query { x: Int }") + val base = minimalDocumentConfig + + (FieldSelectionWeight to CompoundingWeight.Never) + + (InlineFragmentWeight to CompoundingWeight.Once) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (UntypedInlineFragmentWeight to 0.0))) { document -> + document.allChildrenOfType().all { it.typeCondition != null }.shouldBe(true) + } + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (UntypedInlineFragmentWeight to 1.0))) { document -> + document.allChildrenOfType().all { it.typeCondition == null }.shouldBe(true) + } + } + + test("ImplicitNullValueWeight controls optional arguments") { + val schema = schema("type Query { x(a: Int, b: Int! = 0, c: Int = 0): Int }") + val base = minimalDocumentConfig + + (BanSelectionCoordinates to setOf("Query" to "__typename")) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (ImplicitNullValueWeight to 0.0))) { document -> + document.allChildrenOfType().filter { it.name == "x" }.all { it.arguments.size == 3 }.shouldBe(true) + } + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (ImplicitNullValueWeight to 1.0))) { document -> + document.allChildrenOfType().filter { it.name == "x" }.all { it.arguments.isEmpty() }.shouldBe(true) + } + } + + test("OperationCount controls operation definitions") { + val cases = Arb.int(1..5).flatMap { count -> + val config = minimalDocumentConfig + + (AnonymousOperationWeight to 0.0) + + (OperationCount to count..count) + Arb.graphQLDocument(documentTestSchema, config).map { count to it } + } + checkAll(documentPropertyConfig, cases) { (count, document) -> + document.getDefinitionsOfType(OperationDefinition::class.java).size.shouldBe(count) + } + } + + test("VariableWeight controls variable generation") { + val schema = schema("type Query { x(a: Int!): Int }") + val disabled = minimalDocumentConfig + + (BanSelectionCoordinates to setOf("Query" to "__typename")) + + (VariableWeight to 0.0) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, disabled)) { document -> + document.allChildrenOfType().shouldBeEmpty() + document.allChildrenOfType().shouldBeEmpty() + } + + val config = minimalDocumentConfig + + (BanSelectionCoordinates to setOf("Query" to "__typename")) + + (VariableWeight to 1.0) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, config)) { document -> + document.allChildrenOfType().shouldNotBeEmpty() + document.allChildrenOfType().shouldNotBeEmpty() + assertValid(schema, document) + } + } + + test("reuses variables safely across operations and fragments") { + val schema = schema("type Query { x(a: Int!): Int }") + val config = Config.default + + (OperationCount to 2..2) + + (FragmentSpreadWeight to CompoundingWeight(.4, 1)) + + (VariableWeight to .3) + assertAllDocumentsValid(schema, config) + } + + test("directives on variable definitions do not reference variables") { + val config = Config.default + + (AppliedDirectiveWeight to CompoundingWeight.Once) + + (VariableWeight to 1.0) + checkAll(documentPropertyConfig, Arb.graphQLDocument(documentTestSchema, config)) { document -> + document.allChildrenOfType() + .flatMap { it.allChildrenOfType() } + .flatMap { it.allChildrenOfType() } + .shouldBeEmpty() + } + } + + test("nullable variables are not used in non-null positions") { + val schema = schema("type Query { x(a: Int, b: Int! = 0): Int }") + val config = minimalDocumentConfig + + (BanSelectionCoordinates to setOf("Query" to "__typename")) + + (VariableWeight to 1.0) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, config)) { document -> + val definitions = document.allChildrenOfType().associateBy { it.name } + document.allChildrenOfType() + .filter { it.name == "b" } + .flatMap { it.allChildrenOfType() } + .all { definitions.getValue(it.name).type is NonNullType } + .shouldBe(true) + } + } + + test("incremental directives are excluded from subscription operations and mutation roots") { + val schema = schema( + """ + directive @defer(if: Boolean = true, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT + directive @stream(if: Boolean = true, label: String, initialCount: Int = 0) on FIELD + type Obj { x: Int, child: Obj } + type Mutation { x: Int, obj: Obj } + type Subscription { x: Int, obj: Obj } + type Query { x: Int } + """.trimIndent() + ) + val config = minimalDocumentConfig + + (AppliedDirectiveWeight to CompoundingWeight.Once) + + (OperationCount to 3..3) + checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, config)) { document -> + document.getDefinitionsOfType(OperationDefinition::class.java) + .filter { it.operation == OperationDefinition.Operation.SUBSCRIPTION } + .all { operation -> + operation.allChildrenOfType().none { it.name == "defer" || it.name == "stream" } + } + .shouldBe(true) + + document.getDefinitionsOfType(OperationDefinition::class.java) + .filter { it.operation == OperationDefinition.Operation.MUTATION } + .flatMap { it.selectionSet.selections } + .filterIsInstance>() + .all { selection -> selection.directives.none { it.name == "defer" || it.name == "stream" } } + .shouldBe(true) + } + } +}) + +private val documentPropertyConfig = PropTestConfig(iterations = 100) + +private val minimalDocumentConfig = Config.default + + (AliasWeight to 0.0) + + (AnonymousOperationWeight to 0.0) + + (AppliedDirectiveWeight to CompoundingWeight.Never) + + (DocumentUncoercedValueWeight to 0.0) + + (ExplicitNullValueWeight to 0.0) + + (FieldNameLength to 4..4) + + (FieldSelectionWeight to CompoundingWeight.Once) + + (FragmentDefinitionWeight to 0.0) + + (FragmentSpreadWeight to CompoundingWeight.Never) + + (ImplicitNullValueWeight to 0.0) + + (InlineFragmentWeight to CompoundingWeight.Never) + + (MaxSelectionSetDepth to 1) + + (OperationCount to 1..1) + + (TypeNameLength to 4..4) + + (UntypedInlineFragmentWeight to 0.0) + + (VariableWeight to 0.0) + +private val documentTestSchema = schema( + """ + directive @dir(arg: Int!) repeatable on QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | VARIABLE_DEFINITION + input Input { a: Int, b: [Int!]!, c: [[[Int]]] } + type Foo implements I1 { x: Int, next: Union, i1(input: Input!): I1! } + type Bar implements I1 & I2 { x: Int, y: Int, next: Union } + union Union = Foo | Bar + interface I1 { x: Int } + interface I2 { y: Int } + type Query { x(a: Int!): Int, union: Union, y(input: Input!): Union } + type Mutation { x: Int, y: Int } + type Subscription { x: Int, y: Int } + """.trimIndent() +) + +private fun schema(sdl: String): GraphQLSchema = + FastSchemaGenerator().makeExecutableSchema( + SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + +private suspend fun assertAllDocumentsValid( + schema: GraphQLSchema, + config: Config, + iterations: Int = 250 +) { + checkAll(PropTestConfig(iterations = iterations), Arb.graphQLDocument(schema, config)) { document -> + assertValid(schema, document) + } +} + +private fun assertValid( + schema: GraphQLSchema, + document: Document +) { + withClue(AstPrinter.printAst(document)) { + ParseAndValidate.validate(schema, document).shouldBeEmpty() + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputsTest.kt new file mode 100644 index 000000000..c0c895c6c --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputsTest.kt @@ -0,0 +1,73 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import graphql.ParseAndValidate +import graphql.parser.Parser +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.constant +import io.kotest.property.checkAll + +class GraphQLExecutionInputsTest : FunSpec({ + val schema = parseTestSchema( + """ + scalar Custom + input Input { a: Int, b: [Int!]!, c: Nested } + input Nested { value: String } + type Query { x(a: Int!): Int, custom(value: Custom!): Int, input(value: Input!): Int } + """.trimIndent() + ) + + test("AnonymousOperationWeight omits only the unambiguous operation name") { + val scalarOverride = ScalarValueOverrides to mapOf("Custom" to Arb.constant("custom-value")) + val oneOperation = Config.default + scalarOverride + + (OperationCount to 1..1) + + (AnonymousOperationWeight to 1.0) + checkAll(PropTestConfig(iterations = 50), Arb.graphQLExecutionInput(schema, oneOperation)) { input -> + input.operationName.shouldBe(null) + } + + val twoOperations = Config.default + scalarOverride + + (OperationCount to 2..2) + + (AnonymousOperationWeight to 1.0) + checkAll(PropTestConfig(iterations = 50), Arb.graphQLExecutionInput(schema, twoOperations)) { input -> + (input.operationName != null).shouldBe(true) + } + } + + test("ImplicitNullValueWeight omits only variables that may be omitted") { + val document = Parser().parseDocument( + "query Q(\$a: Boolean, \$b: Boolean = false, \$c: Boolean!, \$d: Boolean! = false) { x(a: 1) }" + ) + val omitted = Config.default + (ImplicitNullValueWeight to 1.0) + checkAll(PropTestConfig(iterations = 50), Arb.graphQLExecutionInput(schema, document, omitted)) { input -> + input.variables.keys.shouldBe(setOf("c")) + } + + val retained = Config.default + (ImplicitNullValueWeight to 0.0) + checkAll(PropTestConfig(iterations = 50), Arb.graphQLExecutionInput(schema, document, retained)) { input -> + input.variables.keys.shouldBe(setOf("a", "b", "c", "d")) + } + } + + test("custom scalar external values use ScalarValueOverrides") { + val document = Parser().parseDocument("query Q(\$value: Custom!) { custom(value: \$value) }") + val config = Config.default + + (ScalarValueOverrides to mapOf("Custom" to Arb.constant("custom-value"))) + checkAll(PropTestConfig(iterations = 20), Arb.graphQLExecutionInput(schema, document, config)) { input -> + input.variables.shouldBe(mapOf("value" to "custom-value")) + } + } + + test("generated ExecutionInputs parse and validate") { + val config = Config.default + + (ScalarValueOverrides to mapOf("Custom" to Arb.constant("custom-value"))) + checkAll(PropTestConfig(iterations = 200), Arb.graphQLExecutionInput(schema, config)) { input -> + ParseAndValidate.parseAndValidate(schema, input).errors.shouldBeEmpty() + } + } +}) 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 new file mode 100644 index 000000000..4881a2a2b --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExternalInputValueGeneratorTest.kt @@ -0,0 +1,82 @@ +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.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.constant + +class GraphQLExternalInputValueGeneratorTest : FunSpec({ + test("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("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("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("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("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/GraphQLGeneratorPropertyTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLGeneratorPropertyTest.kt new file mode 100644 index 000000000..e9fdbb348 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLGeneratorPropertyTest.kt @@ -0,0 +1,125 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import graphql.Scalars +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLType +import graphql.schema.GraphQLTypeUtil +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.of +import io.kotest.property.checkAll + +class GraphQLGeneratorPropertyTest : FunSpec({ + test("generated names satisfy the GraphQL name grammar") { + val namePattern = Regex("^[_A-Za-z][_0-9A-Za-z]*$") + checkAll(Arb.graphQLName()) { name -> + namePattern.matches(name).shouldBe(true) + name.startsWith("__").shouldBe(false) + name.shouldNotBe("Boolean") + name.shouldNotBe("Float") + name.shouldNotBe("ID") + name.shouldNotBe("Int") + name.shouldNotBe("String") + } + } + + test("generated enum names exclude reserved values") { + checkAll(Arb.graphQLEnumValueName()) { name -> + (name in setOf("true", "false", "null")).shouldBe(false) + } + } + + test("schema names are unique across type categories") { + checkAll(PropTestConfig(iterations = 250), Arb.int(1..100)) { size -> + val names = Arb.graphQLNames(Config.default + (SchemaSize to size)).bind() + val subtotal = names.interfaces.size + + names.objects.size + + names.inputs.size + + names.unions.size + + names.scalars.size + + names.enums.size + + names.directives.size + subtotal.shouldBe(names.allNames.size) + } + } + + test("zero type weights generate no arbitrary names") { + val config = Config.default + + (TypeTypeWeights to TypeTypeWeights.zero) + + (IncludeBuiltinScalars to false) + + (IncludeBuiltinDirectives to false) + checkAll(Arb.graphQLNames(config)) { names -> + names.allNames.shouldBeEmpty() + } + } + + test("type decorators honor deterministic list and non-null weights") { + val names = GraphQLNames(mapOf(TypeType.Scalar to setOf("Int"))) + val listDepth = Arb.int(0..4) + val nonNull = Arb.of(true, false) + checkAll(listDepth, nonNull) { depth, useNonNull -> + val config = Config.default + + (ListTypeWeight to CompoundingWeight(1.0, depth)) + + (NonNullableTypeWeight to if (useNonNull) 1.0 else 0.0) + val decorated = GraphQLTypesGen(config, names, io.kotest.property.RandomSource.default()) + .decorate(Scalars.GraphQLInt) + countLists(decorated).shouldBe(depth) + countNonNulls(decorated).shouldBe(if (useNonNull) depth + 1 else 0) + } + } + + test("generated oneOf inputs are nullable and inhabited") { + val config = Config.default + + (OneOfTypeWeight to 1.0) + + (SchemaSize to 30) + + (TypeTypeWeights to TypeTypeWeights.zero + (TypeType.Input to 1.0)) + checkAll(PropTestConfig(iterations = 100), Arb.graphQLTypes(config)) { types -> + types.inputs.values.forEach { input -> + input.isOneOf.shouldBe(true) + input.fields.forEach { field -> field.type.isNullable().shouldBe(true) } + input.fields.shouldNotBeEmpty() + } + } + } + + test("included types remain available to generated schemas") { + val included = GraphQLTypes.empty.copy( + scalars = mapOf(Scalars.GraphQLInt.name to Scalars.GraphQLInt) + ) + checkAll(Arb.graphQLTypes(Config.default + (IncludeTypes to included))) { types -> + types.scalars.keys.shouldContainAll(included.scalars.keys) + } + } +}) + +private fun GraphQLType.isNullable(): Boolean = this !is GraphQLNonNull + +private fun countLists(type: GraphQLType): Int { + var current = type + var count = 0 + while (GraphQLTypeUtil.isWrapped(current)) { + if (current is GraphQLList) count++ + current = GraphQLTypeUtil.unwrapOne(current) + } + return count +} + +private fun countNonNulls(type: GraphQLType): Int { + var current = type + var count = 0 + while (GraphQLTypeUtil.isWrapped(current)) { + if (current is GraphQLNonNull) count++ + current = GraphQLTypeUtil.unwrapOne(current) + } + return count +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt new file mode 100644 index 000000000..e93df68d8 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt @@ -0,0 +1,76 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.pair +import io.kotest.property.checkAll + +class GraphQLNamesTest : FunSpec({ + test("allNames is the union of every name bucket") { + checkAll(PropTestConfig(iterations = 100), Arb.int(1..200)) { count -> + val names = Arb.graphQLNames(Config.default + (SchemaSize to count)).bind() + val union = names.interfaces + names.objects + names.inputs + names.unions + + names.scalars + names.enums + names.directives + names.allNames.shouldBe(union) + } + } + + test("GenCustomScalars controls generated custom scalar names") { + listOf(true, false).forEach { include -> + val config = Config.default + (GenCustomScalars to include) + checkAll(PropTestConfig(iterations = 50), Arb.graphQLNames(config)) { names -> + if (!include) (names.scalars - builtinScalars.keys).shouldBeEmpty() + } + } + } + + test("zero weights produce no custom names") { + val config = Config.default + + (TypeTypeWeights to TypeTypeWeights.zero) + + (IncludeBuiltinScalars to false) + + (IncludeBuiltinDirectives to false) + checkAll(PropTestConfig(iterations = 20), Arb.graphQLNames(config)) { names -> + names.allNames.shouldBeEmpty() + } + } + + test("skewed weights control bucket populations") { + val weights = TypeTypeWeights.zero + (TypeType.Enum to 10.0) + (TypeType.Object to 1.0) + val configs = Arb.int(10..200).map { size -> + Config.default + (SchemaSize to size) + (TypeTypeWeights to weights) + } + checkAll(PropTestConfig(iterations = 100), configs) { config -> + val names = Arb.graphQLNames(config).bind() + names.unions.shouldBeEmpty() + (names.enums.size >= names.objects.size).shouldBe(true) + } + } + + test("BanDirectiveNames removes matching generated names") { + val config = Config.default + + (SchemaSize to 20) + + (TypeNameLength to 1..1) + + (TypeTypeWeights to (TypeTypeWeights.zero + (TypeType.Directive to 1.0))) + + (BanDirectiveNames to setOf("Directive_a", "Directive_b")) + checkAll(PropTestConfig(iterations = 100), Arb.graphQLNames(config)) { names -> + names.directives.intersect(config[BanDirectiveNames]).shouldBeEmpty() + } + } + + test("plus unions buckets and filter retains matching names") { + val pairs = Arb.pair(Arb.graphQLNames(), Arb.graphQLNames()) + checkAll(PropTestConfig(iterations = 100), pairs) { (first, second) -> + (first + second).allNames.shouldBe(first.allNames + second.allNames) + first.filter { true }.shouldBe(first) + first.filter { false }.shouldBe(GraphQLNames.empty) + } + } +}) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt new file mode 100644 index 000000000..ecc87536e --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt @@ -0,0 +1,93 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import graphql.language.FragmentDefinition +import graphql.language.SelectionSet +import graphql.parser.Parser +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLOutputType +import graphql.schema.GraphQLUnionType +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.constant +import io.kotest.property.checkAll + +class GraphQLOutputValuesTest : FunSpec({ + test("nullable and non-null scalar values honor ExplicitNullValueWeight") { + val schema = parseTestSchema("type Query { x: Int }") + val scalar = schema.getType("Int") as GraphQLOutputType + val nullConfig = Config.default + (ExplicitNullValueWeight to 1.0) + checkAll(PropTestConfig(iterations = 20), Arb.graphQLOutputValue(schema, scalar, null, config = nullConfig)) { + it.shouldBe(null) + } + val nonNull = GraphQLNonNull.nonNull(scalar) + checkAll(PropTestConfig(iterations = 20), Arb.graphQLOutputValue(schema, nonNull, null, config = nullConfig)) { + it.shouldBeInstanceOf() + } + } + + test("list wrappers preserve configured size and element nullability") { + 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) + checkAll(PropTestConfig(iterations = 20), Arb.graphQLOutputValue(schema, type, null, config = config)) { value -> + val list = value.shouldBeInstanceOf>() + list.size.shouldBe(2) + list.all { it is Int }.shouldBe(true) + } + } + + test("objects honor aliases, inline fragments, and named fragments") { + val schema = parseTestSchema("type Obj { x: Int!, y: Int! } type Query { obj: Obj! }") + 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) + checkAll(PropTestConfig(iterations = 30), Arb.graphQLOutputValue(schema, document, config)) { root -> + root.typeName.shouldBe("Query") + val obj = root.fields.getValue("obj").shouldBeInstanceOf() + obj.fields.keys.toList().shouldContainExactly("alias", "y", "__typename") + obj.fields["__typename"].shouldBe("Obj") + } + } + + test("SelectedTypeBias chooses a concrete type selected by a fragment") { + val schema = parseTestSchema( + "type A { x: Int } type B { x: Int } union U = A | B type Query { u: U }" + ) + 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) + checkAll(PropTestConfig(iterations = 50), Arb.graphQLOutputValue(schema, union, selections, config = config)) { value -> + value.shouldBeInstanceOf().typeName.shouldBe("A") + } + } + + test("custom scalar values use ScalarValueOverrides") { + 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"))) + checkAll(PropTestConfig(iterations = 20), Arb.graphQLOutputValue(schema, scalar, null, config = config)) { + it.shouldBe("custom-value") + } + } +}) + +private fun outputSelections(fragment: String): SelectionSet = + Parser().parseDocument(fragment) + .getDefinitionsOfType(FragmentDefinition::class.java) + .single() + .selectionSet diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt new file mode 100644 index 000000000..fa723f949 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt @@ -0,0 +1,103 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import graphql.ExceptionWhileDataFetching +import graphql.execution.NonNullableFieldWasNullError +import graphql.Scalars +import graphql.schema.GraphQLScalarType +import graphql.schema.idl.RuntimeWiring +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.constant +import io.kotest.property.arbitrary.long +import io.kotest.property.checkAll + +class GraphQLRuntimeWiringsTest : FunSpec({ + test("the same seed and input always return the same result") { + val schema = parseTestSchema( + "interface I { x: Int } type A implements I { x: Int } type Query { x: Int, values: [Int], i: I }" + ) + val input = graphql.ExecutionInput.newExecutionInput() + .query("{ x values i { __typename x } }") + .build() + checkAll(PropTestConfig(iterations = 50), Arb.long()) { seed -> + val graphQL = arbitraryGraphQL(schema, seed) + val results = List(5) { graphQL.execute(input).toSpecification() } + results.distinct().size.shouldBe(1) + } + } + + test("different response keys are independently salted") { + val schema = parseTestSchema("type Query { x: Int! }") + checkAll(PropTestConfig(iterations = 50), Arb.long()) { seed -> + val result = arbitraryGraphQL(schema, seed).execute("{ x a: x b: x c: x }") + val data = requireNotNull(result.getData>()) + data.size.shouldBe(4) + (data.values.distinct().size > 1).shouldBe(true) + } + } + + test("abstract resolvers return concrete schema instances and __typename") { + val schema = parseTestSchema( + "type A { x: Int } type B { x: Int } union U = A | B type Query { u: U }" + ) + val config = Config.default + (ExplicitNullValueWeight to 0.0) + checkAll(PropTestConfig(iterations = 50), Arb.long()) { seed -> + val result = arbitraryGraphQL(schema, seed, config).execute("{ u { __typename } }") + result.errors.shouldBeEmpty() + val data = requireNotNull(result.getData>>()) + (data.getValue("u").getValue("__typename") in setOf("A", "B")).shouldBe(true) + } + } + + test("NullNonNullableWeight injects non-null failures") { + val schema = parseTestSchema("type Query { x: Int! }") + val disabled = Config.default + (NullNonNullableWeight to 0.0) + arbitraryGraphQL(schema, 1, disabled).execute("{ x }").errors.shouldBeEmpty() + + val enabled = Config.default + (NullNonNullableWeight to 1.0) + val errors = arbitraryGraphQL(schema, 1, enabled).execute("{ x }").errors + errors.any { it is NonNullableFieldWasNullError }.shouldBe(true) + } + + test("ExplicitNullValueWeight and ListValueSize shape fetched values") { + val schema = parseTestSchema("type Query { nullable: Int, values: [Int!]! }") + val config = Config.default + + (ExplicitNullValueWeight to 1.0) + + (ListValueSize to 3..3) + val result = arbitraryGraphQL(schema, 2, config).execute("{ nullable values }") + val data = requireNotNull(result.getData>()) + data["nullable"].shouldBe(null) + (data["values"] as List<*>).size.shouldBe(3) + } + + test("ResolverExceptionWeight injects observable resolver failures") { + val schema = parseTestSchema("type Query { x: Int }") + val disabled = Config.default + (ResolverExceptionWeight to 0.0) + arbitraryGraphQL(schema, 3, disabled).execute("{ x }").errors.shouldBeEmpty() + + val enabled = Config.default + (ResolverExceptionWeight to 1.0) + val errors = arbitraryGraphQL(schema, 3, enabled).execute("{ x }").errors + errors.shouldNotBeEmpty() + val exception = (errors.first() as ExceptionWhileDataFetching).exception + (exception is ResolverInjectedException).shouldBe(true) + } + + test("custom scalar outputs use ScalarValueOverrides") { + val customScalar = GraphQLScalarType.newScalar(Scalars.GraphQLString) + .name("Custom") + .build() + val wiring = RuntimeWiring.newRuntimeWiring().scalar(customScalar).build() + val schema = parseTestSchema("scalar Custom type Query { x: Custom! }", wiring) + val config = Config.default + + (ScalarValueOverrides to mapOf("Custom" to Arb.constant("custom-value"))) + val result = arbitraryGraphQL(schema, 4, config).execute("{ x }") + result.errors.shouldBeEmpty() + result.getData>().shouldBe(mapOf("x" to "custom-value")) + } +}) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaDirectiveCycleTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaDirectiveCycleTest.kt new file mode 100644 index 000000000..81d0c90b1 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaDirectiveCycleTest.kt @@ -0,0 +1,143 @@ +package graphql.schema.property + +import graphql.Scalars +import graphql.introspection.Introspection.DirectiveLocation +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLDirective +import graphql.schema.GraphQLEnumType +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLInputObjectField +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLSchema +import graphql.schema.GraphQLTypeReference +import graphql.schema.SchemaTransformer +import graphql.schema.idl.SchemaPrinter +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.property.RandomSource + +class GraphQLSchemaDirectiveCycleTest : FunSpec({ + test("direct dependencies between directive arguments are one-way") { + val schema = directiveControlSchema( + directives = listOf( + controlDirective("a", Scalars.GraphQLInt, DirectiveLocation.ARGUMENT_DEFINITION), + controlDirective("b", Scalars.GraphQLInt, DirectiveLocation.ARGUMENT_DEFINITION) + ) + ).withArbitraryAppliedDirectives() + + val aHasB = schema.directiveArgumentDirectives("a").contains("b") + val bHasA = schema.directiveArgumentDirectives("b").contains("a") + (aHasB xor bHasA).shouldBe(true) + SchemaPrinter().print(schema) + } + + test("dependencies through directive argument input fields are one-way") { + val input = GraphQLInputObjectType.newInputObject() + .name("InputA") + .field( + GraphQLInputObjectField.newInputObjectField() + .name("field") + .type(GraphQLNonNull.nonNull(Scalars.GraphQLInt)) + ) + .build() + val schema = directiveControlSchema( + directives = listOf( + controlDirective("a", GraphQLTypeReference.typeRef("InputA"), DirectiveLocation.ARGUMENT_DEFINITION), + controlDirective("b", Scalars.GraphQLInt, DirectiveLocation.INPUT_FIELD_DEFINITION) + ), + inputs = listOf(input) + ).withArbitraryAppliedDirectives() + + val fieldHasB = (schema.getType("InputA") as GraphQLInputObjectType) + .getFieldDefinition("field") + .appliedDirectives + .any { it.name == "b" } + val bHasA = schema.directiveArgumentDirectives("b").contains("a") + (fieldHasB xor bHasA).shouldBe(true) + SchemaPrinter().print(schema) + } + + test("dependencies through directive argument enum values are one-way") { + val enum = GraphQLEnumType.newEnum().name("E").value("X").build() + val schema = directiveControlSchema( + directives = listOf( + controlDirective("a", GraphQLTypeReference.typeRef("E"), DirectiveLocation.ARGUMENT_DEFINITION), + controlDirective("b", Scalars.GraphQLInt, DirectiveLocation.ENUM_VALUE) + ), + enums = listOf(enum) + ).withArbitraryAppliedDirectives() + + val valueHasB = requireNotNull((schema.getType("E") as GraphQLEnumType).getValue("X")) + .appliedDirectives + .any { it.name == "b" } + val bHasA = schema.directiveArgumentDirectives("b").contains("a") + (valueHasB xor bHasA).shouldBe(true) + SchemaPrinter().print(schema) + } +}) + +private fun GraphQLSchema.withArbitraryAppliedDirectives(): GraphQLSchema { + val config = Config.default + + (AppliedDirectiveWeight to CompoundingWeight.Always) + + (DirectiveIsRepeatable to 0.0) + + (DefaultValueWeight to 0.0) + val randomSource = RandomSource.seeded(0) + val generator = GraphQLInputValueGenerator( + schema = this, + config = config, + randomSource = randomSource, + uncoercedValueWeight = 0.0, + allEdgesGraph = CycleGroups.allInputCycles(this), + mandatoryEdgesGraph = CycleGroups.mandatoryInputCycles(this) + ) + return SchemaTransformer.transformSchema( + this, + AddAppliedDirectives(this, generator, config, randomSource) + ) +} + +private fun GraphQLSchema.directiveArgumentDirectives(directiveName: String): Set { + val directive = requireNotNull(getDirective(directiveName)) + val argument = requireNotNull(directive.getArgument("arg")) + return argument.appliedDirectives.mapTo(linkedSetOf()) { it.name } +} + +private fun directiveControlSchema( + directives: List, + inputs: List = emptyList(), + enums: List = emptyList() +): GraphQLSchema { + val query = GraphQLObjectType.newObject() + .name("Query") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("placeholder") + .type(Scalars.GraphQLInt) + .argument(GraphQLArgument.newArgument().name("arg").type(Scalars.GraphQLInt)) + ) + .build() + val types = GraphQLTypes.empty.copy( + directives = directives.associateBy { it.name }, + objects = mapOf(query.name to query), + inputs = inputs.associateBy { it.name }, + enums = enums.associateBy { it.name } + ) + val config = Config.default + + (AppliedDirectiveWeight to CompoundingWeight.Never) + + (DefaultValueWeight to 0.0) + return SchemaGenerator(config, RandomSource.seeded(0)).createSchema(types) +} + +private fun controlDirective( + name: String, + argumentType: GraphQLInputType, + vararg locations: DirectiveLocation +): GraphQLDirective = + GraphQLDirective.newDirective() + .name(name) + .argument(GraphQLArgument.newArgument().name("arg").type(argumentType)) + .validLocations(*locations) + .build() diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaPropertyTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaPropertyTest.kt new file mode 100644 index 000000000..81f3e32a3 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaPropertyTest.kt @@ -0,0 +1,289 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import graphql.Directives +import graphql.Scalars +import graphql.introspection.Introspection +import graphql.language.StringValue +import graphql.language.NullValue +import graphql.language.ObjectValue +import graphql.language.Value +import graphql.schema.GraphQLAppliedDirective +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLDirective +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLSchema +import graphql.schema.GraphQLSchemaElement +import graphql.schema.GraphQLTypeVisitorStub +import graphql.schema.SchemaTraverser +import graphql.schema.idl.FastSchemaGenerator +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.SchemaParser +import graphql.schema.idl.SchemaPrinter +import graphql.schema.validation.SchemaValidator +import graphql.util.TraversalControl +import graphql.util.TraverserContext +import io.kotest.assertions.withClue +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.RandomSource +import io.kotest.property.checkAll +import io.kotest.property.arbitrary.long + +class GraphQLSchemaPropertyTest : FunSpec({ + test("generated schemas are valid") { + checkAll(PropTestConfig(iterations = 100), Arb.generatedGraphQLSchema()) { generated -> + withClue(generated.sdl) { + SchemaValidator().validateSchema(generated.schema).shouldBeEmpty() + } + } + } + + test("generated schemas round trip through SDL") { + checkAll(PropTestConfig(iterations = 100), Arb.generatedGraphQLSchema()) { generated -> + withClue(generated.sdl) { + val registry = SchemaParser().parse(generated.sdl) + val rebuilt = FastSchemaGenerator() + .makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING) + SchemaValidator().validateSchema(rebuilt).shouldBeEmpty() + SchemaPrinter().print(rebuilt).shouldBe(generated.sdl) + } + } + } + + test("default values are generated and remain valid") { + val config = Config.default + (DefaultValueWeight to 1.0) + checkAll(PropTestConfig(iterations = 50), Arb.generatedGraphQLSchema(config)) { generated -> + withClue(generated.sdl) { + val collector = DefaultValueCollector() + SchemaTraverser().depthFirstFullSchema(collector, generated.schema) + collector.values.shouldNotBeEmpty() + collector.values.all { it }.shouldBe(true) + SchemaValidator().validateSchema(generated.schema).shouldBeEmpty() + } + } + } + + test("oneOf input fields never receive defaults") { + val config = Config.default + + (OneOfTypeWeight to 1.0) + + (DefaultValueWeight to 1.0) + + (TypeTypeWeights to TypeTypeWeights.zero + (TypeType.Input to 1.0)) + + checkAll(PropTestConfig(iterations = 50), Arb.generatedGraphQLSchema(config)) { generated -> + withClue(generated.sdl) { + generated.schema.allTypesAsList + .filterIsInstance() + .filter { it.isOneOf } + .flatMap { it.fields } + .all { !it.hasSetDefaultValue() } + .shouldBe(true) + } + } + } + + test("cyclic nullable inputs receive finite non-trivial defaults") { + val seedSchema = parseTestSchema( + "input Recursive { next: Recursive } type Query { x(value: Recursive): Int }" + ) + val recursive = seedSchema.getType("Recursive") as GraphQLInputObjectType + val types = GraphQLTypes.empty.copy(inputs = mapOf(recursive.name to recursive)) + val config = Config.default + + (DefaultValueWeight to 1.0) + + (ExplicitNullValueWeight to 0.0) + + (MaxValueDepth to 3) + checkAll(PropTestConfig(iterations = 50), Arb.graphQLSchema(types, config)) { schema -> + val input = schema.getType("Recursive") as GraphQLInputObjectType + val defaultValue = input.getFieldDefinition("next").inputFieldDefaultValue.value as ObjectValue + defaultValue.objectFields.shouldNotBeEmpty() + defaultValue.objectFields.none { it.value is NullValue }.shouldBe(true) + } + } + + test("applied directives are generated without invalid dependency cycles") { + val testDirective = GraphQLDirective.newDirective() + .name("testDirective") + .argument( + GraphQLArgument.newArgument() + .name("arg") + .type(Scalars.GraphQLInt) + ) + .validLocations(*Introspection.DirectiveLocation.entries.toTypedArray()) + .build() + val config = Config.default + + (IncludeTypes to (GraphQLTypes.empty + testDirective)) + + (AppliedDirectiveWeight to CompoundingWeight.Once) + + (BanDirectiveNames to builtinDirectives.keys) + + (DefaultValueWeight to 0.0) + + val disabled = config + (AppliedDirectiveWeight to CompoundingWeight.Never) + checkAll(PropTestConfig(iterations = 20), Arb.graphQLSchema(disabled)) { schema -> + val collector = AppliedDirectiveCollector() + SchemaTraverser().depthFirstFullSchema(collector, schema) + collector.directives.none { it.name == testDirective.name }.shouldBe(true) + } + + checkAll(PropTestConfig(iterations = 50), Arb.generatedGraphQLSchema(config)) { generated -> + withClue(generated.sdl) { + val collector = AppliedDirectiveCollector() + SchemaTraverser().depthFirstFullSchema(collector, generated.schema) + collector.directives.filter { it.name == testDirective.name }.shouldNotBeEmpty() + SchemaValidator().validateSchema(generated.schema).shouldBeEmpty() + } + } + } + + test("applied directives are not added to introspection elements") { + val config = Config.default + + (AppliedDirectiveWeight to CompoundingWeight.Once) + + (DefaultValueWeight to 0.0) + + checkAll(PropTestConfig(iterations = 20), Arb.graphQLSchema(config)) { schema -> + val collector = AppliedDirectiveCollector() + listOf( + schema.introspectionSchemaType, + schema.introspectionSchemaFieldDefinition, + schema.introspectionTypenameFieldDefinition, + schema.introspectionTypeFieldDefinition + ).forEach { SchemaTraverser().depthFirst(collector, it) } + collector.directives.shouldBeEmpty() + } + } + + test("generated input types contain no mandatory cycles") { + val config = Config.default + + (NonNullableTypeWeight to 1.0) + + (ListTypeWeight to CompoundingWeight.Never) + + (OneOfTypeWeight to 0.0) + + (DefaultValueWeight to 0.0) + + checkAll(PropTestConfig(iterations = 100), Arb.graphQLSchema(config)) { schema -> + CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) + } + } + + test("uncoerced defaults exercise GraphQL input coercion") { + val config = Config.default + + (DefaultValueWeight to 1.0) + + (SchemaUncoercedValueWeight to 1.0) + checkAll(PropTestConfig(iterations = 25), Arb.generatedGraphQLSchema(config)) { generated -> + withClue(generated.sdl) { + SchemaValidator().validateSchema(generated.schema).shouldBeEmpty() + } + } + } + + test("single-value coercion cannot null a non-null list") { + val listType = GraphQLNonNull.nonNull(GraphQLList.list(Scalars.GraphQLString)) as GraphQLInputType + val config = Config.default + (ExplicitNullValueWeight to 1.0) + checkAll(Arb.long()) { seed -> + val generator = GraphQLInputValueGenerator( + schema = minimalSchema(), + config = config, + randomSource = RandomSource.seeded(seed), + uncoercedValueWeight = 1.0 + ) + generator.generate(listType).shouldBeInstanceOf() + } + } + + test("single-value coercion preserves nested list boundaries") { + val innerList = GraphQLNonNull.nonNull(GraphQLList.list(Scalars.GraphQLString)) + val listType = GraphQLNonNull.nonNull(GraphQLList.list(innerList)) as GraphQLInputType + val config = Config.default + + (ExplicitNullValueWeight to 1.0) + + (ListValueSize to 3..3) + checkAll(Arb.long()) { seed -> + val generator = GraphQLInputValueGenerator( + schema = minimalSchema(), + config = config, + randomSource = RandomSource.seeded(seed), + uncoercedValueWeight = 1.0 + ) + schemaWithDefault(listType, generator.generate(listType)) + } + } +}) + +private fun minimalSchema(): GraphQLSchema = + GraphQLSchema.newSchema() + .query( + GraphQLObjectType.newObject() + .name("Query") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("field") + .type(Scalars.GraphQLString) + ) + ) + .build() + +private fun schemaWithDefault( + type: GraphQLInputType, + value: Value<*> +): GraphQLSchema = + GraphQLSchema.newSchema() + .query( + GraphQLObjectType.newObject() + .name("Query") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("field") + .type(Scalars.GraphQLString) + .argument( + GraphQLArgument.newArgument() + .name("argument") + .type(type) + .defaultValueLiteral(value) + ) + ) + ) + .build() + +private class AppliedDirectiveCollector : GraphQLTypeVisitorStub() { + val directives = mutableListOf() + + override fun visitGraphQLAppliedDirective( + node: GraphQLAppliedDirective, + context: TraverserContext + ): TraversalControl { + directives += node + return TraversalControl.CONTINUE + } +} + +private class DefaultValueCollector : GraphQLTypeVisitorStub() { + val values = mutableListOf() + + override fun visitGraphQLArgument( + node: GraphQLArgument, + context: TraverserContext + ): TraversalControl { + val parent = context.parentNode + if (parent is GraphQLDirective && parent.name in builtinDirectives) return TraversalControl.CONTINUE + values += node.hasSetDefaultValue() + return TraversalControl.CONTINUE + } + + override fun visitGraphQLInputObjectField( + node: graphql.schema.GraphQLInputObjectField, + context: TraverserContext + ): TraversalControl { + val parent = context.parentNode + if (parent !is GraphQLInputObjectType || !parent.isOneOf) { + values += node.hasSetDefaultValue() + } + return TraversalControl.CONTINUE + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt new file mode 100644 index 000000000..995cf14ca --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt @@ -0,0 +1,66 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import graphql.Scalars +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLSchema +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.checkAll + +class GraphQLStringsTest : FunSpec({ + test("graphQLName generates spec-compliant non-introspection names") { + val pattern = Regex("^[_A-Za-z][_0-9A-Za-z]*$") + checkAll(PropTestConfig(iterations = 500), Arb.graphQLName(1..40)) { name -> + name.matches(pattern).shouldBe(true) + name.startsWith("__").shouldBe(false) + } + } + + test("graphQLName produces valid type names") { + val placeholder = GraphQLFieldDefinition.newFieldDefinition() + .name("placeholder") + .type(Scalars.GraphQLInt) + .build() + checkAll(PropTestConfig(iterations = 100), Arb.graphQLName()) { name -> + val query = GraphQLObjectType.newObject().name(name).field(placeholder).build() + runCatching { GraphQLSchema.newSchema().query(query).build() }.isSuccess.shouldBe(true) + } + } + + test("field and argument names are valid and start lower-case when alphabetic") { + val generators = listOf(Arb.graphQLFieldName(), Arb.graphQLArgumentName()) + generators.forEach { generator -> + checkAll(PropTestConfig(iterations = 100), generator) { name -> + name.startsWith("__").shouldBe(false) + val first = name.first() + (!first.isLetter() || first.isLowerCase()).shouldBe(true) + } + } + } + + test("enum value names exclude reserved values") { + checkAll(PropTestConfig(iterations = 200), Arb.graphQLEnumValueName()) { name -> + (name !in setOf("true", "false", "null") && !name.startsWith("__")).shouldBe(true) + } + } + + test("BanFieldNames affects all field-like name generators") { + val banned = ('a'..'m').flatMap { listOf(it.toString(), it.uppercase()) }.toSet() + val config = Config.default + (BanFieldNames to banned) + (FieldNameLength to 1..2) + val generators = listOf( + Arb.graphQLFieldName(config), + Arb.graphQLArgumentName(config), + Arb.graphQLEnumValueName(config) + ) + generators.forEach { generator -> + checkAll(PropTestConfig(iterations = 100), generator) { name -> + (name !in banned).shouldBe(true) + } + } + } +}) 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 new file mode 100644 index 000000000..2538ba220 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTestDataTest.kt @@ -0,0 +1,51 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import graphql.ParseAndValidate +import graphql.schema.idl.SchemaParser +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.checkAll + +class GraphQLTestDataTest : FunSpec({ + test("graphQLTestData generates round-trippable schemas and valid queries") { + val config = Config.default + (SchemaSize to 10) + checkAll(PropTestConfig(iterations = 30), Arb.graphQLTestData(config)) { 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("minimum query length filters generated test data") { + val config = Config.default + + (SchemaSize to 5) + + (MinQueryLength to 10) + checkAll(PropTestConfig(iterations = 10), Arb.graphQLTestData(config)) { data -> + (data.query.length >= 10).shouldBe(true) + } + } + + test("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/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt new file mode 100644 index 000000000..ae282f289 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt @@ -0,0 +1,320 @@ +@file:OptIn(io.kotest.common.ExperimentalKotest::class) + +package graphql.schema.property + +import graphql.Scalars +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLScalarType +import graphql.schema.GraphQLType +import graphql.schema.GraphQLTypeReference +import graphql.schema.GraphQLTypeUtil +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.flatMap +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.long +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.of +import io.kotest.property.arbitrary.pair +import io.kotest.property.arbitrary.set +import io.kotest.property.checkAll + +class GraphQLTypesTest : FunSpec({ + test("GraphQLTypesGen generates requested directives") { + val cases = Arb.set(Arb.graphQLName(), 1..10).flatMap { directiveNames -> + val names = GraphQLNames(mapOf(TypeType.Directive to directiveNames)) + Arb.graphQLTypes(names, minimalTypesConfig).map { directiveNames to it } + } + checkAll(typesPropertyConfig, cases) { (directiveNames, types) -> + types.directives.keys.shouldContainAll(directiveNames) + } + } + + test("DirectiveIsRepeatable controls custom directives") { + val cases = Arb.of(0.0, 1.0).flatMap { weight -> + val config = minimalTypesConfig + + (DirectiveIsRepeatable to weight) + + (IncludeBuiltinDirectives to false) + Arb.graphQLTypes(config).map { weight to it } + } + checkAll(typesPropertyConfig, cases) { (weight, types) -> + types.directives.values.all { it.isRepeatable == (weight == 1.0) }.shouldBe(true) + } + } + + test("ListTypeWeight and NonNullableTypeWeight decorate types") { + val compoundingWeights = Arb.pair(Arb.of(0.0, 1.0), Arb.int(0..5)) + .map { (weight, maximum) -> CompoundingWeight(weight, maximum) } + checkAll(typesPropertyConfig, compoundingWeights, Arb.of(0.0, 1.0), Arb.long()) { + listWeight, nonNullWeight, seed -> + val names = GraphQLNames(mapOf(TypeType.Scalar to setOf("Int"))) + val config = minimalTypesConfig + + (ListTypeWeight to listWeight) + + (NonNullableTypeWeight to nonNullWeight) + val type = GraphQLTypesGen(config, names, RandomSource.seeded(seed)) + .decorate(Scalars.GraphQLInt) + val (lists, nonNulls) = type.decorationCounts() + lists.shouldBe(if (listWeight.weight == 1.0) listWeight.max else 0) + nonNulls.shouldBe(if (nonNullWeight == 1.0) lists + 1 else 0) + } + } + + test("DescriptionLength controls generated descriptions") { + checkAll(typesPropertyConfig, Arb.int(0..50), Arb.long()) { length, seed -> + val config = minimalTypesConfig + (DescriptionLength to length..length) + GraphQLTypesGen(config, GraphQLNames.empty, RandomSource.seeded(seed)) + .genDescription() + .length + .shouldBe(length) + } + } + + test("FieldArgumentWeight controls field arguments") { + val cases = Arb.pair(Arb.of(0.0, 1.0), Arb.int(0..3)).flatMap { (weight, maximum) -> + val compoundingWeight = CompoundingWeight(weight, maximum) + val config = minimalTypesConfig + (FieldArgumentWeight to compoundingWeight) + Arb.graphQLTypes(config).map { compoundingWeight to it } + } + checkAll(typesPropertyConfig, cases) { (weight, types) -> + val fields = (types.objects.values + types.interfaces.values).flatMap { it.fields } + if (weight.weight == 1.0 && weight.max > 0 && fields.isNotEmpty()) { + fields.any { it.arguments.isNotEmpty() }.shouldBe(true) + } else { + fields.all { it.arguments.isEmpty() }.shouldBe(true) + } + } + } + + test("IncludeBuiltinScalars controls built-in scalars") { + val cases = Arb.of(true, false).flatMap { include -> + val config = minimalTypesConfig + (IncludeBuiltinScalars to include) + Arb.graphQLTypes(config).map { include to it } + } + checkAll(typesPropertyConfig, cases) { (include, types) -> + val present = builtinScalars.keys.intersect(types.scalars.keys) + if (include) present.shouldContainAll(builtinScalars.keys) else present.shouldBeEmpty() + } + } + + test("IncludeBuiltinDirectives controls built-in directives") { + val cases = Arb.of(true, false).flatMap { include -> + val config = minimalTypesConfig + (IncludeBuiltinDirectives to include) + Arb.graphQLTypes(config).map { include to it } + } + checkAll(typesPropertyConfig, cases) { (include, types) -> + val present = builtinDirectives.keys.intersect(types.directives.keys) + if (include) present.shouldContainAll(builtinDirectives.keys) else present.shouldBeEmpty() + } + } + + test("DirectiveHasArgs controls directive arguments") { + val cases = Arb.pair(Arb.of(0.0, 1.0), Arb.int(0..3)).flatMap { (weight, maximum) -> + val compoundingWeight = CompoundingWeight(weight, maximum) + val config = minimalTypesConfig + + (DirectiveHasArgs to compoundingWeight) + + (IncludeBuiltinDirectives to false) + Arb.graphQLTypes(config).map { compoundingWeight to it } + } + checkAll(typesPropertyConfig, cases) { (weight, types) -> + if (weight.weight == 0.0 || weight.max == 0) { + types.directives.values.all { it.arguments.isEmpty() }.shouldBe(true) + } else if (types.directives.isNotEmpty()) { + types.directives.values.any { it.arguments.isNotEmpty() }.shouldBe(true) + } + } + } + + test("FieldNameLength controls field-like names") { + val cases = Arb.int(2..20).flatMap { length -> + val config = minimalTypesConfig + (FieldNameLength to length..length) + Arb.graphQLTypes(config).map { length to it } + } + checkAll(typesPropertyConfig, cases) { (length, types) -> + val names = types.objects.values.flatMap { it.fields }.map { it.name } + + types.inputs.values.flatMap { it.fields }.map { it.name } + + types.enums.values.flatMap { it.values }.map { it.name } + names.filterNot { it.startsWith("escape_") }.all { it.length == length }.shouldBe(true) + } + } + + test("InputObjectTypeSize is an upper bound") { + val cases = Arb.int(1..10).flatMap { size -> + val config = minimalTypesConfig + (InputObjectTypeSize to size..size) + Arb.graphQLTypes(config).map { size to it } + } + checkAll(typesPropertyConfig, cases) { (size, types) -> + types.inputs.values.all { it.fields.size <= size }.shouldBe(true) + } + } + + test("InterfaceTypeSize is an upper bound") { + val cases = Arb.int(1..10).flatMap { size -> + val config = minimalTypesConfig + (InterfaceTypeSize to size..size) + Arb.graphQLTypes(config).map { size to it } + } + checkAll(typesPropertyConfig, cases) { (size, types) -> + types.interfaces.values.all { it.fields.size <= size }.shouldBe(true) + } + } + + test("ObjectTypeSize is an upper bound") { + val cases = Arb.int(1..10).flatMap { size -> + val config = minimalTypesConfig + (ObjectTypeSize to size..size) + Arb.graphQLTypes(config).map { size to it } + } + checkAll(typesPropertyConfig, cases) { (size, types) -> + types.objects.values.all { it.fields.size <= size }.shouldBe(true) + } + } + + test("UnionTypeSize is an upper bound") { + val cases = Arb.int(1..10).flatMap { size -> + val config = minimalTypesConfig + (UnionTypeSize to size..size) + Arb.graphQLTypes(config).map { size to it } + } + checkAll(typesPropertyConfig, cases) { (size, types) -> + types.unions.values.all { it.types.size <= size }.shouldBe(true) + } + } + + test("OneOfTypeWeight creates nullable oneOf fields without defaults") { + checkAll(typesPropertyConfig, Arb.graphQLTypes(minimalTypesConfig + (OneOfTypeWeight to 1.0))) { types -> + types.inputs.values.all { input -> + input.isOneOf && input.fields.all { field -> + GraphQLTypeUtil.isNullable(field.type) && !field.hasSetDefaultValue() + } + }.shouldBe(true) + } + } + + test("enum values use their names as backing values") { + val names = GraphQLNames(mapOf(TypeType.Enum to setOf("E"))) + checkAll(typesPropertyConfig, Arb.graphQLTypes(names, minimalTypesConfig + (EnumTypeSize to 1..3))) { types -> + types.enums.values.all { enum -> enum.values.all { it.value == it.name } }.shouldBe(true) + } + } + + test("ObjectImplementsInterface controls object interfaces") { + val names = GraphQLNames( + mapOf( + TypeType.Interface to setOf("I"), + TypeType.Object to setOf("O"), + TypeType.Scalar to setOf("Int") + ) + ) + checkAll(typesPropertyConfig, Arb.of(false, true), Arb.long()) { enabled, seed -> + val config = minimalTypesConfig + + (ObjectImplementsInterface to if (enabled) CompoundingWeight.Once else CompoundingWeight.Never) + val types = GraphQLTypesGen(config, names, RandomSource.seeded(seed)).gen() + types.objects.values.all { it.interfaces.isNotEmpty() == enabled }.shouldBe(true) + } + } + + test("resolve finds present references") { + val cases = Arb.graphQLNames().flatMap { names -> + Arb.graphQLTypes(names, minimalTypesConfig).map { names to it } + } + checkAll(typesPropertyConfig, cases) { (names, types) -> + names.names + .filterKeys { it != TypeType.Directive } + .values + .flatten() + .map(GraphQLTypeReference::typeRef) + .all { types.resolve(it) != null } + .shouldBe(true) + } + } + + test("resolve rejects missing references") { + checkAll(typesPropertyConfig, Arb.graphQLTypes(minimalTypesConfig), Arb.graphQLName()) { types, name -> + if (name !in types.names) { + (types.resolve(GraphQLTypeReference.typeRef(name)) == null).shouldBe(true) + } + } + } + + test("non-null recursive inputs receive escape fields") { + val names = GraphQLNames(mapOf(TypeType.Input to setOf("A"))) + val config = minimalTypesConfig + (NonNullableTypeWeight to 1.0) + checkAll(typesPropertyConfig, Arb.graphQLTypes(names, config)) { types -> + types.inputs.getValue("A").fields.any { + it.name.startsWith("escape_") && it.type is GraphQLScalarType + }.shouldBe(true) + } + } + + test("uninhabited oneOf inputs receive escape fields") { + val names = GraphQLNames(mapOf(TypeType.Input to setOf("A"))) + val config = minimalTypesConfig + (OneOfTypeWeight to 1.0) + checkAll(typesPropertyConfig, Arb.graphQLTypes(names, config)) { types -> + types.inputs.getValue("A").fields.any { + it.name.startsWith("escape_") && it.type is GraphQLScalarType + }.shouldBe(true) + } + } + + test("IncludeTypes retains every included type") { + val cases = Arb.graphQLTypes(minimalTypesConfig).flatMap { included -> + val config = minimalTypesConfig + (IncludeTypes to included) + Arb.graphQLTypes(config).map { included to it } + } + checkAll(typesPropertyConfig, cases) { (included, generated) -> + generated.names.containsAll(included.names).shouldBe(true) + } + } + + test("GenInterfaceStubsIfNeeded implements every interface") { + val config = minimalTypesConfig + (GenInterfaceStubsIfNeeded to true) + checkAll(typesPropertyConfig, Arb.graphQLTypes(config)) { types -> + val implemented = types.objects.values.flatMap { objectType -> + objectType.interfaces.map { it.name } + } + (types.interfaces.keys - implemented.toSet()).shouldBeEmpty() + } + } +}) + +private val typesPropertyConfig = PropTestConfig(iterations = 100) + +/** + * Most generation features are disabled so each property can enable only the behavior it tests. + */ +private val minimalTypesConfig = 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 fun GraphQLType.decorationCounts(): Pair { + var lists = 0 + var nonNulls = 0 + var current = this + while (GraphQLTypeUtil.isWrapped(current)) { + when (current) { + is GraphQLList -> lists++ + is GraphQLNonNull -> nonNulls++ + } + current = GraphQLTypeUtil.unwrapOne(current) + } + return lists to nonNulls +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt new file mode 100644 index 000000000..d84319ead --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt @@ -0,0 +1,15 @@ +package graphql.schema.property + +import graphql.schema.GraphQLSchema +import graphql.schema.idl.FastSchemaGenerator +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.SchemaParser + +internal fun parseTestSchema( + sdl: String, + runtimeWiring: RuntimeWiring = RuntimeWiring.MOCKED_WIRING +): GraphQLSchema = + FastSchemaGenerator().makeExecutableSchema( + SchemaParser().parse(sdl), + runtimeWiring + ) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtils.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtils.kt new file mode 100644 index 000000000..489511b96 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtils.kt @@ -0,0 +1,128 @@ +package graphql.schema.property + +import io.kotest.property.Arb +import io.kotest.property.PropertyTesting +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 +import java.io.PrintStream + +/** + * Flatten this generator into a generator of its elements while preserving the order within + * every generated iterable. + * + * The underlying generator must eventually produce a non-empty iterable. + */ +internal fun Arb>.flatten(): Arb = FlattenArb(map { it.iterator() }) + +/** Transform this generator, dropping values for which [transform] returns null. */ +internal fun Arb.mapNotNull(transform: (T) -> R?): Arb = + map(transform).filter { it != null }.map { requireNotNull(it) } + +private class FlattenArb(private val underlying: Arb>) : Arb() { + private var chunk: Iterator? = null + + override fun edgecase(rs: RandomSource): Sample? { + val current = chunk + if (current != null && current.hasNext()) return null + val next = underlying.edgecase(rs)?.value ?: return null + if (!next.hasNext()) return null + chunk = next + return next.next().asSample() + } + + override fun sample(rs: RandomSource): Sample { + var current = chunk + while (current == null || !current.hasNext()) { + current = underlying.sample(rs).value + chunk = current + } + return current.next().asSample() + } +} + +/** Throw a property failure that includes [seed] when supplied. */ +internal fun failProperty( + message: String, + cause: Throwable? = null, + seed: Long? = null +): Nothing = + throw AssertionError( + buildString { + appendLine(if (seed == null) "Property failed" else "Property failed with seed $seed") + append(message) + }, + cause + ) + +/** An arbitrary generator paired with a property that its generated values must satisfy. */ +internal class CheckedArb( + val arb: Arb, + val check: (T) -> Unit +) { + /** Return the minimum violation found in [iterations] samples, if any. */ + fun minViolation( + comparator: Comparator, + rs: RandomSource, + iter: Int = PropertyTesting.defaultIterationCount, + printEvery: Int = 1_000, + out: PrintStream = System.out + ): Violation? = + arb.asSequence(rs) + .take(iter) + .foldIndexed(null as Violation?) { index, minimum, value -> + if (printEvery > 0 && index % printEvery == 0) out.println("Iteration $index...") + checkValue(value, minimum, comparator, rs.seed, index, out) + } + + /** March through deterministic seeds and return the first violation, if any. */ + fun seedMarch( + startingSeed: Long = 0, + maxIter: Int = 100_000_000, + printEvery: Int = 1_000, + out: PrintStream = System.out + ): Violation? { + repeat(maxIter) { index -> + val seed = startingSeed + index + if (printEvery > 0 && index % printEvery == 0) out.println("Seed $seed...") + var value: T? = null + try { + value = arb.next(RandomSource.seeded(seed)) + check(value) + } catch (error: Throwable) { + return Violation(value, error, seed) + } + } + return null + } + + @Suppress("UNCHECKED_CAST") + private fun checkValue( + value: T, + minimum: Violation?, + comparator: Comparator, + seed: Long, + index: Int, + out: PrintStream + ): Violation? { + return try { + check(value) + minimum + } catch (error: Throwable) { + if (minimum == null) return Violation(value, error, seed) + val minimumValue = minimum.value as T + if (comparator.compare(value, minimumValue) >= 0) return minimum + out.println("Found new min violation at iteration $index") + Violation(value, error, seed) + } + } +} + +/** A generated value that violated a property, with its error and source seed. */ +internal data class Violation(val value: T?, val err: Throwable, val seed: Long) + +/** Pair this generator with [check] for failure mining and seed marching. */ +internal fun Arb.withCheck(check: (T) -> Unit): CheckedArb = CheckedArb(this, check) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtilsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtilsTest.kt new file mode 100644 index 000000000..9e1c0cad6 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtilsTest.kt @@ -0,0 +1,70 @@ +package graphql.schema.property + +import graphql.ExecutionInput +import graphql.parser.Parser +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.constant +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.of +import java.io.ByteArrayOutputStream +import java.io.PrintStream + +class PropertyTestUtilsTest : FunSpec({ + test("flatten preserves order within each generated iterable") { + val values = Arb.constant(listOf(1, 2, 3)).flatten() + .asSequence(RandomSource.seeded(1)) + .take(9) + .toList() + values.shouldBe(listOf(1, 2, 3, 1, 2, 3, 1, 2, 3)) + } + + test("mapNotNull drops null transformations") { + val values = Arb.of(-2, -1, 0, 1, 2) + .mapNotNull { value -> value.takeIf { it > 0 } } + .asSequence(RandomSource.seeded(2)) + .take(30) + .toList() + values.all { it > 0 }.shouldBe(true) + } + + test("failProperty includes its seed and cause") { + val cause = IllegalStateException("cause") + val error = shouldThrow { failProperty("details", cause, 42) } + error.message.shouldContain("Property failed with seed 42") + error.message.shouldContain("details") + error.cause.shouldBe(cause) + } + + test("minViolation returns a failing generated value") { + val output = PrintStream(ByteArrayOutputStream()) + val violation = Arb.constant(7) + .withCheck { error("failure") } + .minViolation(Comparator.naturalOrder(), RandomSource.seeded(9), iter = 5, out = output) + violation?.value.shouldBe(7) + violation?.seed.shouldBe(9) + } + + test("seedMarch reports the first deterministic failing seed") { + val output = PrintStream(ByteArrayOutputStream()) + val violation = Arb.constant("value") + .withCheck { error("failure") } + .seedMarch(startingSeed = 15, maxIter = 2, printEvery = 0, out = output) + violation?.value.shouldBe("value") + violation?.seed.shouldBe(15) + } + + test("document and execution input comparators count AST descendants") { + val small = Parser().parseDocument("{ x }") + val large = Parser().parseDocument("{ x { y z } }") + (DocumentComparator.compare(small, large) < 0).shouldBe(true) + + val smallInput = ExecutionInput.newExecutionInput().query("{ x }").build() + val largeInput = ExecutionInput.newExecutionInput().query("{ x { y z } }").build() + (ExecutionInputComparator.compare(smallInput, largeInput) < 0).shouldBe(true) + } +}) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt new file mode 100644 index 000000000..ebf2aa3ad --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt @@ -0,0 +1,49 @@ +package graphql.schema.property + +import graphql.introspection.Introspection.DirectiveLocation +import graphql.schema.GraphQLCompositeType +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe + +class SchemaRelationshipsTest : FunSpec({ + val schema = parseTestSchema( + """ + directive @fieldDirective on FIELD + interface I { x: Int } + type A implements I { x: Int } + type B implements I { x: Int } + type C { x: Int } + union U = A | C + type Query { i: I, u: U } + """.trimIndent() + ) + val relationships = SchemaRelationships(schema) + + test("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")) + } + + test("spreadability follows overlap of possible concrete types") { + val interfaceType = schema.getType("I") as GraphQLCompositeType + 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) + } + + test("Schemas indexes directives by valid location") { + val schemas = Schemas(schema) + schemas.directivesByLocation.getValue(DirectiveLocation.FIELD) + .map { it.name } + .shouldContain("fieldDirective") + } +}) diff --git a/settings.gradle b/settings.gradle index a15c47033..2453d7c29 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,3 +19,4 @@ plugins { rootProject.name = 'graphql-java' include 'performance-results-page' +include 'graphql-java-property-testing' From a7afcc399f7233de46a8b05e8f2e2a47ed4ef077 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Wed, 5 Aug 2026 09:55:30 +1000 Subject: [PATCH 2/5] Restore original Viaduct property tests --- graphql-java-property-testing/README.md | 32 + graphql-java-property-testing/build.gradle | 4 + .../kotlin/graphql/schema/property/ArbExt.kt | 71 ++ .../graphql/schema/property/CallRecorder.kt | 107 +++ .../graphql/schema/property/CheckedArb.kt | 84 ++ .../graphql/schema/property/CycleGroups.kt | 13 +- .../graphql/schema/property/GeneratorUtils.kt | 134 ++- .../schema/property/GraphQLAstUtils.kt | 24 +- .../schema/property/GraphQLComparators.kt | 18 + .../schema/property/GraphQLRuntimeWirings.kt | 43 + .../graphql/schema/property/ArbExtTest.kt | 123 +++ .../schema/property/CallRecorderTest.kt | 159 ++++ .../graphql/schema/property/CheckedArbTest.kt | 167 ++++ .../graphql/schema/property/ConfigTest.kt | 221 +++-- .../schema/property/CycleGroupsTest.kt | 190 +++- .../graphql/schema/property/DeepArbSuite.kt | 81 ++ .../schema/property/DeepArbSuiteTest.kt | 41 + .../schema/property/DocumentBuilderTest.kt | 592 +++++++++--- .../schema/property/FailureCollector.kt | 735 +++++++++++++++ .../schema/property/GeneratorUtilsTest.kt | 76 -- .../schema/property/GraphQLComparators.kt | 13 - .../schema/property/GraphQLDocumentGenTest.kt | 891 ++++++++++++------ .../property/GraphQLExecutionInputGenTest.kt | 122 +++ .../property/GraphQLExecutionInputsTest.kt | 73 -- .../property/GraphQLGeneratorPropertyTest.kt | 125 --- .../schema/property/GraphQLNamesTest.kt | 176 ++-- .../property/GraphQLRuntimeWiringsTest.kt | 418 ++++++-- .../GraphQLSchemaDirectiveCycleTest.kt | 143 --- .../property/GraphQLSchemaPropertyTest.kt | 289 ------ .../schema/property/GraphQLSchemasTest.kt | 677 +++++++++++++ .../schema/property/GraphQLStringsTest.kt | 160 +++- .../schema/property/GraphQLTypesTest.kt | 744 ++++++++++----- .../schema/property/KotestPropertyBase.kt | 116 +++ .../schema/property/KotestPropertyBaseTest.kt | 130 +++ .../schema/property/PropertyTestFixtures.kt | 17 + .../schema/property/PropertyTestUtils.kt | 128 --- .../schema/property/PropertyTestUtilsTest.kt | 70 -- .../graphql/schema/property/SchemasTest.kt | 39 + .../graphql/schema/property/UtilTest.kt | 370 ++++++++ 39 files changed, 5656 insertions(+), 1960 deletions(-) create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/ArbExt.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CallRecorder.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CheckedArb.kt create mode 100644 graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLComparators.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ArbExtTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CallRecorderTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CheckedArbTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuite.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuiteTest.kt create 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/GeneratorUtilsTest.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLComparators.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputGenTest.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputsTest.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLGeneratorPropertyTest.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaDirectiveCycleTest.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaPropertyTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemasTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBase.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBaseTest.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtils.kt delete mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtilsTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemasTest.kt create mode 100644 graphql-java-property-testing/src/test/kotlin/graphql/schema/property/UtilTest.kt diff --git a/graphql-java-property-testing/README.md b/graphql-java-property-testing/README.md index 44cb1d54b..64b66acbc 100644 --- a/graphql-java-property-testing/README.md +++ b/graphql-java-property-testing/README.md @@ -27,6 +27,38 @@ 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 diff --git a/graphql-java-property-testing/build.gradle b/graphql-java-property-testing/build.gradle index f7efc2af5..12e27d1f8 100644 --- a/graphql-java-property-testing/build.gradle +++ b/graphql-java-property-testing/build.gradle @@ -48,6 +48,7 @@ dependencies { testImplementation "io.kotest:kotest-runner-junit5:${kotestVersion}" 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' } @@ -79,6 +80,9 @@ def dokkaJavadocJar = tasks.register('dokkaJavadocJar', Jar) { tasks.withType(Test).configureEach { useJUnitPlatform() maxHeapSize = '1g' + systemProperty 'junit.jupiter.execution.parallel.enabled', 'true' + systemProperty 'junit.jupiter.execution.parallel.mode.default', 'concurrent' + systemProperty 'junit.jupiter.execution.parallel.mode.classes.default', 'concurrent' testLogging { events 'FAILED', 'SKIPPED' exceptionFormat = 'FULL' 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 new file mode 100644 index 000000000..4c838791f --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/ArbExt.kt @@ -0,0 +1,71 @@ +@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 new file mode 100644 index 000000000..acc6b60e2 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CallRecorder.kt @@ -0,0 +1,107 @@ +@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/CheckedArb.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CheckedArb.kt new file mode 100644 index 000000000..dc20129d8 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CheckedArb.kt @@ -0,0 +1,84 @@ +@file:Suppress("ForbiddenImport", "TooGenericExceptionCaught") + +package graphql.schema.property + +import io.kotest.property.Arb +import io.kotest.property.PropertyTesting +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.next +import java.io.PrintStream + +/** + * An Arb combined with an assertion, providing a common interface for checking all values, + * finding the minimum violating value, and seed marching + */ +class CheckedArb( + val arb: Arb, + val check: (T) -> Unit +) { + /** Return the minimum [Violation] within [iter] samples, if one exists */ + fun minViolation( + comparator: Comparator, + rs: RandomSource, + iter: Int = PropertyTesting.defaultIterationCount, + printEvery: Int = 1_000, + out: PrintStream = System.out + ): Violation? = + arb.asSequence(rs) + .take(iter) + .foldIndexed(null as Violation?) { i, acc, t -> + if (printEvery > 0 && i.mod(printEvery) == 0) { + out.println("Iteration $i...") + } + try { + check(t) + acc + } catch (err: Throwable) { + if (acc == null) { + out.println("Found new min violation at iteration $i") + Violation(t, err, rs.seed) + } else { + @Suppress("UNCHECKED_CAST") + val accValue = acc.value as T + if (comparator.compare(t, accValue) < 0) { + out.println("Found new min violation at iteration $i") + Violation(t, err, rs.seed) + } else { + acc + } + } + } + } + + /** Return the first [Violation] within [maxIter] samples, if one exists */ + fun seedMarch( + startingSeed: Long = 0, + maxIter: Int = 100_000_000, + printEvery: Int = 1_000, + out: PrintStream = System.out + ): Violation? { + var iter = 0 + while (iter < maxIter) { + val seed = startingSeed + iter + if (printEvery > 0 && iter.mod(printEvery) == 0) { + out.println("Seed $seed...") + } + var t: T? = null + try { + val sample = arb.next(RandomSource.seeded(seed)) + t = sample + check(sample) + } catch (e: Throwable) { + return Violation(t, e, seed) + } + iter += 1 + } + return null + } +} + +/** [value] is null when generation fails before a sample is produced. */ +data class Violation(val value: T?, val err: Throwable, val seed: Long) + +/** Return a [CheckedArb] that combines this arb with [check]. */ +fun Arb.withCheck(check: (T) -> Unit): CheckedArb = CheckedArb(this, check) diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt index e15435ab9..d8d889e82 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/CycleGroups.kt @@ -13,12 +13,16 @@ import graphql.schema.GraphQLTypeUtil * defined by the factory method that created this instance (e.g., [mandatoryInputCycles] or [allInputCycles]). * Each group represents a Strongly Connected Component (SCC) in the input object dependency graph. */ -internal class CycleGroups(private val groups: Map>) { - operator fun get(name: String): Set = groups[name].orEmpty() +internal class CycleGroups(val map: Map>) { + operator fun get(name: String): Set = map[name].orEmpty() - fun isEmpty(): Boolean = groups.isEmpty() + fun isEmpty(): Boolean = map.isEmpty() + + override fun toString(): String = map.toString() companion object { + val Empty: CycleGroups = CycleGroups(emptyMap()) + /** * Returns a [CycleGroups] that identifies cycles formed by mandatory input edges. * @@ -73,7 +77,8 @@ internal class CycleGroups(private val groups: Map>) { ): CycleGroups { val inputTypes = schema.allTypesAsList.filterIsInstance() val adjacency = inputTypes.associate { it.name to neighbors(it) } - return CycleGroups(stronglyConnectedCycles(adjacency)) + val cycles = stronglyConnectedCycles(adjacency) + return if (cycles.isEmpty()) Empty else CycleGroups(cycles) } private fun stronglyConnectedCycles( diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt index 97a140dce..68d52c77a 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GeneratorUtils.kt @@ -6,16 +6,23 @@ 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.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 import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.long import io.kotest.property.arbitrary.map +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 kotlin.math.max import kotlin.math.min +import kotlinx.coroutines.delay internal val builtinScalars: Map = listOf( @@ -29,37 +36,140 @@ internal val builtinScalars: Map = internal val builtinDirectives: Map = Directives.BUILT_IN_DIRECTIVES.associateBy { it.name } -/** Return true with probability [weight]. */ -internal fun RandomSource.sampleWeight(weight: Double): Boolean = +/** 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) + +/** + * Filter an Arb to only yield non-empty IntRange values. + * This is different from [Arb.Companion.intRange], which can yield empty + * IntRange values. + */ +fun Arb.nonEmpty(): Arb = filterNot { it.isEmpty() } + +/** Return a new Arb containing only non-null values. */ +@Suppress("UNCHECKED_CAST") +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 how many times [weight] sampled true before reaching its configured maximum. */ -internal fun RandomSource.count(weight: CompoundingWeight): Int { +/** + * 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) } -/** Convert a generator into an infinite sequence using [randomSource]. */ -internal fun Gen.asSequence(randomSource: RandomSource): Sequence = - generate(randomSource).map { it.value } +/** 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()) /** - * Generate subsets of this set. The subset size is constrained by [range], or ranges from zero - * through the input set size when no range is provided. + * 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 + * elements. */ -internal fun Set.arbSubset(range: IntRange? = null): Arb> = - Arb.constant(this).flatMap { values -> +fun Arb>.subset(range: IntRange? = null): Arb> = + flatMap { values -> val first = min(max(range?.first ?: 0, 0), values.size) val last = min(max(range?.last ?: values.size, 0), values.size) Arb.pair(Arb.shuffle(values.toList()), Arb.int(first..last)) .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 + * that is assigned a weight of 0. + */ +fun Arb.Companion.weightedChoose( + weightedArb: Pair>, + fallbackArb: Arb +): Arb { + val weight = weightedArb.first.also { + WeightValidator(it)?.let { message -> throw IllegalArgumentException(message) } + } + return when (weight) { + 0.0 -> fallbackArb + 1.0 -> weightedArb.second + else -> { + val intWeight = (weight * 1_000).toInt() + Arb.choose(intWeight to weightedArb.second, (1_000 - intWeight) to fallbackArb) + } + } +} + +/** + * [Arb.Companion.choose] can, via edge cases, select an Arb that is assigned a weight of 0. + * This method is a replacement for [Arb.Companion.choose] that will never pick an Arb with a 0 weight. + */ +fun Arb.Companion.weightedChoose(arbs: List>>): Arb { + val weightedArbs = arbs + .filter { (weight, _) -> weight > 0.0 } + .map { (weight, arb) -> (weight * 1_000).toInt() to arb } + require(weightedArbs.isNotEmpty()) + return if (weightedArbs.size == 1) { + weightedArbs.first().second + } else { + Arb.choose(weightedArbs[0], weightedArbs[1], *weightedArbs.drop(2).toTypedArray()) + } +} + +/** A unit arb, that always returns Unit. */ +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) + */ +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. diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt index 4faccfaf1..3ac78bd19 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLAstUtils.kt @@ -1,10 +1,14 @@ package graphql.schema.property +import graphql.language.Document import graphql.language.ListType import graphql.language.Node import graphql.language.NonNullType import graphql.language.Type import graphql.language.TypeName +import graphql.parser.Parser +import graphql.parser.ParserEnvironment +import graphql.parser.ParserOptions import graphql.schema.GraphQLInputType import graphql.schema.GraphQLList import graphql.schema.GraphQLNamedType @@ -12,6 +16,22 @@ import graphql.schema.GraphQLNonNull import graphql.schema.GraphQLSchema import graphql.schema.GraphQLType import graphql.schema.GraphQLTypeUtil +import graphql.schema.idl.FastSchemaGenerator +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.SchemaParser + +/** Return a mocked [GraphQLSchema] described by this String value. */ +val String.asSchema: GraphQLSchema + get() = FastSchemaGenerator().makeExecutableSchema(SchemaParser().parse(this), RuntimeWiring.MOCKED_WIRING) + +/** Return a parsed [Document] described by this String value. */ +val String.asDocument: Document + get() = Parser.parse( + ParserEnvironment.newParserEnvironment() + .document(this) + .parserOptions(ParserOptions.getDefaultSdlParserOptions()) + .build() + ) /** All descendants of this AST node, excluding the node itself. */ internal val Node<*>.allChildren: List> @@ -24,7 +44,7 @@ internal inline fun > Node<*>.allChildrenOfType(): List = allChildren.filterIsInstance() /** Convert a language AST type into its schema type counterpart. */ -internal fun Type<*>.asSchemaType(schema: GraphQLSchema): GraphQLType = +fun Type<*>.asSchemaType(schema: GraphQLSchema): GraphQLType = when (this) { is ListType -> GraphQLList.list(requireNotNull(type).asSchemaType(schema)) is NonNullType -> GraphQLNonNull.nonNull(requireNotNull(type).asSchemaType(schema)) @@ -33,7 +53,7 @@ internal fun Type<*>.asSchemaType(schema: GraphQLSchema): GraphQLType = } /** Convert a schema type into its language AST counterpart. */ -internal fun GraphQLType.asAstType(): Type<*> = +fun GraphQLType.asAstType(): Type<*> = when (this) { is GraphQLList -> ListType(GraphQLTypeUtil.unwrapOneAs(this).asAstType()) is GraphQLNonNull -> NonNullType(GraphQLTypeUtil.unwrapOneAs(this).asAstType()) diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLComparators.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLComparators.kt new file mode 100644 index 000000000..67bc96fa4 --- /dev/null +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLComparators.kt @@ -0,0 +1,18 @@ +package graphql.schema.property + +import graphql.ExecutionInput +import graphql.language.Document + +/** A [Comparator] that orders [Document]s by their node count. */ +val DocumentComparator: Comparator = + Comparator.comparingInt { it.allChildren.size } + +/** + * A [Comparator] that orders [ExecutionInput]s by how many nodes are in their parsed document. + * + * This comparator reparses document text to compare the size of the node trees. Comparing document + * string lengths would be faster, but node count keeps its behavior consistent with + * [DocumentComparator]. + */ +val ExecutionInputComparator: Comparator = + Comparator.comparingInt { it.query.asDocument.allChildren.size } diff --git a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt index af219792e..e5ba5ad74 100644 --- a/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt +++ b/graphql-java-property-testing/src/main/kotlin/graphql/schema/property/GraphQLRuntimeWirings.kt @@ -21,6 +21,7 @@ import graphql.schema.GraphQLScalarType import graphql.schema.GraphQLSchema import graphql.schema.GraphQLTypeUtil import graphql.schema.GraphQLUnionType +import graphql.schema.idl.RuntimeWiring import io.kotest.property.Arb import io.kotest.property.RandomSource import io.kotest.property.arbitrary.int @@ -54,6 +55,48 @@ fun arbitraryGraphQL( return GraphQL.newGraphQL(executableSchema).build() } +/** + * Create a RuntimeWiring that serves arbitrary data for a schema. + * + * Though the data returned by this wiring is arbitrarily generated, the wiring + * guarantees that for a given [seed] value it will always return the same data for a + * 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]. + */ +fun arbRuntimeWiring( + sdl: String, + seed: Long, + cfg: Config = Config.default +): RuntimeWiring { + val schema = sdl.asSchema + val codeRegistry = arbitraryCodeRegistry(schema, seed, cfg) + val builder = RuntimeWiring.newRuntimeWiring() + schema.allTypesAsList.forEach { type -> + when (type) { + is GraphQLObjectType -> builder.type(type.name) { typeBuilder -> + type.fieldDefinitions.fold(typeBuilder) { current, field -> + current.dataFetcher( + field.name, + codeRegistry.getDataFetcher(FieldCoordinates.coordinates(type.name, field.name), field) + ) + } + } + + is GraphQLInterfaceType -> builder.type(type.name) { + it.typeResolver(codeRegistry.getTypeResolver(type)) + } + + is GraphQLUnionType -> builder.type(type.name) { + it.typeResolver(codeRegistry.getTypeResolver(type)) + } + } + } + return builder.build() +} + private class ArbitraryCodeRegistryGenerator( private val schema: GraphQLSchema, private val seed: Long, 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 new file mode 100644 index 000000000..844aa1d63 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ArbExtTest.kt @@ -0,0 +1,123 @@ +@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 new file mode 100644 index 000000000..7a5845a12 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CallRecorderTest.kt @@ -0,0 +1,159 @@ +@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/CheckedArbTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CheckedArbTest.kt new file mode 100644 index 000000000..091a8ce19 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CheckedArbTest.kt @@ -0,0 +1,167 @@ +package graphql.schema.property + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.Sample +import io.kotest.property.arbitrary.constant +import io.kotest.property.arbitrary.int +import io.kotest.property.asSample +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CheckedArbTest : KotestPropertyBase() { + @Test + fun `check evaluates the value`() { + var checkedValue: Int? = null + val checkedArb = Arb.constant(1).withCheck { checkedValue = it } + + checkedArb.check(2) + + assertEquals(2, checkedValue) + } + + @Test + fun `minViolation -- succeeds`() { + val mv = Arb.constant(1) + .withCheck {} + .minViolation(Comparator.naturalOrder(), randomSource) + assertNull(mv) + } + + @Test + fun `minViolation -- suppresses output when printEvery is zero`() { + val out = ByteArrayOutputStream() + val mv = Arb.int(0..100) + .withCheck {} + .minViolation( + comparator = Comparator.naturalOrder(), + rs = randomSource, + iter = iterations, + printEvery = 0, + out = PrintStream(out) + ) + assertNull(mv) + assertEquals("", out.toString()) + } + + @Test + fun `minViolation -- fails`() { + val err = RuntimeException() + val mv = Arb.int(0..5) + .withCheck { i -> + if (i == 0) throw err + } + .minViolation(Comparator.naturalOrder(), randomSource) + assertEquals(Violation(0, err, seed), mv) + } + + @Test + fun `minViolation -- honors negative comparator results`() { + val err = RuntimeException() + val values = listOf(4, 2, 1) + val arb = object : Arb() { + private var index = 0 + + override fun edgecase(rs: RandomSource): Sample? = null + + override fun sample(rs: RandomSource): Sample = values[index++].asSample() + } + val mv = arb.withCheck { throw err } + .minViolation( + comparator = Comparator { lhs, rhs -> + when { + lhs < rhs -> -2 + lhs > rhs -> 2 + else -> 0 + } + }, + rs = randomSource, + iter = values.size + ) + assertEquals(Violation(1, err, seed), mv) + } + + @Test + fun `seedMarch -- succeeds`() { + val result = Arb.int(0..100) + .withCheck {} + .seedMarch(maxIter = iterations, printEvery = -1) + assertNull(result) + } + + @Test + fun `seedMarch -- fails`() { + var countDown = 3 + val err = RuntimeException() + val result = Arb.constant(1) + .withCheck { + if (countDown-- == 0) { + throw err + } + } + .seedMarch(maxIter = iterations, printEvery = -1) + assertEquals(Violation(1, err, 3), result) + } + + @Test + fun `seedMarch -- returns generator failures with seed`() { + val err = RuntimeException() + val arb = object : Arb() { + override fun edgecase(rs: RandomSource): Sample? = null + + override fun sample(rs: RandomSource): Sample { + if (rs.seed == 5L) { + throw err + } + return 1.asSample() + } + } + val result = arb + .withCheck {} + .seedMarch(startingSeed = 3, maxIter = 3, printEvery = -1) + assertNull(result?.value) + assertSame(err, result?.err) + assertEquals(5, result?.seed) + } + + @Test + fun `seedMarch -- returns assertion failures`() { + val result = Arb.constant(1) + .withCheck { assertTrue(it < 0) } + .seedMarch(maxIter = 1, printEvery = -1) + assertEquals(1, result?.value) + assertEquals(0, result?.seed) + result?.err.shouldBeInstanceOf() + } + + @Test + fun `seedMarch -- suppress output`() { + val out = ByteArrayOutputStream() + Arb.int(0..100) + .withCheck {} + .seedMarch(maxIter = iterations, printEvery = -1, out = PrintStream(out)) + out.toString() shouldBe "" + } + + @Test + fun `seedMarch -- printEvery`() { + val out = ByteArrayOutputStream() + Arb.int(0..50) + .withCheck {} + .seedMarch(startingSeed = 3, maxIter = 40, printEvery = 10, out = PrintStream(out)) + out.toString().replace("\r\n", "\n") shouldBe """ + |Seed 3... + |Seed 13... + |Seed 23... + |Seed 33... + | + """.trimMargin().replace("\r\n", "\n") + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt index 9be73dcbf..c8152de4b 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/ConfigTest.kt @@ -1,84 +1,181 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) +@file:Suppress("ForbiddenImport") package graphql.schema.property -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.assertions.throwables.shouldThrow import io.kotest.property.Arb -import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.char +import io.kotest.property.arbitrary.choice import io.kotest.property.arbitrary.double import io.kotest.property.arbitrary.int import io.kotest.property.arbitrary.intRange import io.kotest.property.arbitrary.pair -import io.kotest.property.checkAll - -class ConfigTest : FunSpec({ - test("Config validates entries") { - val valid = ConfigKey(0) { null } - val invalid = ConfigKey(0) { "invalid" } - checkAll(PropTestConfig(iterations = 100), Arb.int()) { value -> - (Config.default + (valid to value))[valid].shouldBe(value) - shouldThrow { Config.default + (invalid to value) } - .message.shouldBe("invalid") +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.Test + +class ConfigTest : KotestPropertyBase() { + @Test + fun `Config_plus validates entries`(): 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) + } + + val putInvalid = + Result.runCatching { + Config.default + (alwaysInvalid to i) + } + putValid.isSuccess && putInvalid.isFailure + } } - } - test("a Config overrides another Config") { - val first = ConfigKey(1, Unvalidated) - val second = ConfigKey(2, Unvalidated) - val base = Config.default + (first to 11) + (second to 22) - val combined = base + (Config.default + (first to 111)) - combined[first].shouldBe(111) - combined[second].shouldBe(22) + @Test + fun `Config_plus config overrides values`() { + 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 combined = c1 + c2 + + assertEquals(111, combined[k1]) + assertEquals(22, combined[k2]) } - test("missing keys return defaults and present keys return configured values") { - checkAll(PropTestConfig(iterations = 100), Arb.int(), Arb.int()) { default, configured -> - val key = ConfigKey(default, Unvalidated) - Config.default[key].shouldBe(default) - (Config.default + (key to configured))[key].shouldBe(configured) + @Test + fun `Config_get with missing key returns default value`(): Unit = + runBlocking { + Arb.int().forAll { i -> + val key = ConfigKey(i, Unvalidated) + Config.default.get(key) == i + } } - } - test("WeightValidator accepts exactly the inclusive unit interval") { - checkAll(PropTestConfig(iterations = 200), Arb.double()) { value -> - val valid = runCatching { Config.validateOrThrow(WeightValidator, value) }.isSuccess - valid.shouldBe(value in 0.0..1.0) + @Test + fun `Config_get with present key returns configured value`(): Unit = + runBlocking { + val key = ConfigKey(0, Unvalidated) + Arb.int().forAll { x -> + val cfg = Config.default + (key to x) + cfg[key] == x + } } - runCatching { Config.validateOrThrow(WeightValidator, 0.0) }.isSuccess.shouldBe(true) - runCatching { Config.validateOrThrow(WeightValidator, 1.0) }.isSuccess.shouldBe(true) - } - test("CompoundingWeightValidator rejects invalid weights and maxima") { - val values = Arb.pair(Arb.double(), Arb.int()) - checkAll(PropTestConfig(iterations = 200), values) { (weight, maximum) -> - val valid = runCatching { - Config.validateOrThrow(CompoundingWeightValidator, CompoundingWeight(weight, maximum)) - }.isSuccess - valid.shouldBe(weight in 0.0..1.0 && maximum >= 0) + @Test + fun `WeightValidator rejects values outside of 0 and 1`(): Unit = + runBlocking { + Arb + .double() + .withEdgecases(listOf(-1.0, 0.0, 1.0)) + .forAll { d -> + val result = + Result.runCatching { + Config.validateOrThrow(WeightValidator, d) + } + + if (d < 0.0 || d > 1.0) { + result.isFailure + } else { + result.isSuccess + } + } } - } - test("IntValidator accepts only values in its domain") { - checkAll(PropTestConfig(iterations = 200), Arb.intRange(-10_000..10_000), Arb.int()) { domain, value -> - val valid = runCatching { Config.validateOrThrow(IntValidator(domain), value) }.isSuccess - valid.shouldBe(value in domain) + @Test + fun `CompoundingWeightValidator rejects invalid values`(): Unit = + runBlocking { + val cw = + Arb.bind( + Arb.double().withEdgecases(listOf(0.0, 1.0)), + Arb.int() + ) { weight, max -> + CompoundingWeight(weight, max) + } + + cw.checkAll { + val passed = + Result + .runCatching { + Config.validateOrThrow(CompoundingWeightValidator, it) + }.isSuccess + + if (it.weight < 0.0 && passed) { + markFailure() + } else if (it.weight > 1.0 && passed) { + markFailure() + } else if (it.max < 0 && passed) { + markFailure() + } else { + markSuccess() + } + } } - } - test("IntRangeValidator accepts non-empty subranges") { - val ranges = Arb.intRange(-10_000..10_000) - checkAll(PropTestConfig(iterations = 200), ranges, ranges) { domain, range -> - val valid = runCatching { Config.validateOrThrow(IntRangeValidator(domain), range) }.isSuccess - valid.shouldBe(!range.isEmpty() && domain.first <= range.first && domain.last >= range.last) + @Test + fun `IntValidator rejects values outside of range`(): Unit = + runBlocking { + Arb + .pair( + Arb.intRange(Int.MIN_VALUE until Int.MAX_VALUE), + Arb.int() + ).forAll { (range, i) -> + val result = + Result.runCatching { + Config.validateOrThrow(IntValidator(range), i) + } + + if (range.contains(i)) { + result.isSuccess + } else { + result.isFailure + } + } } - } - test("Unvalidated accepts unrelated values") { - Config.validateOrThrow(Unvalidated, 1) - Config.validateOrThrow(Unvalidated, "value") - Config.validateOrThrow(Unvalidated, Any()) - Unvalidated(Unit).shouldBe(null) - } -}) + @Test + fun `IntRangeValidator rejects values outside of domain`(): Unit = + runBlocking { + Arb + .pair( + Arb.intRange(Int.MIN_VALUE until Int.MAX_VALUE), + Arb.intRange(Int.MIN_VALUE until Int.MAX_VALUE) + ).forAll { (domain, range) -> + val result = + Result.runCatching { + Config.validateOrThrow(IntRangeValidator(domain), range) + } + + if (range.isEmpty()) { + result.isFailure + } else if (domain.first <= range.first && domain.last >= range.last) { + result.isSuccess + } else { + result.isFailure + } + } + } + + @Test + fun `Unvalidated accepts all values`(): Unit = + runBlocking { + Arb + .choice( + Arb.int(), + Arb.string(), + Arb.double(), + Arb.char() + ).forAll { + Result + .runCatching { + Config.validateOrThrow(Unvalidated, it) + }.isSuccess + } + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt index 529b8fe4a..3307d04e7 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/CycleGroupsTest.kt @@ -1,69 +1,155 @@ +@file:Suppress("ForbiddenImport") + package graphql.schema.property -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.shouldBe - -class CycleGroupsTest : FunSpec({ - test("schemas without recursive input types have no cycle groups") { - val schemas = listOf( - "type Query { x: Int }", - "input A { x: Int } input B { a: A } type Query { x(input: B): Int }" - ) - schemas.forEach { sdl -> - val schema = parseTestSchema(sdl) - CycleGroups.allInputCycles(schema).isEmpty().shouldBe(true) - CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) +import io.kotest.property.Arb +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.Test + +class CycleGroupsTest : KotestPropertyBase() { + @Test + fun `Empty`(): Unit = + runBlocking { + Arb.string().forAll { CycleGroups.Empty[it].isEmpty() } } + + @Test + fun `no input types`() { + val schema = "type Foo { x: Int }".asPermissiveTestSchema + val all = CycleGroups.allInputCycles(schema) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(CycleGroups.Empty, all) + assertEquals(CycleGroups.Empty, hard) + } + + @Test + fun `acyclic input types`() { + val schema = """ + input A { x: Int } + input B { a: A } + """.asPermissiveTestSchema + val all = CycleGroups.allInputCycles(schema) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(CycleGroups.Empty, all) + assertEquals(CycleGroups.Empty, hard) + } + + @Test + fun `nullable self-loop`() { + val schema = "input Inp { inp: Inp }".asPermissiveTestSchema + val all = CycleGroups.allInputCycles(schema) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(mapOf("Inp" to setOf("Inp")), all.map) + assertEquals(CycleGroups.Empty, hard) + } + + @Test + fun `nullable 2-type cycle`() { + val schema = """ + input A { b: B } + input B { a: A } + """.asPermissiveTestSchema + val all = CycleGroups.allInputCycles(schema) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(mapOf("A" to setOf("A", "B"), "B" to setOf("A", "B")), all.map) + assertEquals(CycleGroups.Empty, hard) + } + + @Test + fun `nullable 2-type cycle with observer`() { + val schema = """ + input A { b: B } + input B { a: A } + input C { a: A } + """.asPermissiveTestSchema + val all = CycleGroups.allInputCycles(schema) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(mapOf("A" to setOf("A", "B"), "B" to setOf("A", "B")), all.map) + assertEquals(CycleGroups.Empty, hard) } - test("nullable self loops are optional cycles") { - val schema = parseTestSchema("input Inp { inp: Inp } type Query { x(inp: Inp): Int }") - CycleGroups.allInputCycles(schema)["Inp"].shouldBe(setOf("Inp")) - CycleGroups.mandatoryInputCycles(schema)["Inp"].shouldBeEmpty() + @Test + fun `non-nullable cycle with lists`() { + val schema = """ + input A { b: [B!]! } + input B { a: A! } + """.asPermissiveTestSchema + val all = CycleGroups.allInputCycles(schema) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(mapOf("A" to setOf("A", "B"), "B" to setOf("A", "B")), all.map) + assertEquals(CycleGroups.Empty, hard) } - test("nullable mutually recursive types share a cycle group") { - val schema = parseTestSchema( - "input A { b: B } input B { a: A } input C { a: A } type Query { x(c: C): Int }" - ) + @Test + fun `non-null recursion`() { + // note that this type is schematically invalid + val schema = "input Inp { inp: Inp! }".asPermissiveTestSchema val all = CycleGroups.allInputCycles(schema) - all["A"].shouldBe(setOf("A", "B")) - all["B"].shouldBe(setOf("A", "B")) - all["C"].shouldBeEmpty() - CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(mapOf("Inp" to setOf("Inp")), all.map) + assertEquals(mapOf("Inp" to setOf("Inp")), hard.map) } - test("non-null lists break mandatory recursion") { - val schema = parseTestSchema( - "input A { b: [B!]! } input B { a: A! } type Query { x(a: A): Int }" - ) - CycleGroups.allInputCycles(schema)["A"].shouldBe(setOf("A", "B")) - CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) + @Test + fun `non-null co-recursion`() { + // note that these types are schematically invalid + val schema = """ + input A { b: B! } + input B { a: A! } + """.asPermissiveTestSchema + val all = CycleGroups.allInputCycles(schema) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(mapOf("A" to setOf("A", "B"), "B" to setOf("A", "B")), all.map) + assertEquals(mapOf("A" to setOf("A", "B"), "B" to setOf("A", "B")), hard.map) } - test("mixed-nullability recursion is not mandatory") { - val schema = parseTestSchema( - "input A { b: B! } input B { a: A } type Query { x(a: A): Int }" - ) - CycleGroups.allInputCycles(schema)["A"].shouldBe(setOf("A", "B")) - CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) + @Test + fun `co-recursion with mixed nullability`() { + val schema = """ + input A { b: B! } + input B { a: A } + """.asPermissiveTestSchema + val all = CycleGroups.allInputCycles(schema) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(mapOf("A" to setOf("A", "B"), "B" to setOf("A", "B")), all.map) + assertEquals(CycleGroups.Empty, hard) } - test("nullable oneOf recursion is optional") { - val schema = parseTestSchema( - "input A @oneOf { b: B } input B { a: A } type Query { x(a: A): Int }" - ) - CycleGroups.allInputCycles(schema)["A"].shouldBe(setOf("A", "B")) - CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) + @Test + fun `co-recursion with nullable oneof`() { + val schema = """ + input A @oneOf { b: B } + input B { a: A } + """.asPermissiveTestSchema + val all = CycleGroups.allInputCycles(schema) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(mapOf("A" to setOf("A", "B"), "B" to setOf("A", "B")), all.map) + assertEquals(CycleGroups.Empty, hard) } - test("oneOf fields participate in mandatory cycles when their host is required") { - val schema = parseTestSchema( - "input A @oneOf { b: B, escape: Int } input B { a: A! } type Query { x(a: A): Int }" - ) - val mandatory = CycleGroups.mandatoryInputCycles(schema) - mandatory["A"].shouldBe(setOf("A", "B")) - mandatory["B"].shouldBe(setOf("A", "B")) + @Test + fun `co-recursion with non-nullable oneof`() { + val schema = """ + input A @oneOf { b:B, escape:Int } + input B { a:A! } + """.asPermissiveTestSchema + val all = CycleGroups.allInputCycles(schema) + val hard = CycleGroups.mandatoryInputCycles(schema) + + assertEquals(mapOf("A" to setOf("A", "B"), "B" to setOf("A", "B")), all.map) + assertEquals(mapOf("A" to setOf("A", "B"), "B" to setOf("A", "B")), hard.map) } -}) +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuite.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuite.kt new file mode 100644 index 000000000..77147fc5a --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuite.kt @@ -0,0 +1,81 @@ +@file:Suppress("ForbiddenImport") + +package graphql.schema.property + +import java.io.File +import java.io.FileWriter +import kotlin.random.Random +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.fail +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test + +/** + * Base class for progressively finding regressions using a [CheckedArb] + * + * This suite defines multiple junit test cases that represent different run + * modes. These test cases each support a specific kind of use case for + * verifying the correctness of [checkedArb]. See kdocs on each method for more. + * + * @see `check all` + * @see `min violation` + * @see `seed march` + */ +abstract class DeepArbSuite( + seed: Long = Random.nextLong(), + iterations: Int = 1_000, + private val minViolationIterations: Int = iterations * 10, + private val seedMarchStartingSeed: Long = 0, +) : KotestPropertyBase(seed, iterations) { + protected abstract val checkedArb: CheckedArb + + protected abstract val comparator: Comparator + + /** + * This method will run for a relatively small number of iterations. It runs by + * default in all settings, making it an economical ongoing correctness checker + * + * @see iterations + */ + @Test + fun `check all`() { + runBlocking { + checkedArb.arb.checkAll { checkedArb.check(it) } + } + } + + /** + * Check many more samples and report the simplest failure found. + * + * This should be left disabled so that it doesn't slow down normal tests. + * Implementations of this method can be run manually in Idea or via command line. + */ + @Test + @Disabled + fun `min violation`() { + checkedArb.minViolation(comparator, randomSource, minViolationIterations) + ?.let(::dumpAndFail) + } + + /** + * Run forever, reporting the first failing seed. + * + * This should be left disabled so that it doesn't slow down normal tests. + * Implementations of this method can be run manually in Idea or via command line. + */ + @Test + @Disabled + fun `seed march`() { + checkedArb.seedMarch(startingSeed = seedMarchStartingSeed) + ?.let(::dumpAndFail) + } + + private fun dumpAndFail(violation: Violation<*>) { + val file = File.createTempFile("violation-", ".txt") + FileWriter(file).use { writer -> + writer.write("seed: ${violation.seed}\n") + writer.write(violation.err.toString()) + } + fail("Wrote dump (seed ${violation.seed}) to:\n${file.absolutePath}") + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuiteTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuiteTest.kt new file mode 100644 index 000000000..33bd90da4 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DeepArbSuiteTest.kt @@ -0,0 +1,41 @@ +package graphql.schema.property + +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.Sample +import io.kotest.property.arbitrary.constant +import io.kotest.property.asSample +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DeepArbSuiteTest : DeepArbSuite(iterations = 1) { + override val comparator: Comparator = Comparator.naturalOrder() + + override val checkedArb: CheckedArb = + Arb.constant(1).withCheck { + assertEquals(1, it) + } + + @Test + fun `check all uses configured seed and checks each sample once`() { + var observedSeed: Long? = null + var checkCount = 0 + val recordingArb = object : Arb() { + override fun edgecase(rs: RandomSource): io.kotest.property.Sample? = null + + override fun sample(rs: RandomSource): Sample { + observedSeed = rs.seed + return 1.asSample() + } + } + val suite = object : DeepArbSuite(seed = 123L, iterations = 1) { + override val comparator: Comparator = Comparator.naturalOrder() + override val checkedArb = recordingArb.withCheck { checkCount += 1 } + } + + suite.`check all`() + + assertEquals(123L, observedSeed) + assertEquals(1, checkCount) + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt index 694374e84..9566f61ba 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/DocumentBuilderTest.kt @@ -7,206 +7,492 @@ import graphql.language.AstPrinter import graphql.language.IntValue import graphql.language.NullValue import graphql.language.OperationDefinition -import graphql.parser.Parser import graphql.schema.FieldCoordinates import graphql.schema.GraphQLCompositeType import graphql.schema.GraphQLFieldDefinition import graphql.schema.GraphQLList import graphql.schema.GraphQLNonNull -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe - -class DocumentBuilderTest : FunSpec({ - test("ArgumentKey compares the printed value instead of AST identity") { - val first = Argument("arg", NullValue.of()) - val second = Argument("arg", NullValue.of()) - first.shouldNotBe(second) - ArgumentKey(first).shouldBe(ArgumentKey(second)) - ArgumentKey(first).hashCode().shouldBe(ArgumentKey(second).hashCode()) - ArgumentKey(first).arg.shouldBe(first) - } - - test("FieldKey equality includes arguments and wrapper structure") { - DocumentBuilderFixture("type Query { x(arg: Int): Int }").run { - val first = FieldKey("x", null, setOf(ArgumentKey(Argument("arg", IntValue.of(1)))), typeExpr("Int")) - val same = FieldKey("x", null, setOf(ArgumentKey(Argument("arg", IntValue.of(1)))), typeExpr("Int")) - val different = FieldKey("x", null, setOf(ArgumentKey(Argument("arg", NullValue.of()))), typeExpr("Int")) - first.shouldBe(same) - first.shouldNotBe(different) +import graphql.schema.GraphQLOutputType +import graphql.schema.GraphQLScalarType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DocumentBuilderTest { + private class Fixture( + sdl: String, + fn: Fixture.() -> Unit = {} + ) { + val schema = sdl.asSchema + val query = schema.queryType + val schemas = Schemas(schema) + val fragments = Fragments(schemas) + val sb = SelectionsBuilder() + val __typename = Introspection.TypeNameMetaFieldDef + + init { + fn(this) + } + + fun field( + type: String, + fieldName: String + ): GraphQLFieldDefinition = + if (fieldName == __typename.name) { + __typename + } else { + requireNotNull(schema.getFieldDefinition(FieldCoordinates.coordinates(type, fieldName))) + } + + fun key( + type: String, + fieldName: String, + alias: String? = null, + vararg arguments: Argument + ): FieldKey = field(type, fieldName).key(alias, arguments.toSet()) + + val String.compositeType: GraphQLCompositeType get() = requireNotNull(schema.getTypeAs(this)) + val String.outputType: GraphQLOutputType get() = requireNotNull(schema.getTypeAs(this)) + val String.scalarType: GraphQLScalarType get() = requireNotNull(schema.getTypeAs(this)) + val String.typeExpr: TypeExpr get() = TypeExpr(requireNotNull(schema.getTypeAs(this))) + + fun assertDocument(expected: String) { + val db = DocumentBuilder(schemas, fragments) + val op = OperationDefinition + .newOperationDefinition() + .operation(OperationDefinition.Operation.QUERY) + .selectionSet(sb.build()) + .build() + db.add(op) + + val doc = db.build() + + val normExpected = AstPrinter.printAst(expected.asDocument) + val actual = AstPrinter.printAst(doc) + assertEquals(normExpected, actual) } } - test("TypeExpr hashes list and null wrappers in order") { - TypeExpr(GraphQLInt).listNullHash.shouldBe(1) - TypeExpr(GraphQLList.list(GraphQLInt)).listNullHash.shouldBe(2) - TypeExpr(GraphQLNonNull.nonNull(GraphQLInt)).listNullHash.shouldBe(3) - TypeExpr(GraphQLList.list(GraphQLList.list(GraphQLInt))).listNullHash.shouldBe(4) - TypeExpr(GraphQLList.list(GraphQLNonNull.nonNull(GraphQLInt))).listNullHash.shouldBe(5) - TypeExpr(GraphQLNonNull.nonNull(GraphQLList.list(GraphQLInt))).listNullHash.shouldBe(6) - TypeExpr(GraphQLList.list(GraphQLNonNull.nonNull(GraphQLInt))) - .shouldNotBe(TypeExpr(GraphQLNonNull.nonNull(GraphQLList.list(GraphQLInt)))) + @Test + fun `ArgumentKey -- equals and hashCode`() { + val a1 = Argument("arg", NullValue.of()) + val a2 = Argument("arg", NullValue.of()) + // sanity + assertNotEquals(a1, a2) + + val ak1 = ArgumentKey(a1) + val ak2 = ArgumentKey(a2) + assertEquals(ak1, ak2) + assertEquals(ak1.hashCode(), ak2.hashCode()) + + assertEquals(a1, ak1.arg) } - test("KeyTree creates scalar leaves and composite branches") { - DocumentBuilderFixture("type Query { x: Int, q: Query }").run { - val scalarKey = key("Query", "x") - val objectKey = key("Query", "q") - KeyTree(scalarKey)[scalarKey].shouldBe(null) - (KeyTree(objectKey)[objectKey] != null).shouldBe(true) + @Test + fun `FieldKey -- equality`() { + Fixture("type Query { x(arg:Int): Int }") { + val fk1 = FieldKey( + fieldName = "x", + alias = null, + arguments = setOf( + ArgumentKey(Argument("arg", IntValue.of(1))) + ), + fieldType = "Int".typeExpr + ) + + val fk2 = FieldKey( + fieldName = "x", + alias = null, + arguments = setOf( + ArgumentKey(Argument("arg", IntValue.of(1))) + ), + fieldType = "Int".typeExpr + ) + + assertEquals(fk1, fk2) + + val fk3 = FieldKey( + fieldName = "x", + alias = null, + arguments = setOf( + ArgumentKey(Argument("arg", NullValue.of())) + ), + fieldType = "Int".typeExpr + ) + assertNotEquals(fk1, fk3) } } - test("KeyTree detects merge-compatible and conflicting response keys") { - DocumentBuilderFixture("type Query { x: Int, q: Query }").run { - val x = key("Query", "x") - val q = key("Query", "q") - KeyTree().canMerge(KeyTree()).shouldBe(true) - KeyTree(x).canMerge(KeyTree(x)).shouldBe(true) - KeyTree(x).canMerge(KeyTree(q)).shouldBe(true) - KeyTree(x).canMerge(KeyTree(key("Query", "q", "x"))).shouldBe(false) - - val first = KeyTree(q).apply { merge(q, KeyTree(x)) } - val compatible = KeyTree(q).apply { merge(q, KeyTree(key("Query", "__typename"))) } - val conflicting = KeyTree(q).apply { merge(q, KeyTree(key("Query", "q", "x"))) } - first.canMerge(compatible).shouldBe(true) - first.canMerge(conflicting).shouldBe(false) + @Test + fun `TypeExpr -- hash`() { + // Int + assertEquals(1, TypeExpr(GraphQLInt).listNullHash) + + // [Int] + assertEquals(2, TypeExpr(GraphQLList(GraphQLInt)).listNullHash) + + // Int! + assertEquals(3, TypeExpr(GraphQLNonNull(GraphQLInt)).listNullHash) + + // [[Int]] + assertEquals( + 4, + TypeExpr( + GraphQLList(GraphQLList(GraphQLInt)) + ).listNullHash + ) + + // [Int!] + assertEquals( + 5, + TypeExpr( + GraphQLList(GraphQLNonNull(GraphQLInt)) + ).listNullHash + ) + + // [Int]! + assertEquals( + 6, + TypeExpr( + GraphQLNonNull(GraphQLList(GraphQLInt)) + ).listNullHash + ) + } + + @Test + fun `TypeExpr -- equality`() { + // no wrappers + assertEquals( + TypeExpr(GraphQLInt), + TypeExpr(GraphQLInt), + ) + + // with wrappers + assertEquals( + TypeExpr(GraphQLList(GraphQLNonNull(GraphQLInt))), + TypeExpr(GraphQLList(GraphQLNonNull(GraphQLInt))), + ) + + // wrappers in diff order are not equal + assertNotEquals( + TypeExpr(GraphQLNonNull(GraphQLList(GraphQLInt))), + TypeExpr(GraphQLList(GraphQLNonNull(GraphQLInt))), + ) + } + + @Test + fun `KeyTree -- create from field key`() { + Fixture("type Query { x:Int, q:Query }") { + val xk = key("Query", "x") + val xkt = KeyTree(xk) + assertNull(xkt[xk]) + + val qk = key("Query", "q") + val qkt = KeyTree(qk) + assertNotNull(qkt[qk]) } } - test("KeyTree merge recursively combines compatible branches") { - DocumentBuilderFixture("type Query { x: Int, q: Query }").run { - val x = key("Query", "x") - val q = key("Query", "q") - val typename = key("Query", "__typename") - KeyTree(x).merge(KeyTree(typename)).toMap().shouldBe(mapOf(x to null, typename to null)) + @Test + fun `KeyTree -- canMerge`() { + Fixture("type Query { x:Int, q:Query }") { + val xk = key("Query", "x") + val qk = key("Query", "q") + val tk = key("Query", "__typename") + + // empty-empty + assertTrue(KeyTree().canMerge(KeyTree())) - val first = KeyTree(q).apply { merge(q, KeyTree(x)) } - val second = KeyTree(q).apply { merge(q, KeyTree(typename)) } - first.merge(second).toMap().shouldBe(mapOf(q to mapOf(x to null, typename to null))) + // same selections + assertTrue( + KeyTree(xk).canMerge(KeyTree(xk)) + ) + + // different selections + assertTrue( + KeyTree(xk).canMerge(KeyTree(qk)) + ) + + // conflicting selections + assertFalse( + KeyTree(xk).canMerge( + KeyTree(key("Query", "q", "x")) + ) + ) + + // compatible nested selections + let { + val a = KeyTree(qk).apply { + merge(qk, KeyTree(xk)) + } + val b = KeyTree(qk).apply { + merge(qk, KeyTree(tk)) + } + assertTrue(a.canMerge(b)) + } + + // incompatible nested selections + let { + val a = KeyTree(qk).apply { + merge(qk, KeyTree(xk)) + } + val b = KeyTree(qk).apply { + merge(qk, KeyTree(key("Query", "q", "x"))) + } + assertFalse(a.canMerge(b)) + } } } - test("KeyTree clone is independent") { - DocumentBuilderFixture("type Query { x: Int, y: Int }").run { - val x = key("Query", "x") - val y = key("Query", "y") - val original = KeyTree(x) - val clone = original.clone() - original.toMap().shouldBe(clone.toMap()) - original.merge(KeyTree(y)) - original.toMap().shouldNotBe(clone.toMap()) + @Test + fun `KeyTree -- merge`() { + Fixture("type Query { x:Int, q:Query }") { + val xk = key("Query", "x") + val qk = key("Query", "q") + val tk = key("Query", "__typename") + + // empty + assertEquals( + emptyMap(), + KeyTree().merge(KeyTree()).toMap() + ) + + // same selections + assertEquals( + mapOf(xk to null), + KeyTree(xk).merge(KeyTree(xk)).toMap() + ) + + // compatible selections + assertEquals( + mapOf(xk to null, tk to null), + KeyTree(xk).merge(KeyTree(tk)).toMap() + ) + + // nested selections + let { + val a = KeyTree(qk).also { tree -> tree.merge(qk, KeyTree(xk)) } + val b = KeyTree(qk).also { tree -> tree.merge(qk, KeyTree(tk)) } + + val result = a.merge(b).toMap() + assertEquals( + mapOf( + qk to mapOf(xk to null, tk to null) + ), + result + ) + } } } - test("SelectionsBuilder emits scalar and aliased fields") { - DocumentBuilderFixture("type Query { x: Int }").run { - selections.add(FieldSelection(key("Query", "x"))) - assertDocument("{ x }") + @Test + fun `KeyTree -- clone`() { + Fixture("type Query { x:Int y:Int}") { + val xk = key("Query", "x") + val yk = key("Query", "y") + + val t1 = KeyTree(xk) + val t2 = t1.clone() + assertEquals(t1.toMap(), t2.toMap()) + + t1.merge(KeyTree(yk)) + assertNotEquals(t1.toMap(), t2.toMap()) + } + } + + @Test + fun `SelectionsBuilder -- add scalar field`() { + Fixture("type Query { x:Int }") { + sb.add(FieldSelection(key("Query", "x"))) + assertDocument("{x}") } - DocumentBuilderFixture("type Query { x: Int }").run { - selections.add(FieldSelection(key("Query", "x", "alias"))) - assertDocument("{ alias: x }") + } + + @Test + fun `SelectionsBuilder -- add aliased scalar`() { + Fixture("type Query { x:Int }") { + val xKey = field("Query", "x").key("alias") + sb.add(FieldSelection(xKey)) + assertDocument("{alias: x}") } } - test("SelectionsBuilder emits nested object selections") { - DocumentBuilderFixture("type Obj { y: Int } type Query { obj: Obj }").run { - val objectKey = key("Query", "obj") - val scope = selections.newFieldScope(objectKey) - scope.add(FieldSelection(key("Obj", "y"))) - selections.add(FieldSelection(objectKey, scope)) + @Test + fun `SelectionsBuilder -- add object field`() { + Fixture( + """ + type Obj { y:Int } + type Query { obj:Obj } + """.trimIndent() + ) { + val objk = key("Query", "obj") + val yk = key("Obj", "y") + val objs = sb.newFieldScope(objk) + objs.add(FieldSelection(yk)) + sb.add(FieldSelection(objk, objs)) + assertDocument("{ obj { y } }") } } - test("SelectionsBuilder emits typed and untyped inline fragments") { - DocumentBuilderFixture("type Query { x: Int }").run { - selections.add(InlineFragmentSelection(null, listOf(FieldSelection(key("Query", "x"))))) + @Test + fun `SelectionsBuilder -- add untyped inline fragment`() { + Fixture("type Query { x:Int }") { + sb.add( + InlineFragmentSelection( + null, + listOf( + FieldSelection(key("Query", "x")) + ) + ) + ) assertDocument("{ ... { x } }") } - DocumentBuilderFixture("type Query { x: Int }").run { - selections.add(InlineFragmentSelection("Query", listOf(FieldSelection(key("Query", "x"))))) - assertDocument("{ ... on Query { x } }") - } } - test("DocumentBuilder emits reusable fragment definitions") { - DocumentBuilderFixture("type Query { x: Int }").run { - val scope = selections.newSpreadScope() - scope.add(FieldSelection(key("Query", "x"))) - fragments += FragmentDef("Frag", schema.queryType, scope, emptyList(), emptyList()) - selections.add(FragmentSpreadSelection("Frag", scope.selections)) - selections.add(FragmentSpreadSelection("Frag", scope.selections)) - selections.add(FieldSelection(key("Query", "x"))) - assertDocument("{ ... Frag ... Frag x } fragment Frag on Query { x }") + @Test + fun `SelectionsBuilder -- add typed inline fragment`() { + Fixture("type Query { x:Int }") { + sb.add( + InlineFragmentSelection( + "Query", + listOf( + FieldSelection(key("Query", "x")) + ) + ) + ) + assertDocument("{ ... on Query { x } }") } } - test("SelectionsBuilder checks aliases and arguments before adding fields") { - DocumentBuilderFixture("type Query { x(arg: Int): Int }").run { - val x = field("Query", "x").key() - selections.canAdd(x).shouldBe(true) - selections.add(FieldSelection(x)) - selections.canAdd(x.copy()).shouldBe(true) - selections.canAdd(x.copy(alias = "alias")).shouldBe(true) - val withNull = setOf(ArgumentKey(Argument("arg", NullValue.of()))) - selections.canAdd(x.copy(arguments = withNull)).shouldBe(false) - selections.canAdd(x.copy(alias = "alias", arguments = withNull)).shouldBe(true) + @Test + fun `SelectionsBuilder -- add fragment spread`() { + Fixture("type Query { x:Int }") { + val defScope = sb.newSpreadScope() + defScope.add(FieldSelection(key("Query", "x"))) + val frag = FragmentDef( + "Frag", + query, + defScope, + emptyList(), + emptyList() + ) + fragments += frag + FragmentSpreadSelection("Frag", defScope.selections) + + sb.add(FragmentSpreadSelection("Frag", defScope.selections)) + sb.add(FragmentSpreadSelection("Frag", defScope.selections)) + sb.add(FieldSelection(key("Query", "x"))) + + assertDocument( + """ + { + ... Frag + ... Frag + x + } + fragment Frag on Query { x } + """.trimIndent() + ) } } - test("field scopes share merge constraints without losing local selections") { - DocumentBuilderFixture("type Obj { x: Int, y: Int, z: Int } type Query { obj: Obj }").run { - val objectKey = key("Query", "obj") - val firstScope = SelectionsBuilder(listOf(FieldSelection(key("Obj", "x")))) - selections.add(FieldSelection(objectKey, firstScope)) - - val secondScope = selections.newFieldScope(objectKey) - secondScope.canAdd(key("Obj", "x", "__typename")).shouldBe(true) - secondScope.canAdd(key("Obj", "__typename", "x")).shouldBe(false) - secondScope.add(FieldSelection(key("Obj", "y"))) - selections.add(FieldSelection(objectKey, secondScope)) - - val thirdScope = selections.newFieldScope(objectKey) - thirdScope.canAdd(key("Obj", "__typename", "x")).shouldBe(false) - thirdScope.canAdd(key("Obj", "__typename", "y")).shouldBe(false) - thirdScope.add(FieldSelection(key("Obj", "z"))) - selections.add(FieldSelection(objectKey, thirdScope)) - assertDocument("{ obj { x } obj { y } obj { z } }") + @Test + fun `SelectionsBuilder -- canAdd key`() { + Fixture("type Query { x(arg:Int):Int }") { + val xk = field("Query", "x").key() + assertTrue(sb.canAdd(xk)) + sb.add(FieldSelection(xk)) + + // can add key with same key values + assertTrue(sb.canAdd(xk.copy())) + + // can add key with an unused alias + assertTrue(sb.canAdd(xk.copy(alias = "alias"))) + + // cannot add key with different arguments + assertFalse(sb.canAdd(xk.copy(arguments = setOf(ArgumentKey(Argument("arg", NullValue.of())))))) + + // can add key with an unused alias and different arguments + assertTrue(sb.canAdd(xk.copy(alias = "alias", arguments = setOf(ArgumentKey(Argument("arg", NullValue.of())))))) } } -}) -private class DocumentBuilderFixture(sdl: String) { - val schema = parseTestSchema(sdl) - private val schemas = Schemas(schema) - val fragments = Fragments(schemas) - val selections = SelectionsBuilder() + @Test + fun `SelectionsBuilder -- canAdd fieldScope`() { + Fixture( + """ + type Obj { x:Int, y:Int, z:Int } + type Query { obj:Obj } + """.trimIndent() + ) { + // construct a selection set like: + // { + // obj { x } + // obj { y } + // obj { z } + // } + val objk = key("Query", "obj") + sb.add( + FieldSelection( + objk, + SelectionsBuilder( + listOf( + FieldSelection(key("Obj", "x"), null) + ) + ) + ) + ) - fun field(type: String, fieldName: String): GraphQLFieldDefinition { - if (fieldName == Introspection.TypeNameMetaFieldDef.name) return Introspection.TypeNameMetaFieldDef - return requireNotNull(schema.getFieldDefinition(FieldCoordinates.coordinates(type, fieldName))) - } + assertDocument("{ obj { x } }") - fun key( - type: String, - fieldName: String, - alias: String? = null, - vararg arguments: Argument - ): FieldKey = field(type, fieldName).key(alias, arguments.toSet()) + sb.newFieldScope(objk).also { obj2 -> + // can add `x`, which is mergeable with selection x above + assertTrue(obj2.canAdd(key("Obj", "x", "__typename"))) + // cannot add `x:__typename`, which would conflict with selection above + assertFalse(obj2.canAdd(key("Obj", "__typename", "x"))) + obj2.add(FieldSelection(key("Obj", "y"))) - fun typeExpr(name: String): TypeExpr = TypeExpr(requireNotNull(schema.getType(name)) as graphql.schema.GraphQLOutputType) + FieldSelection(objk, obj2).also { + assertTrue(sb.canAdd(it)) + sb.add(it) + } + } - fun assertDocument(expected: String) { - val builder = DocumentBuilder(schemas, fragments) - builder.add( - OperationDefinition.newOperationDefinition() - .operation(OperationDefinition.Operation.QUERY) - .selectionSet(selections.build()) - .build() - ) - val expectedDocument = Parser().parseDocument(expected) - AstPrinter.printAst(builder.build()).shouldBe(AstPrinter.printAst(expectedDocument)) + assertDocument( + """ + { + obj { x } + obj { y } + } + """.trimIndent() + ) + + sb.newFieldScope(objk).also { obj3 -> + // cannot add `x:__typename`, which would conflict with selection above + assertFalse(obj3.canAdd(key("Obj", "__typename", "x"))) + assertFalse(obj3.canAdd(key("Obj", "__typename", "y"))) + obj3.add(FieldSelection(key("Obj", "z"))) + + FieldSelection(objk, obj3).also { + assertTrue(sb.canAdd(it)) + sb.add(it) + } + } + + assertDocument( + """ + { + obj { x } + obj { y } + obj { z } + } + """.trimIndent() + ) + } } } 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 new file mode 100644 index 000000000..108151752 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/FailureCollector.kt @@ -0,0 +1,735 @@ +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/GeneratorUtilsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratorUtilsTest.kt deleted file mode 100644 index 5d24a6fe5..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GeneratorUtilsTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) - -package graphql.schema.property - -import graphql.schema.GraphQLInterfaceType -import graphql.schema.GraphQLType -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.RandomSource -import io.kotest.property.arbitrary.arbitrary -import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.set -import io.kotest.property.checkAll - -class GeneratorUtilsTest : FunSpec({ - test("sampleWeight handles deterministic boundary weights") { - checkAll(PropTestConfig(iterations = 100), arbitrary { randomSource -> randomSource.sampleWeight(1.0) }) { - it.shouldBe(true) - } - checkAll(PropTestConfig(iterations = 100), arbitrary { randomSource -> randomSource.sampleWeight(0.0) }) { - it.shouldBe(false) - } - } - - test("count observes Never, Once, and configured maxima") { - checkAll(PropTestConfig(iterations = 100), Arb.int(0..100), Arb.int()) { maximum, seed -> - val randomSource = RandomSource.seeded(seed.toLong()) - randomSource.count(CompoundingWeight.Never).shouldBe(0) - randomSource.count(CompoundingWeight.Once).shouldBe(1) - randomSource.count(CompoundingWeight(1.0, maximum)).shouldBe(maximum) - } - } - - test("arbSubset returns only source elements and clamps its size range") { - checkAll(PropTestConfig(iterations = 100), Arb.set(Arb.int(), 0..30)) { values -> - val subset = values.arbSubset().bind() - values.containsAll(subset).shouldBe(true) - values.arbSubset(values.size..values.size).bind().shouldBe(values) - values.arbSubset((values.size + 1)..(values.size + 10)).bind().shouldBe(values) - } - } - - test("schema and AST type wrappers round trip") { - val schema = parseTestSchema( - "type Query { a: Int, b: [[Int]], c: [[Int!]!]! }" - ) - val types = listOf("a", "b", "c").map { name -> schema.queryType.getFieldDefinition(name).type } - types.forEach { type -> - val ast = type.asAstType() - sameType(type, ast.asSchemaType(schema)).shouldBe(true) - } - } - - test("nonConflicting keeps related interfaces together and rejects unrelated field collisions") { - val schema = parseTestSchema( - """ - interface Parent { x: Int } - interface Child implements Parent { x: Int, y: Int } - interface Conflict { x: String } - interface Independent { z: Int } - type Obj implements Parent & Child { x: Int, y: Int } - type Query { obj: Obj } - """.trimIndent() - ) - val interfaces = setOf("Parent", "Child", "Conflict", "Independent") - .mapTo(linkedSetOf()) { schema.getType(it) as GraphQLInterfaceType } - val selected = interfaces.nonConflicting() - selected.map { it.name }.contains("Independent").shouldBe(true) - (selected.map { it.name }.intersect(setOf("Parent", "Child", "Conflict")).size == 1).shouldBe(true) - } -}) - -private fun sameType(first: GraphQLType, second: GraphQLType): Boolean = - first.toString() == second.toString() diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLComparators.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLComparators.kt deleted file mode 100644 index 6f9f4373e..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLComparators.kt +++ /dev/null @@ -1,13 +0,0 @@ -package graphql.schema.property - -import graphql.ExecutionInput -import graphql.language.Document -import graphql.parser.Parser - -/** Order documents by their AST node count for property-failure minimization. */ -internal val DocumentComparator: Comparator = - Comparator.comparingInt { it.allChildren.size } - -/** Order execution inputs by the AST node count of their query document. */ -internal val ExecutionInputComparator: Comparator = - Comparator.comparingInt { Parser().parseDocument(it.query).allChildren.size } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt index 750ff4037..a2d797458 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLDocumentGenTest.kt @@ -1,4 +1,4 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) +@file:Suppress("ForbiddenImport") package graphql.schema.property @@ -19,398 +19,663 @@ import graphql.language.OperationDefinition import graphql.language.VariableDefinition import graphql.language.VariableReference import graphql.schema.GraphQLSchema -import graphql.schema.idl.FastSchemaGenerator -import graphql.schema.idl.RuntimeWiring -import graphql.schema.idl.SchemaParser -import io.kotest.assertions.withClue -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldNotBeEmpty -import io.kotest.matchers.shouldBe +import graphql.validation.ValidationError import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.flatMap +import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.map -import io.kotest.property.checkAll - -class GraphQLDocumentGenTest : FunSpec({ - test("generates valid documents for a trivial schema with minimal config") { - assertAllDocumentsValid(schema("type Query { x: Int }"), minimalDocumentConfig) - } +import io.kotest.property.arbitrary.take +import io.kotest.property.assume +import kotlin.math.sqrt +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.fail + +class GraphQLDocumentGenTest : KotestPropertyBase( + iterations = 5_000 +) { + private val schema = + """ + directive @dir(arg:Int!) repeatable on QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | VARIABLE_DEFINITION + input Input { a:Int, b:[Int!]!, c:[[[Int]]] } + type Foo implements I1 { x:Int, next:Union, i1(inp:Input!):I1! } + type Bar implements I1 & I2 { x:Int, y:Int, next:Union } + union Union = Foo | Bar + interface I1 { x:Int } + interface I2 { y:Int } + type Query { x(a:Int!):Int, u:Union, y(inp:Input!): Union } + type Mutation { x:Int, y:Int } + type Subscription { x:Int, y:Int } + """.trimIndent().asSchema + + private fun mkConfig( + aliasWeight: Double = 0.0, + anonymousOperationWeight: Double = 0.0, + banDirectiveNames: Set = emptySet(), + banSelectionCoordinates: Set = emptySet(), + documentUncoercedValueWeight: Double = 0.0, + directiveWeight: CompoundingWeight = CompoundingWeight.Never, + explicitNullValueWeight: Double = 0.0, + fieldNameLength: Int = 4, + fieldSelectionWeight: CompoundingWeight = CompoundingWeight.Once, + fragmentDefinitionWeight: Double = 0.0, + fragmentSpreadWeight: CompoundingWeight = CompoundingWeight.Never, + implicitNullValueWeight: Double = 0.0, + inlineFragmentWeight: CompoundingWeight = CompoundingWeight.Never, + maxSelectionSetDepth: Int = 1, + operationCount: Int = 1, + typeNameLength: Int = 4, + 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) + + @Test + fun `manual inspection`(): Unit = + // this test makes no assertions but is useful for visually inspecting + // the generated documents to see that they are being generated as we + // expect + runBlocking { + Arb.graphQLDocument(schema).checkAll(10) { doc -> + assertEquals(emptyList(), validate(schema, doc)) + + val docString = AstPrinter.printAst(doc) + print(docString) + println("-----") + // set a breakpoint on the next line + markSuccess() + } + } - test("generates valid documents for a trivial schema with default config") { - assertAllDocumentsValid(schema("type Query { x: Int }"), Config.default) + @Test + fun `Arb_graphQLDocument -- generates valid documents with minimal config for trivial schema`() { + assertAllDocumentsValid("type Query { x:Int }", mkConfig()) } - test("generates valid documents for a representative schema with minimal config") { - assertAllDocumentsValid(documentTestSchema, minimalDocumentConfig) + @Test + fun `Arb_graphQLDocument -- generates valid documents with default config for trivial schema`() { + assertAllDocumentsValid("type Query { x:Int }", Config.default) } - test("generates valid documents for a representative schema with default config") { - assertAllDocumentsValid(documentTestSchema, Config.default) + @Test + fun `Arb_graphQLDocument -- generates valid documents with default config`() { + assertAllDocumentsValid(schema, Config.default) } - test("generates valid documents for schemas with default values") { - val schema = schema( - """ - directive @dir(inp: Inp = {x: 2}) repeatable on QUERY | FIELD - input Inp { x: Int = 1, y: Int! = 2, z: Int = null } - type Query { x(inp: Inp = {y: 3, z: 0}): Int } - """.trimIndent() - ) + @Test + fun `Arb_graphQLDocument -- generates valid documents for schemas with default values`() { + val schema = """ + directive @dir(inp:Inp = {x:2}) repeatable on QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | VARIABLE_DEFINITION + input Inp { x:Int = 1, y:Int! = 2, z:Int = null } + type Query { + x(inp: Inp = { y: 3, z:0 }): Int + } + """.trimIndent().asSchema assertAllDocumentsValid(schema, Config.default) } - test("generates valid documents for arbitrary generated schemas") { - val config = Config.default + + @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 cases = Arb.graphQLSchema(config).flatMap { schema -> - Arb.graphQLDocument(schema, config).map { document -> schema to document } - } - checkAll(PropTestConfig(iterations = 500), cases) { (schema, document) -> - assertValid(schema, document) - } + + // 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 + val iter = sqrt(iterations.toDouble()).toInt() + + // GJ schemas + Arb + .graphQLSchema(cfg) + .take(iter, randomSource) + .forEach { schema -> + assertAllDocumentsValid(schema, cfg, iter) + } + } - test("handles merged selections, unions, and recursive types") { - val schemas = listOf( + @Test + fun `Arb_graphQLDocument -- generates valid documents with minimal config`() { + assertAllDocumentsValid(schema, mkConfig()) + } + + @Test + fun `Arb_graphQLDocument -- merged field subselections`() = + assertAllDocumentsValid( """ - type Obj { x: Int, next: Obj, others: [Obj] } - type Query { obj: Obj } - """.trimIndent(), + type Obj { x:Int, next:Obj, others: [Obj] } + type Query { obj:Obj } + """.trimIndent() + ) + + @Test + fun `Arb_graphQLDocument -- merged field subselections with interfaces`() = + assertAllDocumentsValid( """ - interface I { i: I } - type Obj implements I { x: Int, i: I } + interface I { i:I } + type Obj implements I { x:Int, i:I } type Query { i: I } - """.trimIndent(), + """.trimIndent() + ) + + @Test + fun `Arb_graphQLDocument -- union selections`() = + assertAllDocumentsValid( """ - type Foo { x: Int, u: U } - type Bar { y: Int, u: U } + type Foo { x:Int, u:U } + type Bar { y:Int, u:U } union U = Foo | Bar - type Query { u: U } + type Query { u:U } """.trimIndent(), + cfg = Config.default + (FragmentSpreadWeight to CompoundingWeight.Never) + ) + + @Test + fun `Arb_graphQLDocument -- cyclic input objects`() = + assertAllDocumentsValid( """ - input Input { x: Int, input: Input } - type Query { x(input: Input!): Int } - """.trimIndent(), + input Input { x:Int, i:Input } + type Query { x(inp:Input!):Int } + """.trimIndent() + ) + + @Test + fun `Arb_graphQLDocument -- cyclic output objects`() = + assertAllDocumentsValid( """ - type Obj { obj: Obj! } - type Query { obj: Obj! } + type Obj { obj:Obj! } + type Query { obj:Obj! } """.trimIndent() ) - schemas.forEach { sdl -> assertAllDocumentsValid(schema(sdl), Config.default, 100) } - } - test("generates valid subscription documents") { - val schema = schema( + @Test + fun `Arb_graphQLDocument -- schemas with subscriptions`() = + assertAllDocumentsValid( """ - type Obj { x: Int, child: Obj } - type Subscription { x: Int, obj: Obj } - type Query { x: Int } + type Obj { x:Int, y:Obj } + type Subscription { x:Int, obj:Obj } + type Query { x:Int } """.trimIndent() ) - assertAllDocumentsValid(schema, Config.default) - } - test("AliasWeight controls aliases") { - val schema = schema("type Query { x: Int }") - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, minimalDocumentConfig + (AliasWeight to 0.0))) { document -> - document.allChildrenOfType().all { it.alias == null }.shouldBe(true) - } - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, minimalDocumentConfig + (AliasWeight to 1.0))) { document -> - document.allChildrenOfType().all { it.alias != null }.shouldBe(true) + @Test + fun `Arb_graphQLDocument -- schemas with subscriptions and incremental directives`(): Unit = + runBlocking { + mkConfig(directiveWeight = CompoundingWeight.Always).let { cfg -> + val schema = """ + directive @defer(if: Boolean = true, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT + directive @stream(if: Boolean = true, label: String, initialCount: Int = 0) on FIELD + type Obj { x:Int, y:Obj } + type Subscription { x:Int, obj:Obj } + type Query { x:Int } + """.trimIndent().asSchema + + // incremental directives may not appear anywhere in a subscription operation + Arb.graphQLDocument(schema, cfg).forAll { doc -> + doc + .getDefinitionsOfType(OperationDefinition::class.java) + .filter { it.operation == OperationDefinition.Operation.SUBSCRIPTION } + .all { op -> + op.allChildrenOfType().none { it.name == "defer" || it.name == "stream" } + } + } + } } - } - test("FieldNameLength controls generated alias names") { - val schema = schema("type Query { x: Int }") - val cases = Arb.int(1..10).flatMap { length -> - val config = minimalDocumentConfig + - (AliasWeight to 1.0) + - (FieldNameLength to length..length) - Arb.graphQLDocument(schema, config).map { length to it } - } - checkAll(documentPropertyConfig, cases) { (length, document) -> - document.allChildrenOfType() - .filter { it.alias != "x" && it.alias != "__typename" } - .all { it.alias?.length == length } - .shouldBe(true) + @Test + fun `Arb_graphQLDocument -- schemas with mutations and incremental directives`(): Unit = + runBlocking { + mkConfig(directiveWeight = CompoundingWeight.Always).let { cfg -> + val schema = """ + directive @defer(if: Boolean = true, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT + directive @stream(if: Boolean = true, label: String, initialCount: Int = 0) on FIELD + type Obj { x:Int, y:Obj } + type Mutation { x:Int, obj:Obj } + type Query { x:Int } + """.trimIndent().asSchema + + // incremental directives may not be used on a mutation root selection + Arb.graphQLDocument(schema, cfg).forAll { doc -> + doc + .getDefinitionsOfType(OperationDefinition::class.java) + .filter { it.operation == OperationDefinition.Operation.MUTATION } + .flatMap { it.selectionSet.selections } + .mapNotNull { it as? DirectivesContainer<*> } + .all { sel -> + sel.directives.none { it.name == "defer" || it.name == "stream" } + } + } + } } - } - test("AnonymousOperationWeight controls anonymous operations") { - val schema = schema("type Query { x: Int }") - val named = minimalDocumentConfig + (AnonymousOperationWeight to 0.0) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, named)) { document -> - document.getDefinitionsOfType(OperationDefinition::class.java).all { it.name != null }.shouldBe(true) - } + @Test + fun AliasWeight() { + runBlocking { + // disabled + mkConfig(aliasWeight = 0.0).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + doc.allChildrenOfType().all { it.alias == null } + } + } - val anonymous = minimalDocumentConfig + (AnonymousOperationWeight to 1.0) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, anonymous)) { document -> - val operations = document.getDefinitionsOfType(OperationDefinition::class.java) - operations.size.shouldBe(1) - operations.single().name.shouldBe(null) + // enabled + mkConfig(aliasWeight = 1.0).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + doc.allChildrenOfType().all { it.alias != null } + } + } } } - test("BanSelectionCoordinates excludes configured fields") { - val schema = schema("type Query { x: Int, y: Int }") - val config = minimalDocumentConfig + - (BanSelectionCoordinates to setOf("Query" to "x")) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, config)) { document -> - document.allChildrenOfType().none { it.name == "x" }.shouldBe(true) + @Test + fun AnonymousOperationWeight() { + runBlocking { + // disabled + mkConfig(anonymousOperationWeight = 0.0).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { + val operations = it.getDefinitionsOfType(OperationDefinition::class.java) + operations.isNotEmpty() && operations.all { op -> op.name != null } + } + } + + // enabled + mkConfig(anonymousOperationWeight = 1.0).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { + val operations = it.getDefinitionsOfType(OperationDefinition::class.java) + operations.size == 1 && operations[0].name == null + } + } } } - test("AppliedDirectiveWeight and BanDirectiveNames control document directives") { - val schema = schema( - """ - directive @dir(arg: Int) repeatable on QUERY | FIELD - type Query { x: Int } - """.trimIndent() - ) - val enabled = minimalDocumentConfig + - (AppliedDirectiveWeight to CompoundingWeight.Once) + - (BanDirectiveNames to builtinDirectives.keys) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, enabled)) { document -> - document.allChildrenOfType().filter { it.name == "dir" }.shouldNotBeEmpty() + @Test + fun BanSelectionCoordinates(): Unit = + runBlocking { + val schema = "type Query { x:Int }".asSchema + + // disabled + mkConfig(banSelectionCoordinates = emptySet()).let { cfg -> + Arb.graphQLDocument(schema, cfg) + .asSequence(randomSource) + .take(1000) + .any { + val fields = it.allChildrenOfType() + fields.any { it.name == "x" } + } + } + + // enabled + mkConfig(banSelectionCoordinates = setOf("Query" to "x")).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { + val fields = it.allChildrenOfType() + fields.none { it.name == "x" } + } + } } - val disabled = enabled + (AppliedDirectiveWeight to CompoundingWeight.Never) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, disabled)) { document -> - document.allChildrenOfType().shouldBeEmpty() - } + @Test + fun DirectiveWeight(): Unit = + runBlocking { + // disabled + mkConfig(directiveWeight = CompoundingWeight.Never).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { + it.allChildrenOfType>().all { child -> child.directives.isEmpty() } + } + } - val banned = enabled + (BanDirectiveNames to (builtinDirectives.keys + "dir")) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, banned)) { document -> - document.allChildrenOfType().none { it.name == "dir" }.shouldBe(true) + // enabled + mkConfig(directiveWeight = CompoundingWeight.Once).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { + it.allChildrenOfType>().all { child -> child.directives.isNotEmpty() } + } + } } - } - test("DocumentUncoercedValueWeight controls coercible literals") { - val schema = schema("type Query { field(arg: Float!): Int }") - val base = minimalDocumentConfig + - (BanSelectionCoordinates to setOf("Query" to "__typename")) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (DocumentUncoercedValueWeight to 0.0))) { document -> - document.allChildrenOfType().all { it.value is FloatValue }.shouldBe(true) - } - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (DocumentUncoercedValueWeight to 1.0))) { document -> - document.allChildrenOfType().all { it.value is IntValue }.shouldBe(true) - } - } + @Test + fun `DocumentUncoercedValueWeight`(): Unit = + runBlocking { + val schema = "extend type Query { field(arg: Float!):Int }".asPermissiveTestSchema - test("fragment configuration generates valid named and inline fragments") { - checkAll(documentPropertyConfig, Arb.graphQLDocument(documentTestSchema, minimalDocumentConfig)) { document -> - document.allChildrenOfType().shouldBeEmpty() - document.allChildrenOfType().shouldBeEmpty() - } + // disabled + mkConfig(documentUncoercedValueWeight = 0.0).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { + it.allChildrenOfType().all { arg -> arg.value is FloatValue } + } + } - val namedConfig = minimalDocumentConfig + - (FieldSelectionWeight to CompoundingWeight.Never) + - (FragmentSpreadWeight to CompoundingWeight.Once) + - (FragmentDefinitionWeight to 1.0) - checkAll(documentPropertyConfig, Arb.graphQLDocument(documentTestSchema, namedConfig)) { document -> - val spreads = document.allChildrenOfType() - val definitions = document.getDefinitionsOfType(FragmentDefinition::class.java) - spreads.shouldNotBeEmpty() - definitions.shouldNotBeEmpty() - spreads.size.shouldBe(definitions.size) - assertValid(documentTestSchema, document) + // enabled + mkConfig(documentUncoercedValueWeight = 1.0).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { + it.allChildrenOfType().all { arg -> arg.value is IntValue } + } + } } - val inlineConfig = minimalDocumentConfig + - (FieldSelectionWeight to CompoundingWeight.Never) + - (InlineFragmentWeight to CompoundingWeight.Once) - checkAll(documentPropertyConfig, Arb.graphQLDocument(documentTestSchema, inlineConfig)) { document -> - document.allChildrenOfType().shouldNotBeEmpty() - assertValid(documentTestSchema, document) + @Test + fun BanDirectiveNames(): Unit = + runBlocking { + // enabled + mkConfig(directiveWeight = CompoundingWeight.Once, banDirectiveNames = setOf("dir")).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { + it.allChildrenOfType().none { d -> d.name == "dir" } + } + } } - } - test("UntypedInlineFragmentWeight controls type conditions") { - val schema = schema("type Query { x: Int }") - val base = minimalDocumentConfig + - (FieldSelectionWeight to CompoundingWeight.Never) + - (InlineFragmentWeight to CompoundingWeight.Once) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (UntypedInlineFragmentWeight to 0.0))) { document -> - document.allChildrenOfType().all { it.typeCondition != null }.shouldBe(true) + @Test + fun `FieldNameLength -- alias names`(): Unit = + runBlocking { + val schema = "type Query { x: Int }".asSchema + + arbitrary { + val length = Arb.int(1..10).bind() + val cfg = mkConfig(aliasWeight = 1.0, fieldNameLength = length) + length to Arb.graphQLDocument(schema, cfg).bind() + }.forAll { (length, doc) -> + val fields = doc + .allChildrenOfType() + // the alias generator will try to generate some alias names that collide with field names + // which will ignore the FieldNameLength config. Filter these out + .filter { it.alias != "x" && it.alias != "__typename" } + + fields.all { it.alias?.length == length } + } } - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (UntypedInlineFragmentWeight to 1.0))) { document -> - document.allChildrenOfType().all { it.typeCondition == null }.shouldBe(true) + + @Test + fun FragmentDefinitionWeight(): Unit = + runBlocking { + val schema = "type Query { x:Int }".asSchema + + // enabled: bias to new fragments + mkConfig( + fragmentDefinitionWeight = 1.0, + fragmentSpreadWeight = CompoundingWeight.Once + ).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + val spreads = doc.allChildrenOfType() + val defs = doc.allChildrenOfType() + spreads.size == defs.size + } + } } - } - test("ImplicitNullValueWeight controls optional arguments") { - val schema = schema("type Query { x(a: Int, b: Int! = 0, c: Int = 0): Int }") - val base = minimalDocumentConfig + - (BanSelectionCoordinates to setOf("Query" to "__typename")) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (ImplicitNullValueWeight to 0.0))) { document -> - document.allChildrenOfType().filter { it.name == "x" }.all { it.arguments.size == 3 }.shouldBe(true) + @Test + fun FragmentSpreadWeight(): Unit = + runBlocking { + // disabled + mkConfig(fragmentSpreadWeight = CompoundingWeight.Never).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + doc.allChildrenOfType().isEmpty() + } + } + + // enabled + mkConfig(fragmentSpreadWeight = CompoundingWeight.Once).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + doc.allChildrenOfType().isNotEmpty() + } + } } - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, base + (ImplicitNullValueWeight to 1.0))) { document -> - document.allChildrenOfType().filter { it.name == "x" }.all { it.arguments.isEmpty() }.shouldBe(true) + + @Test + fun ImplicitNullValueWeight(): Unit = + runBlocking { + val schema = "type Query { x(a:Int, b:Int!=0, c:Int=0):Int }".asSchema + + // disabled + mkConfig( + fieldSelectionWeight = CompoundingWeight.Once, + implicitNullValueWeight = 0.0 + ).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + val fields = doc + .allChildrenOfType() + .filter { it.name == "x" } + assume(fields.isNotEmpty()) + fields.all { it.arguments.size == 3 } + } + } + + // enabled + mkConfig( + fieldSelectionWeight = CompoundingWeight.Once, + implicitNullValueWeight = 1.0 + ).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + val fields = doc.allChildrenOfType() + fields.all { it.arguments.isEmpty() } + } + } } - } - test("OperationCount controls operation definitions") { - val cases = Arb.int(1..5).flatMap { count -> - val config = minimalDocumentConfig + - (AnonymousOperationWeight to 0.0) + - (OperationCount to count..count) - Arb.graphQLDocument(documentTestSchema, config).map { count to it } + @Test + fun InlineFragmentWeight(): Unit = + runBlocking { + // disabled + mkConfig(inlineFragmentWeight = CompoundingWeight.Never).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + doc.allChildrenOfType().isEmpty() + } + } + + // enabled + mkConfig( + fieldSelectionWeight = CompoundingWeight.Never, + inlineFragmentWeight = CompoundingWeight.Once + ).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + doc.allChildrenOfType().isNotEmpty() + } + } } - checkAll(documentPropertyConfig, cases) { (count, document) -> - document.getDefinitionsOfType(OperationDefinition::class.java).size.shouldBe(count) + + @Test + fun OperationCount(): Unit = + runBlocking { + arbitrary { + val operationCount = Arb.int(1..10).bind() + val cfg = mkConfig(operationCount = operationCount) + val doc = Arb.graphQLDocument(schema, cfg).bind() + operationCount to doc + }.forAll { (operationCount, doc) -> + doc.allChildrenOfType().size == operationCount + } } - } - test("VariableWeight controls variable generation") { - val schema = schema("type Query { x(a: Int!): Int }") - val disabled = minimalDocumentConfig + - (BanSelectionCoordinates to setOf("Query" to "__typename")) + - (VariableWeight to 0.0) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, disabled)) { document -> - document.allChildrenOfType().shouldBeEmpty() - document.allChildrenOfType().shouldBeEmpty() + @Test + fun UntypedInlineFragmentWeight(): Unit = + runBlocking { + val schema = "type Query { x:Int }".asSchema + + // disabled + mkConfig(inlineFragmentWeight = CompoundingWeight.Once, untypedInlineFragmentWeight = 0.0).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + val inlineFragments = doc.allChildrenOfType() + assume(inlineFragments.isNotEmpty()) + inlineFragments.all { it.typeCondition != null } + } + } + + // enabled + mkConfig(inlineFragmentWeight = CompoundingWeight.Once, untypedInlineFragmentWeight = 1.0).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + val inlineFragments = doc.allChildrenOfType() + assume(inlineFragments.isNotEmpty()) + inlineFragments.all { it.typeCondition == null } + } + } } - val config = minimalDocumentConfig + - (BanSelectionCoordinates to setOf("Query" to "__typename")) + - (VariableWeight to 1.0) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, config)) { document -> - document.allChildrenOfType().shouldNotBeEmpty() - document.allChildrenOfType().shouldNotBeEmpty() - assertValid(schema, document) + @Test + fun VariableWeight(): Unit = + runBlocking { + val schema = "type Query { x(a:Int!):Int }".asSchema + + // disabled + mkConfig(variableWeight = 0.0).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + val defs = doc.allChildrenOfType() + val refs = doc.allChildrenOfType() + defs.isEmpty() && refs.isEmpty() + } + } + + // enabled + mkConfig(variableWeight = 1.0).let { cfg -> + Arb.graphQLDocument(schema, cfg).forAll { doc -> + // skip over any generations don't select the 'x' field + assume(doc.allChildrenOfType().any { it.name == "x" }) + + val defs = doc.allChildrenOfType() + val refs = doc.allChildrenOfType() + defs.isNotEmpty() && refs.isNotEmpty() + } + } } - } - test("reuses variables safely across operations and fragments") { - val schema = schema("type Query { x(a: Int!): Int }") - val config = Config.default + + @Test + fun `VariableWeight -- variable reuse stress test`() { + // Multi-operation documents can have variable reconciliation problems. + // 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) - assertAllDocumentsValid(schema, config) + assertAllDocumentsValid("type Query { x(a:Int!):Int }", cfg) } - test("directives on variable definitions do not reference variables") { - val config = Config.default + - (AppliedDirectiveWeight to CompoundingWeight.Once) + - (VariableWeight to 1.0) - checkAll(documentPropertyConfig, Arb.graphQLDocument(documentTestSchema, config)) { document -> - document.allChildrenOfType() - .flatMap { it.allChildrenOfType() } - .flatMap { it.allChildrenOfType() } - .shouldBeEmpty() + @Test + fun `VariableWeight -- no variables-in-directives-on-variables`(): Unit = + runBlocking { + // VariableDefinitions may have directives, though those directives may not use variables. + // A validation hole in graphql-java allows documents with this pattern to pass validation + // and be executed even though they are not valid according to the spec: + // 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 schema = "type Query { x(a:Int!):Int }".asSchema + + Arb + .graphQLDocument(schema, cfg) + .forAll { doc -> + val badVariableRefs = doc + .allChildrenOfType() + .flatMap { it.allChildrenOfType() } + .flatMap { it.allChildrenOfType() } + + badVariableRefs.isEmpty() + } } - } - test("nullable variables are not used in non-null positions") { - val schema = schema("type Query { x(a: Int, b: Int! = 0): Int }") - val config = minimalDocumentConfig + - (BanSelectionCoordinates to setOf("Query" to "__typename")) + - (VariableWeight to 1.0) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, config)) { document -> - val definitions = document.allChildrenOfType().associateBy { it.name } - document.allChildrenOfType() - .filter { it.name == "b" } - .flatMap { it.allChildrenOfType() } - .all { definitions.getValue(it.name).type is NonNullType } - .shouldBe(true) + @Test + fun `VariableWeight -- nullable variables are not used in non-nullable positions`(): Unit = + runBlocking { + val cfg = mkConfig(variableWeight = 1.0) + val schema = "type Query { x(a:Int, b:Int!=0):Int }".asSchema + Arb.graphQLDocument(schema, cfg).forAll { doc -> + val bargs = doc.allChildrenOfType().filter { it.name == "b" } + assume(bargs.isNotEmpty()) + + val vdefs = doc.allChildrenOfType().associateBy { it.name } + bargs + .flatMap { it.allChildrenOfType() } + .map { vdefs[it.name]!!.type } + .all { it is NonNullType } + } } - } - test("incremental directives are excluded from subscription operations and mutation roots") { - val schema = schema( - """ - directive @defer(if: Boolean = true, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT - directive @stream(if: Boolean = true, label: String, initialCount: Int = 0) on FIELD - type Obj { x: Int, child: Obj } - type Mutation { x: Int, obj: Obj } - type Subscription { x: Int, obj: Obj } - type Query { x: Int } - """.trimIndent() - ) - val config = minimalDocumentConfig + - (AppliedDirectiveWeight to CompoundingWeight.Once) + - (OperationCount to 3..3) - checkAll(documentPropertyConfig, Arb.graphQLDocument(schema, config)) { document -> - document.getDefinitionsOfType(OperationDefinition::class.java) - .filter { it.operation == OperationDefinition.Operation.SUBSCRIPTION } - .all { operation -> - operation.allChildrenOfType().none { it.name == "defer" || it.name == "stream" } - } - .shouldBe(true) - - document.getDefinitionsOfType(OperationDefinition::class.java) - .filter { it.operation == OperationDefinition.Operation.MUTATION } - .flatMap { it.selectionSet.selections } - .filterIsInstance>() - .all { selection -> selection.directives.none { it.name == "defer" || it.name == "stream" } } - .shouldBe(true) + private fun assertAllDocumentsValid( + sdl: String, + cfg: Config = Config.default + ) = assertAllDocumentsValid(sdl.asSchema, cfg) + + private fun assertAllDocumentsValid( + schema: GraphQLSchema, + cfg: Config = Config.default, + iter: Int = iterations + ): Unit = + runBlocking { + Arb.graphQLDocument(schema, cfg).assertAllValid(schema, iter) } - } -}) - -private val documentPropertyConfig = PropTestConfig(iterations = 100) - -private val minimalDocumentConfig = Config.default + - (AliasWeight to 0.0) + - (AnonymousOperationWeight to 0.0) + - (AppliedDirectiveWeight to CompoundingWeight.Never) + - (DocumentUncoercedValueWeight to 0.0) + - (ExplicitNullValueWeight to 0.0) + - (FieldNameLength to 4..4) + - (FieldSelectionWeight to CompoundingWeight.Once) + - (FragmentDefinitionWeight to 0.0) + - (FragmentSpreadWeight to CompoundingWeight.Never) + - (ImplicitNullValueWeight to 0.0) + - (InlineFragmentWeight to CompoundingWeight.Never) + - (MaxSelectionSetDepth to 1) + - (OperationCount to 1..1) + - (TypeNameLength to 4..4) + - (UntypedInlineFragmentWeight to 0.0) + - (VariableWeight to 0.0) - -private val documentTestSchema = schema( - """ - directive @dir(arg: Int!) repeatable on QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | VARIABLE_DEFINITION - input Input { a: Int, b: [Int!]!, c: [[[Int]]] } - type Foo implements I1 { x: Int, next: Union, i1(input: Input!): I1! } - type Bar implements I1 & I2 { x: Int, y: Int, next: Union } - union Union = Foo | Bar - interface I1 { x: Int } - interface I2 { y: Int } - type Query { x(a: Int!): Int, union: Union, y(input: Input!): Union } - type Mutation { x: Int, y: Int } - type Subscription { x: Int, y: Int } - """.trimIndent() -) - -private fun schema(sdl: String): GraphQLSchema = - FastSchemaGenerator().makeExecutableSchema( - SchemaParser().parse(sdl), - RuntimeWiring.MOCKED_WIRING - ) - -private suspend fun assertAllDocumentsValid( - schema: GraphQLSchema, - config: Config, - iterations: Int = 250 -) { - checkAll(PropTestConfig(iterations = iterations), Arb.graphQLDocument(schema, config)) { document -> - assertValid(schema, document) + + private fun Arb.assertAllValid( + schema: GraphQLSchema, + iter: Int = iterations + ): Unit = + runBlocking { + minInvalid(schema, iter)?.let { doc -> + val errors = validate(schema, doc) + debug(schema, doc, errors) + fail( + buildString { + append("Testing failed with seed: $seed\n") + append("Document is not valid:\n") + errors.forEach { append(" - $it\n") } + append("Document:\n") + append(AstPrinter.printAst(doc)) + append("\n") + } + ) + } + } + + private fun Arb.minInvalid( + schema: GraphQLSchema, + iter: Int = iterations + ): Document? { + return withCheck { assertTrue(validate(schema, it).isEmpty()) } + .minViolation(DocumentComparator, randomSource, iter) + ?.value } } -private fun assertValid( +private fun validate( schema: GraphQLSchema, - document: Document + doc: Document +): List = ParseAndValidate.validate(schema, doc) + +// Tests in this suite can be tricky to debug. This method is useful for inspecting the state of a +// tests input in a non-suspending context +@Suppress("UNUSED", "UNUSED_PARAMETER", "UNUSED_VARIABLE") +private fun debug( + schema: GraphQLSchema, + doc: Document, + vararg extras: Any, ) { - withClue(AstPrinter.printAst(document)) { - ParseAndValidate.validate(schema, document).shouldBeEmpty() - } + val rels = SchemaRelationships(schema) + val docString = AstPrinter.printAst(doc) + println(docString) + val operations = doc.getDefinitionsOfType(OperationDefinition::class.java) + val children = doc.children + val allChildren = doc.allChildren + + // set a breakpoint on this assignment: + val x = 1 } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputGenTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputGenTest.kt new file mode 100644 index 000000000..47430596b --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputGenTest.kt @@ -0,0 +1,122 @@ +@file:Suppress("ForbiddenImport") + +package graphql.schema.property + +import graphql.ExecutionInput +import graphql.ParseAndValidate +import io.kotest.property.Arb +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class GraphQLExecutionInputGenTest : KotestPropertyBase() { + private val schema = + """ + directive @dir(arg:Int!) repeatable on QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | VARIABLE_DEFINITION + input Input { a:Int, b:[Int!]!, c:[[[Int]]] } + type Foo implements I1 { x:Int, next:Union, i1(inp:Input!):I1! } + type Bar implements I1 & I2 { x:Int, y:Int, next:Union } + union Union = Foo | Bar + interface I1 { x:Int } + interface I2 { y:Int } + extend type Mutation { x:Int, y:Int } + extend type Subscription { x:Int, y:Int } + extend type Query { x(a:Int!):Int, u:Union, y(inp:Input!): Union } + """.asPermissiveTestSchema + + private fun mkConfig( + anonymousOperationWeight: Double = 0.0, + operationCount: Int = 1, + implicitNullValueWeight: Double = 0.0, + ): Config = + Config.default + + (AnonymousOperationWeight to anonymousOperationWeight) + + (ImplicitNullValueWeight to implicitNullValueWeight) + + (OperationCount to operationCount.asIntRange()) + + @Test + fun AnonymousOperationWeight(): Unit = + runBlocking { + // disabled + mkConfig(anonymousOperationWeight = 0.0, operationCount = 1).let { cfg -> + Arb.graphQLExecutionInput(schema, cfg).forAll { + it.operationName != null + } + } + + // enabled, but operationCount > 1 + mkConfig(anonymousOperationWeight = 1.0, operationCount = 2).let { cfg -> + Arb.graphQLExecutionInput(schema, cfg).forAll { + it.operationName != null + } + } + + // enabled, operationCount = 1 + mkConfig(anonymousOperationWeight = 1.0, operationCount = 1).let { cfg -> + Arb.graphQLExecutionInput(schema, cfg).forAll { + it.operationName == null + } + } + } + + @Test + fun ImplicitNullValueWeight(): Unit = + runBlocking { + // Variables: + // a: nullable without default + // b: nullable with default + // c: non-nullable without default + // d: non-nullable with default + val doc = """ + directive @dir(x:Boolean) on FIELD + query Q(${'$'}a:Boolean, ${'$'}b:Boolean=false, ${'$'}c:Boolean!, ${'$'}d:Boolean!=false) { + x @dir(x:${'$'}a) + x @dir(x:${'$'}b) + x @dir(x:${'$'}c) + x @dir(x:${'$'}d) + } + """.trimIndent().asDocument + + // disabled: no implicit nulls means every variable will have a value + Arb.graphQLExecutionInput(schema, doc, mkConfig(implicitNullValueWeight = 0.0)).forAll { ei -> + ei.variables.keys == setOf("a", "b", "c", "d") + } + + // enabled: only variables that are non-nullable without a default value will have a value + Arb.graphQLExecutionInput(schema, doc, mkConfig(implicitNullValueWeight = 1.0)).forAll { ei -> + ei.variables.keys == setOf("c") + } + } + + @Test + fun `generates valid ExecutionInputs with a default config`(): Unit = + runBlocking { + Arb.graphQLExecutionInput(schema).assertAllValid() + } + + private fun Arb.assertAllValid() { + val violation = withCheck { + assertFalse( + ParseAndValidate.parseAndValidate(schema, it).isFailure + ) + }.minViolation(ExecutionInputComparator, randomSource, iterations) + + assertNull(violation) { + violation!! + val failure = requireNotNull(violation.value) + + val result = ParseAndValidate.parseAndValidate(schema, failure) + buildString { + append("ExecutionInput failed validation:\n") + append("Seed: ${violation.seed}\n") + result.errors.forEach { append("$it\n") } + append("Operation: ${failure.operationName}\n") + append("Variables: ${failure.variables}\n") + append("Document:\n") + append(failure.query) + append("\n") + } + } + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputsTest.kt deleted file mode 100644 index c0c895c6c..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLExecutionInputsTest.kt +++ /dev/null @@ -1,73 +0,0 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) - -package graphql.schema.property - -import graphql.ParseAndValidate -import graphql.parser.Parser -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.shouldBe -import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.constant -import io.kotest.property.checkAll - -class GraphQLExecutionInputsTest : FunSpec({ - val schema = parseTestSchema( - """ - scalar Custom - input Input { a: Int, b: [Int!]!, c: Nested } - input Nested { value: String } - type Query { x(a: Int!): Int, custom(value: Custom!): Int, input(value: Input!): Int } - """.trimIndent() - ) - - test("AnonymousOperationWeight omits only the unambiguous operation name") { - val scalarOverride = ScalarValueOverrides to mapOf("Custom" to Arb.constant("custom-value")) - val oneOperation = Config.default + scalarOverride + - (OperationCount to 1..1) + - (AnonymousOperationWeight to 1.0) - checkAll(PropTestConfig(iterations = 50), Arb.graphQLExecutionInput(schema, oneOperation)) { input -> - input.operationName.shouldBe(null) - } - - val twoOperations = Config.default + scalarOverride + - (OperationCount to 2..2) + - (AnonymousOperationWeight to 1.0) - checkAll(PropTestConfig(iterations = 50), Arb.graphQLExecutionInput(schema, twoOperations)) { input -> - (input.operationName != null).shouldBe(true) - } - } - - test("ImplicitNullValueWeight omits only variables that may be omitted") { - val document = Parser().parseDocument( - "query Q(\$a: Boolean, \$b: Boolean = false, \$c: Boolean!, \$d: Boolean! = false) { x(a: 1) }" - ) - val omitted = Config.default + (ImplicitNullValueWeight to 1.0) - checkAll(PropTestConfig(iterations = 50), Arb.graphQLExecutionInput(schema, document, omitted)) { input -> - input.variables.keys.shouldBe(setOf("c")) - } - - val retained = Config.default + (ImplicitNullValueWeight to 0.0) - checkAll(PropTestConfig(iterations = 50), Arb.graphQLExecutionInput(schema, document, retained)) { input -> - input.variables.keys.shouldBe(setOf("a", "b", "c", "d")) - } - } - - test("custom scalar external values use ScalarValueOverrides") { - val document = Parser().parseDocument("query Q(\$value: Custom!) { custom(value: \$value) }") - val config = Config.default + - (ScalarValueOverrides to mapOf("Custom" to Arb.constant("custom-value"))) - checkAll(PropTestConfig(iterations = 20), Arb.graphQLExecutionInput(schema, document, config)) { input -> - input.variables.shouldBe(mapOf("value" to "custom-value")) - } - } - - test("generated ExecutionInputs parse and validate") { - val config = Config.default + - (ScalarValueOverrides to mapOf("Custom" to Arb.constant("custom-value"))) - checkAll(PropTestConfig(iterations = 200), Arb.graphQLExecutionInput(schema, config)) { input -> - ParseAndValidate.parseAndValidate(schema, input).errors.shouldBeEmpty() - } - } -}) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLGeneratorPropertyTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLGeneratorPropertyTest.kt deleted file mode 100644 index e9fdbb348..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLGeneratorPropertyTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) - -package graphql.schema.property - -import graphql.Scalars -import graphql.schema.GraphQLList -import graphql.schema.GraphQLNonNull -import graphql.schema.GraphQLType -import graphql.schema.GraphQLTypeUtil -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldContainAll -import io.kotest.matchers.collections.shouldNotBeEmpty -import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe -import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.of -import io.kotest.property.checkAll - -class GraphQLGeneratorPropertyTest : FunSpec({ - test("generated names satisfy the GraphQL name grammar") { - val namePattern = Regex("^[_A-Za-z][_0-9A-Za-z]*$") - checkAll(Arb.graphQLName()) { name -> - namePattern.matches(name).shouldBe(true) - name.startsWith("__").shouldBe(false) - name.shouldNotBe("Boolean") - name.shouldNotBe("Float") - name.shouldNotBe("ID") - name.shouldNotBe("Int") - name.shouldNotBe("String") - } - } - - test("generated enum names exclude reserved values") { - checkAll(Arb.graphQLEnumValueName()) { name -> - (name in setOf("true", "false", "null")).shouldBe(false) - } - } - - test("schema names are unique across type categories") { - checkAll(PropTestConfig(iterations = 250), Arb.int(1..100)) { size -> - val names = Arb.graphQLNames(Config.default + (SchemaSize to size)).bind() - val subtotal = names.interfaces.size + - names.objects.size + - names.inputs.size + - names.unions.size + - names.scalars.size + - names.enums.size + - names.directives.size - subtotal.shouldBe(names.allNames.size) - } - } - - test("zero type weights generate no arbitrary names") { - val config = Config.default + - (TypeTypeWeights to TypeTypeWeights.zero) + - (IncludeBuiltinScalars to false) + - (IncludeBuiltinDirectives to false) - checkAll(Arb.graphQLNames(config)) { names -> - names.allNames.shouldBeEmpty() - } - } - - test("type decorators honor deterministic list and non-null weights") { - val names = GraphQLNames(mapOf(TypeType.Scalar to setOf("Int"))) - val listDepth = Arb.int(0..4) - val nonNull = Arb.of(true, false) - checkAll(listDepth, nonNull) { depth, useNonNull -> - val config = Config.default + - (ListTypeWeight to CompoundingWeight(1.0, depth)) + - (NonNullableTypeWeight to if (useNonNull) 1.0 else 0.0) - val decorated = GraphQLTypesGen(config, names, io.kotest.property.RandomSource.default()) - .decorate(Scalars.GraphQLInt) - countLists(decorated).shouldBe(depth) - countNonNulls(decorated).shouldBe(if (useNonNull) depth + 1 else 0) - } - } - - test("generated oneOf inputs are nullable and inhabited") { - val config = Config.default + - (OneOfTypeWeight to 1.0) + - (SchemaSize to 30) + - (TypeTypeWeights to TypeTypeWeights.zero + (TypeType.Input to 1.0)) - checkAll(PropTestConfig(iterations = 100), Arb.graphQLTypes(config)) { types -> - types.inputs.values.forEach { input -> - input.isOneOf.shouldBe(true) - input.fields.forEach { field -> field.type.isNullable().shouldBe(true) } - input.fields.shouldNotBeEmpty() - } - } - } - - test("included types remain available to generated schemas") { - val included = GraphQLTypes.empty.copy( - scalars = mapOf(Scalars.GraphQLInt.name to Scalars.GraphQLInt) - ) - checkAll(Arb.graphQLTypes(Config.default + (IncludeTypes to included))) { types -> - types.scalars.keys.shouldContainAll(included.scalars.keys) - } - } -}) - -private fun GraphQLType.isNullable(): Boolean = this !is GraphQLNonNull - -private fun countLists(type: GraphQLType): Int { - var current = type - var count = 0 - while (GraphQLTypeUtil.isWrapped(current)) { - if (current is GraphQLList) count++ - current = GraphQLTypeUtil.unwrapOne(current) - } - return count -} - -private fun countNonNulls(type: GraphQLType): Int { - var current = type - var count = 0 - while (GraphQLTypeUtil.isWrapped(current)) { - if (current is GraphQLNonNull) count++ - current = GraphQLTypeUtil.unwrapOne(current) - } - return count -} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt index e93df68d8..8f189a267 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLNamesTest.kt @@ -1,76 +1,132 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) +@file:Suppress("ForbiddenImport") package graphql.schema.property -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.shouldBe import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.flatMap import io.kotest.property.arbitrary.int import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.of import io.kotest.property.arbitrary.pair -import io.kotest.property.checkAll - -class GraphQLNamesTest : FunSpec({ - test("allNames is the union of every name bucket") { - checkAll(PropTestConfig(iterations = 100), Arb.int(1..200)) { count -> - val names = Arb.graphQLNames(Config.default + (SchemaSize to count)).bind() - val union = names.interfaces + names.objects + names.inputs + names.unions + - names.scalars + names.enums + names.directives - names.allNames.shouldBe(union) +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GraphQLNamesTest : KotestPropertyBase() { + @Test + fun `Arb_graphQLNames`(): Unit = + runBlocking { + Arb + .int(min = 1, max = 1000) + .forAll { count -> + val cfg = Config.default + (SchemaSize to count) + val names = Arb.graphQLNames(cfg).bind() + + val subtotal = + names.interfaces.size + + names.objects.size + + names.inputs.size + + names.unions.size + + names.scalars.size + + names.enums.size + + names.directives.size + + subtotal == names.allNames.size + } } - } - test("GenCustomScalars controls generated custom scalar names") { - listOf(true, false).forEach { include -> - val config = Config.default + (GenCustomScalars to include) - checkAll(PropTestConfig(iterations = 50), Arb.graphQLNames(config)) { names -> - if (!include) (names.scalars - builtinScalars.keys).shouldBeEmpty() - } + @Test + fun `Arb_graphQLNames -- IncludeCustomScalars`(): Unit = + runBlocking { + Arb + .of(true, false) + .flatMap { incl -> + val cfg = Config.default + (GenCustomScalars to incl) + Arb + .graphQLNames(cfg) + .map { names -> incl to names.scalars - builtinScalars.keys } + }.checkInvariants { (incl, scalars), check -> + if (!incl) { + check.isTrue( + scalars.isEmpty(), + "Found ${scalars.size} scalars when IncludeCustomScalars is $incl" + ) + } + } } - } - - test("zero weights produce no custom names") { - val config = Config.default + - (TypeTypeWeights to TypeTypeWeights.zero) + - (IncludeBuiltinScalars to false) + - (IncludeBuiltinDirectives to false) - checkAll(PropTestConfig(iterations = 20), Arb.graphQLNames(config)) { names -> - names.allNames.shouldBeEmpty() + + @Test + fun `Arb_graphQLNames -- TypeTypeWeights -- zero`(): Unit = + runBlocking { + // all weights are 0 + val cfg = Config.default + + (TypeTypeWeights to TypeTypeWeights.zero) + + (IncludeBuiltinScalars to false) + + (IncludeBuiltinDirectives to false) + + Arb.graphQLNames(cfg).forAll { names -> names.allNames.isEmpty() } } - } - test("skewed weights control bucket populations") { - val weights = TypeTypeWeights.zero + (TypeType.Enum to 10.0) + (TypeType.Object to 1.0) - val configs = Arb.int(10..200).map { size -> - Config.default + (SchemaSize to size) + (TypeTypeWeights to weights) + @Test + fun `Arb_graphQLNames -- TypeTypeWeights -- skewed weights`(): Unit = + runBlocking { + val ttw = TypeTypeWeights.default + (TypeType.Enum to 10.0) + (TypeType.Union to 0.0) + Arb + .int(0 until 1_000) + .flatMap { schemaSize -> + val cfg = Config.default + (TypeTypeWeights to ttw) + (SchemaSize to schemaSize) + Arb.graphQLNames(cfg) + }.forAll { names -> + names.unions.isEmpty() && names.enums.size >= names.objects.size + } } - checkAll(PropTestConfig(iterations = 100), configs) { config -> - val names = Arb.graphQLNames(config).bind() - names.unions.shouldBeEmpty() - (names.enums.size >= names.objects.size).shouldBe(true) + + @Test + fun `Arb_graphqlNames -- BanDirectiveNames`(): Unit = + 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)) + + // sanity check that a "Directive_a" name *can* be generated + assertTrue( + Arb.graphQLNames(cfg) + .asSequence() + .take(100) + .any { names -> dirName in names.directives } + ) + + // assert that the name never gets generated when banned + Arb.graphQLNames(cfg + (BanDirectiveNames to setOf(dirName))) + .forAll { names -> + dirName !in names.directives + } } - } - - test("BanDirectiveNames removes matching generated names") { - val config = Config.default + - (SchemaSize to 20) + - (TypeNameLength to 1..1) + - (TypeTypeWeights to (TypeTypeWeights.zero + (TypeType.Directive to 1.0))) + - (BanDirectiveNames to setOf("Directive_a", "Directive_b")) - checkAll(PropTestConfig(iterations = 100), Arb.graphQLNames(config)) { names -> - names.directives.intersect(config[BanDirectiveNames]).shouldBeEmpty() + + @Test + fun `graphQLNames plus`(): Unit = + runBlocking { + Arb + .pair( + Arb.graphQLNames(), + Arb.graphQLNames() + ).forAll { (a, b) -> + (a.allNames + b.allNames) == (a + b).allNames + } } - } - - test("plus unions buckets and filter retains matching names") { - val pairs = Arb.pair(Arb.graphQLNames(), Arb.graphQLNames()) - checkAll(PropTestConfig(iterations = 100), pairs) { (first, second) -> - (first + second).allNames.shouldBe(first.allNames + second.allNames) - first.filter { true }.shouldBe(first) - first.filter { false }.shouldBe(GraphQLNames.empty) + + @Test + fun `filter`(): Unit = + runBlocking { + Arb.graphQLNames().forAll { names -> + names.filter { false } == GraphQLNames.empty + } + + Arb.graphQLNames().forAll { names -> + names.filter { true } == names + } } - } -}) +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt index fa723f949..df8e9db6a 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLRuntimeWiringsTest.kt @@ -1,103 +1,359 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) +@file:Suppress("ForbiddenImport") package graphql.schema.property import graphql.ExceptionWhileDataFetching +import graphql.GraphQL import graphql.execution.NonNullableFieldWasNullError -import graphql.Scalars -import graphql.schema.GraphQLScalarType +import graphql.language.Field import graphql.schema.idl.RuntimeWiring -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldNotBeEmpty -import io.kotest.matchers.shouldBe +import graphql.schema.idl.SchemaGenerator +import graphql.schema.idl.SchemaParser import io.kotest.property.Arb -import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.constant +import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.flatMap +import io.kotest.property.arbitrary.intRange import io.kotest.property.arbitrary.long -import io.kotest.property.checkAll - -class GraphQLRuntimeWiringsTest : FunSpec({ - test("the same seed and input always return the same result") { - val schema = parseTestSchema( - "interface I { x: Int } type A implements I { x: Int } type Query { x: Int, values: [Int], i: I }" - ) - val input = graphql.ExecutionInput.newExecutionInput() - .query("{ x values i { __typename x } }") - .build() - checkAll(PropTestConfig(iterations = 50), Arb.long()) { seed -> - val graphQL = arbitraryGraphQL(schema, seed) - val results = List(5) { graphQL.execute(input).toSpecification() } - results.distinct().size.shouldBe(1) +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.take +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +class GraphQLRuntimeWiringsTest : KotestPropertyBase() { + @Test + fun `returns the same data for the same query and same seed`(): Unit = + runBlocking { + val sdl = """ + interface I { x: Int } + union Union = Query | Obj + type Obj implements I { x:Int } + type Query implements I { x:Int, u:[Union], i:[I] } + """.trimIndent() + Arb.long().forAll(1_000) { seed -> + val gql = mkGraphQL(sdl, arbRuntimeWiring(sdl, seed)) + val inp = Arb.graphQLExecutionInput(gql.graphQLSchema).bind() + val results = (1..10).map { gql.execute(inp).toSpecification() } + results.distinct().size == 1 + } } - } - test("different response keys are independently salted") { - val schema = parseTestSchema("type Query { x: Int! }") - checkAll(PropTestConfig(iterations = 50), Arb.long()) { seed -> - val result = arbitraryGraphQL(schema, seed).execute("{ x a: x b: x c: x }") - val data = requireNotNull(result.getData>()) - data.size.shouldBe(4) - (data.values.distinct().size > 1).shouldBe(true) + @Test + fun `returns different data for the same query and different seeds`(): Unit = + runBlocking { + val sdl = """ + type Obj { x:Int! } + type Query { obj:Obj! } + """.trimIndent() + val schema = sdl.asSchema + + // 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) + + Arb + .graphQLDocument(schema, cfg) + // require that queries select an interesting field + .filter { it.allChildrenOfType().any { child -> child.name == "x" } } + .flatMap { doc -> + arbitrary { rs -> + Arb + .long() + .take(100, rs) + .toList() + .map { seed -> + val input = Arb.graphQLExecutionInput(schema, doc, cfg).bind() + val gql = mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) + val result = gql.execute(input) + result.toSpecification() + } + } + }.forAll(100) { results -> + results.toSet().size > 1 + } } - } - test("abstract resolvers return concrete schema instances and __typename") { - val schema = parseTestSchema( - "type A { x: Int } type B { x: Int } union U = A | B type Query { u: U }" - ) - val config = Config.default + (ExplicitNullValueWeight to 0.0) - checkAll(PropTestConfig(iterations = 50), Arb.long()) { seed -> - val result = arbitraryGraphQL(schema, seed, config).execute("{ u { __typename } }") - result.errors.shouldBeEmpty() - val data = requireNotNull(result.getData>>()) - (data.getValue("u").getValue("__typename") in setOf("A", "B")).shouldBe(true) + @Test + fun `can resolve __typename`(): Unit = + runBlocking { + val sdl = "type Query {x:Int}" + val doc = "{ __typename }".asDocument + + Arb.long().forAll(100) { seed -> + val gql = mkGraphQL(sdl, arbRuntimeWiring(sdl, seed)) + val inp = Arb.graphQLExecutionInput(gql.graphQLSchema, doc).bind() + val results = Arb + .constant(inp) + .map(gql::execute) + .take(10, randomSource) + + results.all { + it.toSpecification()["data"] == mapOf("__typename" to "Query") + } + } } - } - test("NullNonNullableWeight injects non-null failures") { - val schema = parseTestSchema("type Query { x: Int! }") - val disabled = Config.default + (NullNonNullableWeight to 0.0) - arbitraryGraphQL(schema, 1, disabled).execute("{ x }").errors.shouldBeEmpty() + @Test + fun `returns the same value for fields in the same merge group`(): Unit = + runBlocking { + val sdl = "type Query {x: Int}" + val doc = """ + { + x + x + x @skip(if:false) + x @include(if:true) + ... on Query { x } + ... { x } + ... F + } + fragment F on Query { x } + """.trimIndent().asDocument - val enabled = Config.default + (NullNonNullableWeight to 1.0) - val errors = arbitraryGraphQL(schema, 1, enabled).execute("{ x }").errors - errors.any { it is NonNullableFieldWasNullError }.shouldBe(true) - } + Arb.long().forAll(100) { seed -> + val gql = mkGraphQL(sdl, arbRuntimeWiring(sdl, seed)) + val inp = Arb.graphQLExecutionInput(gql.graphQLSchema, doc).bind() + val results = Arb + .constant(inp) + .map(gql::execute) + .take(10, randomSource) - test("ExplicitNullValueWeight and ListValueSize shape fetched values") { - val schema = parseTestSchema("type Query { nullable: Int, values: [Int!]! }") - val config = Config.default + - (ExplicitNullValueWeight to 1.0) + - (ListValueSize to 3..3) - val result = arbitraryGraphQL(schema, 2, config).execute("{ nullable values }") - val data = requireNotNull(result.getData>()) - data["nullable"].shouldBe(null) - (data["values"] as List<*>).size.shouldBe(3) - } + results.all { + val data = requireNotNull(it.getData>()) + // all keys should be merged and have the same value + data.size == 1 && data.values.distinct().size == 1 + } + } + } - test("ResolverExceptionWeight injects observable resolver failures") { - val schema = parseTestSchema("type Query { x: Int }") - val disabled = Config.default + (ResolverExceptionWeight to 0.0) - arbitraryGraphQL(schema, 3, disabled).execute("{ x }").errors.shouldBeEmpty() + @Test + fun `returns different values for fields in different merge groups`(): Unit = + runBlocking { + val sdl = "type Query {x: Int}" + val doc = """ + { + x + a:x + b:x @skip(if:false) + c:x @include(if:true) + ... on Query { d:x } + ... { e:x } + ... F + } + fragment F on Query { f:x } + """.trimIndent().asDocument - val enabled = Config.default + (ResolverExceptionWeight to 1.0) - val errors = arbitraryGraphQL(schema, 3, enabled).execute("{ x }").errors - errors.shouldNotBeEmpty() - val exception = (errors.first() as ExceptionWhileDataFetching).exception - (exception is ResolverInjectedException).shouldBe(true) - } + Arb.long().forAll(100) { seed -> + val gql = mkGraphQL(sdl, arbRuntimeWiring(sdl, seed)) + val inp = Arb.graphQLExecutionInput(gql.graphQLSchema, doc).bind() + val results = Arb + .constant(inp) + .map(gql::execute) + .take(10, randomSource) + + results.all { + val data = requireNotNull(it.getData>()) + // keys should not be in the same CollectFields group and should have distinct values + data.size == 7 && data.values.distinct().size > 1 + } + } + } + + @Test + fun `type resolvers return referentially valid types`(): Unit = + runBlocking { + val sdl = """ + type Foo { x:Int } + type Bar { x:Int } + union Union = Foo | Bar + type Query {u:Union} + """.trimIndent() + val doc = "{ u { __typename } }".asDocument + + Arb.long().forAll(100) { seed -> + val wiring = arbRuntimeWiring(sdl, seed) + .let { w -> + val baseTypeResolver = w.typeResolvers["Union"]!! + w.transform { + // the setup for this test replaces a type resolver, which requires disabling strictMode + it.strictMode(false) + + it.type("Union") { + it.typeResolver { env -> + baseTypeResolver.getType(env).also { + assertSame(env.schema.getObjectType(it.name), it) + } + } + } + } + } + + val gql = mkGraphQL(sdl, wiring) + val inp = Arb.graphQLExecutionInput(gql.graphQLSchema, doc).bind() + val results = Arb + .constant(inp) + .map(gql::execute) + .take(10, randomSource) + + results.all { it.errors.isEmpty() } + } + } + + @Test + fun `generates valid list values`(): Unit = + runBlocking { + val sdl = "type Query { x:[Int!]! }" + val doc = "{x}".asDocument + + val cfg = Config.default + Arb + .long() + .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } + .flatMap { gql -> + val input = Arb.graphQLExecutionInput(gql.graphQLSchema, doc, cfg) + input.map(gql::execute) + }.forAll { + val data = requireNotNull(it.getData>>()) + data["x"]!!.all { v -> v is Int } + } + } + + @Test + fun NullNonNullableWeight(): Unit = + runBlocking { + val sdl = "type Query { x:Int! }" + val doc = "{x}".asDocument + + // disabled + (Config.default + (NullNonNullableWeight to 0.0)).let { cfg -> + Arb + .long() + .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } + .flatMap { gql -> + val input = Arb.graphQLExecutionInput(gql.graphQLSchema, doc, cfg) + input.map(gql::execute) + }.forAll { + it.errors.isEmpty() + } + } + + // enabled + (Config.default + (NullNonNullableWeight to 1.0)).let { cfg -> + Arb + .long() + .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } + .flatMap { gql -> + val input = Arb.graphQLExecutionInput(gql.graphQLSchema, doc, cfg) + input.map(gql::execute) + }.forAll { + it.errors.any { it is NonNullableFieldWasNullError } + } + } + } + + @Test + @Suppress("UNCHECKED_CAST") + fun ExplicitNullValueWeight(): Unit = + runBlocking { + val sdl = "type Query { x:Int }" + val doc = "{x}".asDocument + + // disabled + (Config.default + (ExplicitNullValueWeight to 0.0)).let { cfg -> + Arb + .long() + .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } + .flatMap { gql -> + val input = Arb.graphQLExecutionInput(gql.graphQLSchema, doc, cfg) + input.map(gql::execute) + }.forAll { + val data = it.toSpecification()["data"] as Map + data["x"] != null + } + } + + // enabled + (Config.default + (ExplicitNullValueWeight to 1.0)).let { cfg -> + Arb + .long() + .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } + .flatMap { gql -> + val input = Arb.graphQLExecutionInput(gql.graphQLSchema, doc, cfg) + input.map(gql::execute) + }.forAll { + val data = it.toSpecification()["data"] as Map + data["x"] == null + } + } + } + + @Test + @Suppress("UNCHECKED_CAST") + fun ListValueSize(): Unit = + runBlocking { + val sdl = "type Query { x:[Int] }" + val doc = "{x}".asDocument + + arbitrary { _ -> + val seed = Arb.long().bind() + val listSize = Arb + .intRange(0..100) + .filter { !it.isEmpty() } + .bind() + val cfg = Config.default + (ListValueSize to listSize) + (ExplicitNullValueWeight to 0.0) + val gql = mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) + val input = Arb.graphQLExecutionInput(gql.graphQLSchema, doc, cfg).bind() + listSize to gql.execute(input) + }.forAll { (listSize, result) -> + val data = result.toSpecification()["data"] as Map + val x = data["x"] as List + x.size in listSize + } + } + + @Test + fun ResolverExceptionWeight(): Unit = + runBlocking { + val sdl = "type Query { x:[Int] }" + val doc = "{x}".asDocument + + // disabled + (Config.default + (ResolverExceptionWeight to 0.0)).let { cfg -> + Arb + .long() + .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } + .flatMap { gql -> + val input = Arb.graphQLExecutionInput(gql.graphQLSchema, doc, cfg) + input.map(gql::execute) + }.forAll { + it.errors.isEmpty() + } + } + + // enabled + (Config.default + (ResolverExceptionWeight to 1.0)).let { cfg -> + Arb + .long() + .map { seed -> mkGraphQL(sdl, arbRuntimeWiring(sdl, seed, cfg)) } + .flatMap { gql -> + val input = Arb.graphQLExecutionInput(gql.graphQLSchema, doc, cfg) + input.map(gql::execute) + }.forAll { + val err = (it.errors.firstOrNull() as? ExceptionWhileDataFetching)?.exception + err is ResolverInjectedException + } + } + } - test("custom scalar outputs use ScalarValueOverrides") { - val customScalar = GraphQLScalarType.newScalar(Scalars.GraphQLString) - .name("Custom") + private fun mkGraphQL( + sdl: String, + wiring: RuntimeWiring + ): GraphQL { + val schema = SchemaGenerator().makeExecutableSchema(SchemaParser().parse(sdl), wiring) + return GraphQL + .newGraphQL(schema) .build() - val wiring = RuntimeWiring.newRuntimeWiring().scalar(customScalar).build() - val schema = parseTestSchema("scalar Custom type Query { x: Custom! }", wiring) - val config = Config.default + - (ScalarValueOverrides to mapOf("Custom" to Arb.constant("custom-value"))) - val result = arbitraryGraphQL(schema, 4, config).execute("{ x }") - result.errors.shouldBeEmpty() - result.getData>().shouldBe(mapOf("x" to "custom-value")) } -}) +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaDirectiveCycleTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaDirectiveCycleTest.kt deleted file mode 100644 index 81d0c90b1..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaDirectiveCycleTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -package graphql.schema.property - -import graphql.Scalars -import graphql.introspection.Introspection.DirectiveLocation -import graphql.schema.GraphQLArgument -import graphql.schema.GraphQLDirective -import graphql.schema.GraphQLEnumType -import graphql.schema.GraphQLFieldDefinition -import graphql.schema.GraphQLInputObjectField -import graphql.schema.GraphQLInputObjectType -import graphql.schema.GraphQLInputType -import graphql.schema.GraphQLNonNull -import graphql.schema.GraphQLObjectType -import graphql.schema.GraphQLSchema -import graphql.schema.GraphQLTypeReference -import graphql.schema.SchemaTransformer -import graphql.schema.idl.SchemaPrinter -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.property.RandomSource - -class GraphQLSchemaDirectiveCycleTest : FunSpec({ - test("direct dependencies between directive arguments are one-way") { - val schema = directiveControlSchema( - directives = listOf( - controlDirective("a", Scalars.GraphQLInt, DirectiveLocation.ARGUMENT_DEFINITION), - controlDirective("b", Scalars.GraphQLInt, DirectiveLocation.ARGUMENT_DEFINITION) - ) - ).withArbitraryAppliedDirectives() - - val aHasB = schema.directiveArgumentDirectives("a").contains("b") - val bHasA = schema.directiveArgumentDirectives("b").contains("a") - (aHasB xor bHasA).shouldBe(true) - SchemaPrinter().print(schema) - } - - test("dependencies through directive argument input fields are one-way") { - val input = GraphQLInputObjectType.newInputObject() - .name("InputA") - .field( - GraphQLInputObjectField.newInputObjectField() - .name("field") - .type(GraphQLNonNull.nonNull(Scalars.GraphQLInt)) - ) - .build() - val schema = directiveControlSchema( - directives = listOf( - controlDirective("a", GraphQLTypeReference.typeRef("InputA"), DirectiveLocation.ARGUMENT_DEFINITION), - controlDirective("b", Scalars.GraphQLInt, DirectiveLocation.INPUT_FIELD_DEFINITION) - ), - inputs = listOf(input) - ).withArbitraryAppliedDirectives() - - val fieldHasB = (schema.getType("InputA") as GraphQLInputObjectType) - .getFieldDefinition("field") - .appliedDirectives - .any { it.name == "b" } - val bHasA = schema.directiveArgumentDirectives("b").contains("a") - (fieldHasB xor bHasA).shouldBe(true) - SchemaPrinter().print(schema) - } - - test("dependencies through directive argument enum values are one-way") { - val enum = GraphQLEnumType.newEnum().name("E").value("X").build() - val schema = directiveControlSchema( - directives = listOf( - controlDirective("a", GraphQLTypeReference.typeRef("E"), DirectiveLocation.ARGUMENT_DEFINITION), - controlDirective("b", Scalars.GraphQLInt, DirectiveLocation.ENUM_VALUE) - ), - enums = listOf(enum) - ).withArbitraryAppliedDirectives() - - val valueHasB = requireNotNull((schema.getType("E") as GraphQLEnumType).getValue("X")) - .appliedDirectives - .any { it.name == "b" } - val bHasA = schema.directiveArgumentDirectives("b").contains("a") - (valueHasB xor bHasA).shouldBe(true) - SchemaPrinter().print(schema) - } -}) - -private fun GraphQLSchema.withArbitraryAppliedDirectives(): GraphQLSchema { - val config = Config.default + - (AppliedDirectiveWeight to CompoundingWeight.Always) + - (DirectiveIsRepeatable to 0.0) + - (DefaultValueWeight to 0.0) - val randomSource = RandomSource.seeded(0) - val generator = GraphQLInputValueGenerator( - schema = this, - config = config, - randomSource = randomSource, - uncoercedValueWeight = 0.0, - allEdgesGraph = CycleGroups.allInputCycles(this), - mandatoryEdgesGraph = CycleGroups.mandatoryInputCycles(this) - ) - return SchemaTransformer.transformSchema( - this, - AddAppliedDirectives(this, generator, config, randomSource) - ) -} - -private fun GraphQLSchema.directiveArgumentDirectives(directiveName: String): Set { - val directive = requireNotNull(getDirective(directiveName)) - val argument = requireNotNull(directive.getArgument("arg")) - return argument.appliedDirectives.mapTo(linkedSetOf()) { it.name } -} - -private fun directiveControlSchema( - directives: List, - inputs: List = emptyList(), - enums: List = emptyList() -): GraphQLSchema { - val query = GraphQLObjectType.newObject() - .name("Query") - .field( - GraphQLFieldDefinition.newFieldDefinition() - .name("placeholder") - .type(Scalars.GraphQLInt) - .argument(GraphQLArgument.newArgument().name("arg").type(Scalars.GraphQLInt)) - ) - .build() - val types = GraphQLTypes.empty.copy( - directives = directives.associateBy { it.name }, - objects = mapOf(query.name to query), - inputs = inputs.associateBy { it.name }, - enums = enums.associateBy { it.name } - ) - val config = Config.default + - (AppliedDirectiveWeight to CompoundingWeight.Never) + - (DefaultValueWeight to 0.0) - return SchemaGenerator(config, RandomSource.seeded(0)).createSchema(types) -} - -private fun controlDirective( - name: String, - argumentType: GraphQLInputType, - vararg locations: DirectiveLocation -): GraphQLDirective = - GraphQLDirective.newDirective() - .name(name) - .argument(GraphQLArgument.newArgument().name("arg").type(argumentType)) - .validLocations(*locations) - .build() diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaPropertyTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaPropertyTest.kt deleted file mode 100644 index 81f3e32a3..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemaPropertyTest.kt +++ /dev/null @@ -1,289 +0,0 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) - -package graphql.schema.property - -import graphql.Directives -import graphql.Scalars -import graphql.introspection.Introspection -import graphql.language.StringValue -import graphql.language.NullValue -import graphql.language.ObjectValue -import graphql.language.Value -import graphql.schema.GraphQLAppliedDirective -import graphql.schema.GraphQLArgument -import graphql.schema.GraphQLDirective -import graphql.schema.GraphQLFieldDefinition -import graphql.schema.GraphQLInputObjectType -import graphql.schema.GraphQLInputType -import graphql.schema.GraphQLList -import graphql.schema.GraphQLNonNull -import graphql.schema.GraphQLObjectType -import graphql.schema.GraphQLSchema -import graphql.schema.GraphQLSchemaElement -import graphql.schema.GraphQLTypeVisitorStub -import graphql.schema.SchemaTraverser -import graphql.schema.idl.FastSchemaGenerator -import graphql.schema.idl.RuntimeWiring -import graphql.schema.idl.SchemaParser -import graphql.schema.idl.SchemaPrinter -import graphql.schema.validation.SchemaValidator -import graphql.util.TraversalControl -import graphql.util.TraverserContext -import io.kotest.assertions.withClue -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldNotBeEmpty -import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeInstanceOf -import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.RandomSource -import io.kotest.property.checkAll -import io.kotest.property.arbitrary.long - -class GraphQLSchemaPropertyTest : FunSpec({ - test("generated schemas are valid") { - checkAll(PropTestConfig(iterations = 100), Arb.generatedGraphQLSchema()) { generated -> - withClue(generated.sdl) { - SchemaValidator().validateSchema(generated.schema).shouldBeEmpty() - } - } - } - - test("generated schemas round trip through SDL") { - checkAll(PropTestConfig(iterations = 100), Arb.generatedGraphQLSchema()) { generated -> - withClue(generated.sdl) { - val registry = SchemaParser().parse(generated.sdl) - val rebuilt = FastSchemaGenerator() - .makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING) - SchemaValidator().validateSchema(rebuilt).shouldBeEmpty() - SchemaPrinter().print(rebuilt).shouldBe(generated.sdl) - } - } - } - - test("default values are generated and remain valid") { - val config = Config.default + (DefaultValueWeight to 1.0) - checkAll(PropTestConfig(iterations = 50), Arb.generatedGraphQLSchema(config)) { generated -> - withClue(generated.sdl) { - val collector = DefaultValueCollector() - SchemaTraverser().depthFirstFullSchema(collector, generated.schema) - collector.values.shouldNotBeEmpty() - collector.values.all { it }.shouldBe(true) - SchemaValidator().validateSchema(generated.schema).shouldBeEmpty() - } - } - } - - test("oneOf input fields never receive defaults") { - val config = Config.default + - (OneOfTypeWeight to 1.0) + - (DefaultValueWeight to 1.0) + - (TypeTypeWeights to TypeTypeWeights.zero + (TypeType.Input to 1.0)) - - checkAll(PropTestConfig(iterations = 50), Arb.generatedGraphQLSchema(config)) { generated -> - withClue(generated.sdl) { - generated.schema.allTypesAsList - .filterIsInstance() - .filter { it.isOneOf } - .flatMap { it.fields } - .all { !it.hasSetDefaultValue() } - .shouldBe(true) - } - } - } - - test("cyclic nullable inputs receive finite non-trivial defaults") { - val seedSchema = parseTestSchema( - "input Recursive { next: Recursive } type Query { x(value: Recursive): Int }" - ) - val recursive = seedSchema.getType("Recursive") as GraphQLInputObjectType - val types = GraphQLTypes.empty.copy(inputs = mapOf(recursive.name to recursive)) - val config = Config.default + - (DefaultValueWeight to 1.0) + - (ExplicitNullValueWeight to 0.0) + - (MaxValueDepth to 3) - checkAll(PropTestConfig(iterations = 50), Arb.graphQLSchema(types, config)) { schema -> - val input = schema.getType("Recursive") as GraphQLInputObjectType - val defaultValue = input.getFieldDefinition("next").inputFieldDefaultValue.value as ObjectValue - defaultValue.objectFields.shouldNotBeEmpty() - defaultValue.objectFields.none { it.value is NullValue }.shouldBe(true) - } - } - - test("applied directives are generated without invalid dependency cycles") { - val testDirective = GraphQLDirective.newDirective() - .name("testDirective") - .argument( - GraphQLArgument.newArgument() - .name("arg") - .type(Scalars.GraphQLInt) - ) - .validLocations(*Introspection.DirectiveLocation.entries.toTypedArray()) - .build() - val config = Config.default + - (IncludeTypes to (GraphQLTypes.empty + testDirective)) + - (AppliedDirectiveWeight to CompoundingWeight.Once) + - (BanDirectiveNames to builtinDirectives.keys) + - (DefaultValueWeight to 0.0) - - val disabled = config + (AppliedDirectiveWeight to CompoundingWeight.Never) - checkAll(PropTestConfig(iterations = 20), Arb.graphQLSchema(disabled)) { schema -> - val collector = AppliedDirectiveCollector() - SchemaTraverser().depthFirstFullSchema(collector, schema) - collector.directives.none { it.name == testDirective.name }.shouldBe(true) - } - - checkAll(PropTestConfig(iterations = 50), Arb.generatedGraphQLSchema(config)) { generated -> - withClue(generated.sdl) { - val collector = AppliedDirectiveCollector() - SchemaTraverser().depthFirstFullSchema(collector, generated.schema) - collector.directives.filter { it.name == testDirective.name }.shouldNotBeEmpty() - SchemaValidator().validateSchema(generated.schema).shouldBeEmpty() - } - } - } - - test("applied directives are not added to introspection elements") { - val config = Config.default + - (AppliedDirectiveWeight to CompoundingWeight.Once) + - (DefaultValueWeight to 0.0) - - checkAll(PropTestConfig(iterations = 20), Arb.graphQLSchema(config)) { schema -> - val collector = AppliedDirectiveCollector() - listOf( - schema.introspectionSchemaType, - schema.introspectionSchemaFieldDefinition, - schema.introspectionTypenameFieldDefinition, - schema.introspectionTypeFieldDefinition - ).forEach { SchemaTraverser().depthFirst(collector, it) } - collector.directives.shouldBeEmpty() - } - } - - test("generated input types contain no mandatory cycles") { - val config = Config.default + - (NonNullableTypeWeight to 1.0) + - (ListTypeWeight to CompoundingWeight.Never) + - (OneOfTypeWeight to 0.0) + - (DefaultValueWeight to 0.0) - - checkAll(PropTestConfig(iterations = 100), Arb.graphQLSchema(config)) { schema -> - CycleGroups.mandatoryInputCycles(schema).isEmpty().shouldBe(true) - } - } - - test("uncoerced defaults exercise GraphQL input coercion") { - val config = Config.default + - (DefaultValueWeight to 1.0) + - (SchemaUncoercedValueWeight to 1.0) - checkAll(PropTestConfig(iterations = 25), Arb.generatedGraphQLSchema(config)) { generated -> - withClue(generated.sdl) { - SchemaValidator().validateSchema(generated.schema).shouldBeEmpty() - } - } - } - - test("single-value coercion cannot null a non-null list") { - val listType = GraphQLNonNull.nonNull(GraphQLList.list(Scalars.GraphQLString)) as GraphQLInputType - val config = Config.default + (ExplicitNullValueWeight to 1.0) - checkAll(Arb.long()) { seed -> - val generator = GraphQLInputValueGenerator( - schema = minimalSchema(), - config = config, - randomSource = RandomSource.seeded(seed), - uncoercedValueWeight = 1.0 - ) - generator.generate(listType).shouldBeInstanceOf() - } - } - - test("single-value coercion preserves nested list boundaries") { - val innerList = GraphQLNonNull.nonNull(GraphQLList.list(Scalars.GraphQLString)) - val listType = GraphQLNonNull.nonNull(GraphQLList.list(innerList)) as GraphQLInputType - val config = Config.default + - (ExplicitNullValueWeight to 1.0) + - (ListValueSize to 3..3) - checkAll(Arb.long()) { seed -> - val generator = GraphQLInputValueGenerator( - schema = minimalSchema(), - config = config, - randomSource = RandomSource.seeded(seed), - uncoercedValueWeight = 1.0 - ) - schemaWithDefault(listType, generator.generate(listType)) - } - } -}) - -private fun minimalSchema(): GraphQLSchema = - GraphQLSchema.newSchema() - .query( - GraphQLObjectType.newObject() - .name("Query") - .field( - GraphQLFieldDefinition.newFieldDefinition() - .name("field") - .type(Scalars.GraphQLString) - ) - ) - .build() - -private fun schemaWithDefault( - type: GraphQLInputType, - value: Value<*> -): GraphQLSchema = - GraphQLSchema.newSchema() - .query( - GraphQLObjectType.newObject() - .name("Query") - .field( - GraphQLFieldDefinition.newFieldDefinition() - .name("field") - .type(Scalars.GraphQLString) - .argument( - GraphQLArgument.newArgument() - .name("argument") - .type(type) - .defaultValueLiteral(value) - ) - ) - ) - .build() - -private class AppliedDirectiveCollector : GraphQLTypeVisitorStub() { - val directives = mutableListOf() - - override fun visitGraphQLAppliedDirective( - node: GraphQLAppliedDirective, - context: TraverserContext - ): TraversalControl { - directives += node - return TraversalControl.CONTINUE - } -} - -private class DefaultValueCollector : GraphQLTypeVisitorStub() { - val values = mutableListOf() - - override fun visitGraphQLArgument( - node: GraphQLArgument, - context: TraverserContext - ): TraversalControl { - val parent = context.parentNode - if (parent is GraphQLDirective && parent.name in builtinDirectives) return TraversalControl.CONTINUE - values += node.hasSetDefaultValue() - return TraversalControl.CONTINUE - } - - override fun visitGraphQLInputObjectField( - node: graphql.schema.GraphQLInputObjectField, - context: TraverserContext - ): TraversalControl { - val parent = context.parentNode - if (parent !is GraphQLInputObjectType || !parent.isOneOf) { - values += node.hasSetDefaultValue() - } - return TraversalControl.CONTINUE - } -} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemasTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemasTest.kt new file mode 100644 index 000000000..7a8e8042e --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLSchemasTest.kt @@ -0,0 +1,677 @@ +@file:Suppress("ForbiddenImport") + +package graphql.schema.property + +import graphql.Scalars +import graphql.Scalars.GraphQLFloat +import graphql.Scalars.GraphQLInt +import graphql.introspection.Introspection +import graphql.language.ArrayValue +import graphql.language.IntValue +import graphql.language.NullValue +import graphql.language.ObjectValue +import graphql.language.Value +import graphql.schema.GraphQLAppliedDirective +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLDirective +import graphql.schema.FieldCoordinates +import graphql.schema.GraphQLEnumType +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLInputObjectField +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLInputType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNamedType +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLSchema +import graphql.schema.GraphQLSchemaElement +import graphql.schema.GraphQLTypeReference +import graphql.schema.GraphQLTypeUtil +import graphql.schema.GraphQLTypeVisitorStub +import graphql.schema.InputValueWithState +import graphql.schema.SchemaTransformer +import graphql.schema.SchemaTraverser +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.Test + +class GraphQLSchemasTest : KotestPropertyBase() { + // It's useful to have a directive that can be applied to any element + private val testDirective = GraphQLDirective + .newDirective() + .name("testDirective") + .repeatable(false) + .validLocations(*Introspection.DirectiveLocation.values()) + .build() + + @Test + fun `Arb-graphQLSchema can generate an empty-ish schema`(): Unit = + runBlocking { + val cfg = Config.default + (SchemaSize to 0) + Arb.graphQLSchema(cfg).checkAll { + markSuccess() + } + } + + @Test + fun `Arb-graphQLSchema can generate a schema from an empty GraphQLTypes`(): Unit = + runBlocking { + Arb.graphQLSchema(GraphQLTypes.empty).forAll { + true + } + } + + @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 { + Arb.graphQLSchema().forAll(100) { schema -> + val sdl = SchemaPrinter().print(schema) + SchemaParser().parse(sdl) + true + } + } + + @Test + fun `adds default values`(): Unit = + runBlocking { + // disabled + Arb.graphQLSchema(Config.default + (DefaultValueWeight to 0.0)) + .forAll(100) { schema -> + val defaultables = CollectDefaultables(includeOneOfFields = true) + .also { SchemaTraverser().depthFirstFullSchema(it, schema) } + .defaultables + + defaultables.none { it.isSet } + } + + // enabled + Arb.graphQLSchema(Config.default + (DefaultValueWeight to 1.0)) + .forAll(100) { schema -> + val defaultables = CollectDefaultables(includeOneOfFields = false) + .also { SchemaTraverser().depthFirstFullSchema(it, schema) } + .defaultables + defaultables.all { it.isSet } + } + } + + @Test + fun `adds default values -- SchemaUncoercedValueWeight`(): Unit = + runBlocking { + val cfg = Config.default + + (DefaultValueWeight to 1.0) + + (SchemaUncoercedValueWeight to 1.0) + + // int for float + run { + val query = GraphQLObjectType.newObject() + .name("Query") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("field") + .argument( + GraphQLArgument.newArgument().name("arg") + .type(GraphQLNonNull.nonNull(GraphQLFloat)) + ) + .type(GraphQLInt) + ) + .build() + + Arb.graphQLSchema(GraphQLTypes.empty.copy(objects = mapOf("Query" to query)), cfg) + .forAll { schema -> + val field = requireNotNull(schema.getFieldDefinition(FieldCoordinates.coordinates("Query", "field"))) + val arg = field.getArgument("arg") + val default = arg.argumentDefaultValue + default.isSet && default.value is IntValue + } + } + + // singleton for list + run { + val query = GraphQLObjectType.newObject() + .name("Query") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("field") + .argument( + GraphQLArgument.newArgument().name("arg") + .type( + GraphQLNonNull.nonNull( + GraphQLList( + GraphQLNonNull.nonNull(GraphQLInt) + ) + ) + ) + ) + .type(GraphQLInt) + ) + .build() + + Arb.graphQLSchema(GraphQLTypes.empty.copy(objects = mapOf("Query" to query)), cfg) + .forAll { schema -> + val field = requireNotNull(schema.getFieldDefinition(FieldCoordinates.coordinates("Query", "field"))) + val arg = field.getArgument("arg") + val default = arg.argumentDefaultValue + default.isSet && default.value is IntValue + } + } + } + + @Test + fun `adds applied directives`(): Unit = + runBlocking { + val cfg = Config.default + (IncludeTypes to (GraphQLTypes.empty + testDirective)) + + // disabled + Arb.graphQLSchema(cfg + (AppliedDirectiveWeight to CompoundingWeight.Never)) + .forAll(100) { schema -> + val dirs = CollectAppliedDirectives() + .also { SchemaTraverser().depthFirstFullSchema(it, schema) } + .directives + dirs.isEmpty() + } + + // enabled + Arb.graphQLSchema(cfg + (AppliedDirectiveWeight to CompoundingWeight.Once)) + .forAll(100) { schema -> + val dirs = CollectAppliedDirectives() + .also { SchemaTraverser().depthFirstFullSchema(it, schema) } + .directives + dirs.isNotEmpty() + } + + // BanDirectiveNames + Arb.graphQLSchema( + cfg + + (AppliedDirectiveWeight to CompoundingWeight.Once) + + (BanDirectiveNames to setOf(testDirective.name)) + ).forAll(100) { schema -> + val dirs = CollectAppliedDirectives() + .also { SchemaTraverser().depthFirstFullSchema(it, schema) } + .directives + dirs.none { it.name == testDirective.name } + } + } + + @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)) + + Arb.graphQLSchema(cfg) + .forAll(100) { schema -> + val dirs = CollectAppliedDirectives() + .also { visitor -> + val introspectionRoots = listOf( + schema.introspectionSchemaType, + schema.introspectionSchemaFieldDefinition, + schema.introspectionTypenameFieldDefinition, + schema.introspectionTypeFieldDefinition, + ) + introspectionRoots.forEach { elt -> + SchemaTraverser().depthFirst(visitor, elt) + } + } + .directives + dirs.isEmpty() + } + } + + @Test + fun `AddAppliedDirectives prevents direct cycles between directive arguments`() { + val transformed = controlSchema( + directives = listOf( + controlDirective("a", GraphQLNonNull.nonNull(Scalars.GraphQLInt), Introspection.DirectiveLocation.ARGUMENT_DEFINITION), + controlDirective("b", GraphQLNonNull.nonNull(Scalars.GraphQLInt), Introspection.DirectiveLocation.ARGUMENT_DEFINITION), + ) + ).transformAppliedDirectives() + + val aArgDirectives = transformed.directiveArgumentAppliedDirectiveNames("a", "arg") + val bArgDirectives = transformed.directiveArgumentAppliedDirectiveNames("b", "arg") + + assertEquals(true, aArgDirectives.contains("b") xor bArgDirectives.contains("a")) + } + + @Test + fun `AddAppliedDirectives prevents cycles through directive argument input fields`() { + val transformed = controlSchema( + directives = listOf( + controlDirective("a", GraphQLTypeReference.typeRef("InputA"), Introspection.DirectiveLocation.ARGUMENT_DEFINITION), + controlDirective("b", GraphQLNonNull.nonNull(Scalars.GraphQLInt), Introspection.DirectiveLocation.INPUT_FIELD_DEFINITION), + ), + inputs = listOf( + GraphQLInputObjectType + .newInputObject() + .name("InputA") + .field( + GraphQLInputObjectField + .newInputObjectField() + .name("field") + .type(GraphQLNonNull.nonNull(Scalars.GraphQLInt)) + ).build() + ) + ).transformAppliedDirectives() + + val fieldHasB = transformed.inputFieldAppliedDirectiveNames("InputA", "field").contains("b") + val bArgHasA = transformed.directiveArgumentAppliedDirectiveNames("b", "arg").contains("a") + + assertEquals(true, fieldHasB xor bArgHasA) + } + + @Test + fun `AddAppliedDirectives prevents cycles through directive argument enum values`() { + val transformed = controlSchema( + directives = listOf( + controlDirective("a", GraphQLTypeReference.typeRef("E"), Introspection.DirectiveLocation.ARGUMENT_DEFINITION), + controlDirective("b", GraphQLNonNull.nonNull(Scalars.GraphQLInt), Introspection.DirectiveLocation.ENUM_VALUE), + ), + enums = listOf( + GraphQLEnumType + .newEnum() + .name("E") + .value("X") + .build() + ) + ).transformAppliedDirectives() + + val valueHasB = transformed.enumValueAppliedDirectiveNames("E", "X").contains("b") + val bArgHasA = transformed.directiveArgumentAppliedDirectiveNames("b", "arg").contains("a") + + assertEquals(true, valueHasB xor bArgHasA) + } + + @Test + fun `generates inhabited OneOf types`(): Unit = + runBlocking { + // an inhabited type is a type for which it is possible to create a finite value + // https://en.wikipedia.org/wiki/Type_inhabitation + fun isInhabited(type: GraphQLInputObjectType): Boolean { + fun loop( + seenOneOfs: Set, + type: GraphQLInputType + ): Boolean { + assert(type !is GraphQLNonNull) + return if (type is GraphQLInputObjectType && type.isOneOf) { + val checkableFields = type.fields.filter { f -> + if (f.type is GraphQLList) return@filter true + val unwrappedFieldType = GraphQLTypeUtil.unwrapAll(f.type) + unwrappedFieldType !is GraphQLInputObjectType || unwrappedFieldType.name !in seenOneOfs + } + checkableFields.any { f -> + loop(seenOneOfs + type.name, f.type) + } + } else { + true + } + } + + return loop(emptySet(), type) + } + + // 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) + + // without default values + Arb.graphQLSchema(cfg + (DefaultValueWeight to 0.0)) + .forAll { schema -> + schema.allTypesAsList + .mapNotNull { it as? GraphQLInputObjectType } + .all(::isInhabited) + } + + // with default values + Arb.graphQLSchema(cfg + (DefaultValueWeight to 1.0)) + .forAll { schema -> + schema.allTypesAsList + .mapNotNull { it as? GraphQLInputObjectType } + .all(::isInhabited) + } + } + + @Test + fun `generated schemas do not have cyclic default values`(): Unit = + runBlocking { + // see https://spec.graphql.org/draft/#sec-Input-Object-Default-Value-Has-Cycle + // returns true if the schema contains a default value that forms a cycle + fun containsInvalidDefaultValues(schema: GraphQLSchema): Boolean { + val inputTypes = schema.allTypesAsList.filterIsInstance() + + // InputObjectDefaultValueHasCycle(inputObject, defaultValue, visitedFields) + // returns true if this object forms a cycle + fun objectContainsInvalidDefaultValues( + inputObject: GraphQLInputObjectType, + defaultValue: Value<*>?, + visitedFields: Set + ): Boolean { + if (defaultValue is ArrayValue) { + return defaultValue.values.any { objectContainsInvalidDefaultValues(inputObject, it, visitedFields) } + } + // An explicit null value terminates the fallthrough chain — no cycle possible. + // This is what makes `input A { a: A = null }` valid. + if (defaultValue is NullValue) return false + val presentFields: Map?> = when (defaultValue) { + null -> emptyMap() // initial call: treat as empty map + is ObjectValue -> defaultValue.objectFields.associate { it.name to it.value } + else -> return false // scalar value — no cycle possible + } + for (field in inputObject.fields) { + val namedFieldType = + GraphQLTypeUtil.unwrapAll(field.type) as? GraphQLInputObjectType ?: continue + if (field.name in presentFields) { + // Field explicitly provided in the default value object. + // Explicit values (including null) terminate the fallthrough chain, + // so we recurse without tracking visited fields. + // This is what makes `input A { a: A = { a: null } }` valid. + val fieldValue = presentFields[field.name] ?: continue + if (objectContainsInvalidDefaultValues(namedFieldType, fieldValue, visitedFields)) { + return true + } + } else { + // Field absent from the default value object — fall through to its schema default. + // This is where cycles can form: absent fields delegate to their schema default, + // which may itself have absent fields that delegate back. + if (!field.hasSetDefaultValue()) continue + val schemaDefault = field.inputFieldDefaultValue.value as? Value<*> ?: continue + if (field in visitedFields) { + return true + } + if (objectContainsInvalidDefaultValues(namedFieldType, schemaDefault, visitedFields + field)) { + return true + } + } + } + return false + } + + val invalidInputs = inputTypes.filter { objectContainsInvalidDefaultValues(it, null, emptySet()) } + return invalidInputs.isNotEmpty() + } + + Arb.graphQLSchema( + Config.default + (DefaultValueWeight to 1.0) + ).forAll(100) { schema -> + !containsInvalidDefaultValues(schema) + } + } + + @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 arb = arbitrary { + val types = Arb.graphQLTypes(names, cfg).bind() + Arb.graphQLSchema(types, cfg).bind() + } + + arb.forAll { schema -> + CycleGroups.mandatoryInputCycles(schema).isEmpty() + } + } + + @Test + fun `generated schemas can contain values on cyclic input objects`(): Unit = + runBlocking { + /** + * Ensures that for a type like: + * input Inp { inp: Inp } + * That we can generate interesting default values, like: + * input Inp { inp: Inp = { inp: { inp: { inp: null } } } } + */ + + val types = """ + type Query { empty:Int } + input Inp { inp:Inp } + """.asSchema.let { schema -> + GraphQLTypes.empty.copy( + inputs = mapOf("Inp" to requireNotNull(schema.getTypeAs("Inp"))) + ) + } + + Arb.graphQLSchema( + types, + Config.default + + (ExplicitNullValueWeight to 0.0) + + (MaxValueDepth to 3) + + (DefaultValueWeight to 1.0) + ).forAll { schema -> + val default = requireNotNull(schema.getTypeAs("Inp")) + .getField("inp") + .inputFieldDefaultValue + .value as? ObjectValue + + default != null && default.objectFields.isNotEmpty() && default.objectFields.none { it.value is NullValue } + } + } + + /** 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) + Arb.graphQLSchema(cfg).checkAll(1) { + val sdl = SchemaPrinter().print(it) + println(sdl) + markSuccess() + } + } + + private class CollectAppliedDirectives : GraphQLTypeVisitorStub() { + var directives = mutableListOf() + + override fun visitGraphQLAppliedDirective( + node: GraphQLAppliedDirective, + context: TraverserContext + ): TraversalControl { + directives += node + return TraversalControl.CONTINUE + } + } + + private class CollectDefaultables(val includeOneOfFields: Boolean) : GraphQLTypeVisitorStub() { + var defaultables = mutableListOf() + + override fun visitGraphQLArgument( + node: GraphQLArgument, + context: TraverserContext + ): TraversalControl { + defaultables += node.argumentDefaultValue + return TraversalControl.CONTINUE + } + + override fun visitGraphQLInputObjectField( + node: GraphQLInputObjectField, + context: TraverserContext + ): TraversalControl { + val parent = context.parentNode + if (parent is GraphQLInputObjectType && parent.isOneOf && !includeOneOfFields) { + return TraversalControl.CONTINUE + } + defaultables += node.inputFieldDefaultValue + return TraversalControl.CONTINUE + } + + override fun visitGraphQLDirective( + node: GraphQLDirective, + context: TraverserContext + ): TraversalControl { + // skip built-in directives, some of which define arguments with default values that are outside the control of this generator + if (node.name in builtinDirectives) { + return TraversalControl.ABORT + } + return TraversalControl.CONTINUE + } + + override fun visitGraphQLType( + node: GraphQLSchemaElement, + context: TraverserContext + ): TraversalControl = + // skip introspection fields and types, which are outside the control of this generator + if (node is GraphQLNamedType && node.name.startsWith("__")) { + TraversalControl.ABORT + } else { + TraversalControl.CONTINUE + } + } + + private fun GraphQLSchema.transformAppliedDirectives( + seed: Long = 0L, + cfg: Config = + Config.default + + (AppliedDirectiveWeight to CompoundingWeight.Always) + + (DirectiveIsRepeatable to 0.0) + + (DefaultValueWeight to 0.0) + ): GraphQLSchema { + val rs = RandomSource.seeded(seed) + val valueGenerator = GraphQLInputValueGenerator( + schema = this, + config = cfg, + randomSource = rs, + uncoercedValueWeight = cfg[SchemaUncoercedValueWeight], + allEdgesGraph = CycleGroups.allInputCycles(this), + mandatoryEdgesGraph = CycleGroups.mandatoryInputCycles(this) + ) + return SchemaTransformer.transformSchema( + this, + AddAppliedDirectives( + this, + valueGenerator, + cfg, + rs + ) + ) + } + + private fun GraphQLSchema.directiveArgumentAppliedDirectiveNames( + directiveName: String, + argName: String + ): Set = + requireNotNull(getDirective(directiveName)) + .let { requireNotNull(it.getArgument(argName)) } + .appliedDirectives + .mapTo(mutableSetOf()) { it.name } + + private fun GraphQLSchema.inputFieldAppliedDirectiveNames( + inputName: String, + fieldName: String + ): Set = + requireNotNull(getTypeAs(inputName)) + .let { requireNotNull(it.getField(fieldName)) } + .appliedDirectives + .mapTo(mutableSetOf()) { it.name } + + private fun GraphQLSchema.enumValueAppliedDirectiveNames( + enumName: String, + valueName: String + ): Set = + requireNotNull(getTypeAs(enumName)) + .let { requireNotNull(it.getValue(valueName)) } + .appliedDirectives + .mapTo(mutableSetOf()) { it.name } + + private fun controlSchema( + directives: List, + inputs: List = emptyList(), + enums: List = emptyList() + ): GraphQLSchema { + val cfg = + Config.default + + (AppliedDirectiveWeight to CompoundingWeight.Never) + + (DefaultValueWeight to 0.0) + val query = GraphQLObjectType + .newObject() + .name("Query") + .field( + GraphQLFieldDefinition + .newFieldDefinition() + .name("placeholder") + .type(Scalars.GraphQLInt) + .argument( + GraphQLArgument + .newArgument() + .name("arg") + .type(Scalars.GraphQLInt) + ) + ).build() + + val types = GraphQLTypes.empty.copy( + directives = directives.associateBy { it.name }, + objects = mapOf(query.name to query), + inputs = inputs.associateBy { it.name }, + enums = enums.associateBy { it.name } + ) + return SchemaGenerator(cfg, RandomSource.seeded(0)).createSchema(types) + } + + private fun controlDirective( + name: String, + argType: GraphQLInputType, + vararg locations: Introspection.DirectiveLocation + ): GraphQLDirective = + GraphQLDirective + .newDirective() + .name(name) + .argument( + GraphQLArgument + .newArgument() + .name("arg") + .type(argType) + ).validLocations(*locations) + .build() +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt index 995cf14ca..7a3c190dd 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLStringsTest.kt @@ -1,4 +1,4 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) +@file:Suppress("ForbiddenImport") package graphql.schema.property @@ -6,61 +6,121 @@ import graphql.Scalars import graphql.schema.GraphQLFieldDefinition import graphql.schema.GraphQLObjectType import graphql.schema.GraphQLSchema -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.checkAll - -class GraphQLStringsTest : FunSpec({ - test("graphQLName generates spec-compliant non-introspection names") { - val pattern = Regex("^[_A-Za-z][_0-9A-Za-z]*$") - checkAll(PropTestConfig(iterations = 500), Arb.graphQLName(1..40)) { name -> - name.matches(pattern).shouldBe(true) - name.startsWith("__").shouldBe(false) +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test + +class GraphQLStringsTest : KotestPropertyBase() { + @Test + fun `Arb_graphQLName produces spec-compliant names`(): Unit = + runBlocking { + val patt = Regex("^[a-z,A-Z,_][a-z,A-Z,_,0-9,]*") + Arb.graphQLName().forAll { + it.matches(patt) + } + } + + @Test + fun `Arb_graphQLName produces names that are valid GraphQL type names`(): Unit = + runBlocking { + val placeholder = + GraphQLFieldDefinition + .newFieldDefinition() + .name("placeholder") + .type(Scalars.GraphQLInt) + .build() + + Arb.graphQLName().forAll { typeName -> + val query = + GraphQLObjectType + .newObject() + .name(typeName) + .field(placeholder) + .build() + val result = + Result.runCatching { + GraphQLSchema.newSchema().query(query).build() + } + + result.isSuccess + } } - } - - test("graphQLName produces valid type names") { - val placeholder = GraphQLFieldDefinition.newFieldDefinition() - .name("placeholder") - .type(Scalars.GraphQLInt) - .build() - checkAll(PropTestConfig(iterations = 100), Arb.graphQLName()) { name -> - val query = GraphQLObjectType.newObject().name(name).field(placeholder).build() - runCatching { GraphQLSchema.newSchema().query(query).build() }.isSuccess.shouldBe(true) + + @Test + fun `Arb_graphQLName does not produce introspection names`(): Unit = + runBlocking { + Arb.graphQLName(1..4).forNone { it.startsWith("__") } } - } - - test("field and argument names are valid and start lower-case when alphabetic") { - val generators = listOf(Arb.graphQLFieldName(), Arb.graphQLArgumentName()) - generators.forEach { generator -> - checkAll(PropTestConfig(iterations = 100), generator) { name -> - name.startsWith("__").shouldBe(false) - val first = name.first() - (!first.isLetter() || first.isLowerCase()).shouldBe(true) + + @Test + fun `Arb_graphQLFieldName produces names that are valid GraphQL field names`(): Unit = + runBlocking { + val query = + GraphQLObjectType + .newObject() + .name("Query") + .build() + + Arb.graphQLFieldName().forAll { fieldName -> + val newQuery = + query.transform { + val field = + GraphQLFieldDefinition + .newFieldDefinition() + .name(fieldName) + .type(Scalars.GraphQLInt) + .build() + it.clearFields().field(field) + } + + val result = + Result.runCatching { + GraphQLSchema.newSchema().query(newQuery).build() + } + + result.isSuccess } } - } - test("enum value names exclude reserved values") { - checkAll(PropTestConfig(iterations = 200), Arb.graphQLEnumValueName()) { name -> - (name !in setOf("true", "false", "null") && !name.startsWith("__")).shouldBe(true) + @Test + fun `Arb_graphQLFieldName does not produce introspection names`(): Unit = + runBlocking { + Arb.graphQLFieldName().forNone { it.startsWith("__") } + } + + @Test + fun `Arb_graphQLEnumValueName does not produce invalid names`(): Unit = + runBlocking { + Arb.graphQLEnumValueName().forNone { + it.startsWith("__") || + it == "true" || + it == "false" || + it == "null" + } } - } - - test("BanFieldNames affects all field-like name generators") { - val banned = ('a'..'m').flatMap { listOf(it.toString(), it.uppercase()) }.toSet() - val config = Config.default + (BanFieldNames to banned) + (FieldNameLength to 1..2) - val generators = listOf( - Arb.graphQLFieldName(config), - Arb.graphQLArgumentName(config), - Arb.graphQLEnumValueName(config) - ) - generators.forEach { generator -> - checkAll(PropTestConfig(iterations = 100), generator) { name -> - (name !in banned).shouldBe(true) + + @Test + fun `BanFieldNames`(): Unit = + runBlocking { + // create a ban list of single-letter field names, a-m and A-M + val banned = CharRange('a', 'm').fold(emptySet()) { acc, ch -> + acc + ch.toString() + ch.uppercase() + } + + // create a set of single-char names + val cfg = Config.default + + (BanFieldNames to banned) + + (FieldNameLength to 1..2) + + (SchemaSize to 1) + + val arbs = listOf( + Arb.graphQLFieldName(cfg), + Arb.graphQLEnumValueName(cfg), + Arb.graphQLArgumentName(cfg) + ) + + arbs.forEach { arb -> + arb.forNone { banned.contains(it) } } } - } -}) +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt index ae282f289..22aae99a0 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLTypesTest.kt @@ -1,320 +1,556 @@ -@file:OptIn(io.kotest.common.ExperimentalKotest::class) +@file:Suppress("ForbiddenImport") package graphql.schema.property import graphql.Scalars +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLInterfaceType import graphql.schema.GraphQLList import graphql.schema.GraphQLNonNull import graphql.schema.GraphQLScalarType import graphql.schema.GraphQLType import graphql.schema.GraphQLTypeReference import graphql.schema.GraphQLTypeUtil -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldContainAll -import io.kotest.matchers.shouldBe import io.kotest.property.Arb -import io.kotest.property.PropTestConfig import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.element import io.kotest.property.arbitrary.flatMap import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.long +import io.kotest.property.arbitrary.intRange import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.of import io.kotest.property.arbitrary.pair import io.kotest.property.arbitrary.set -import io.kotest.property.checkAll +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test -class GraphQLTypesTest : FunSpec({ - test("GraphQLTypesGen generates requested directives") { - val cases = Arb.set(Arb.graphQLName(), 1..10).flatMap { directiveNames -> - val names = GraphQLNames(mapOf(TypeType.Directive to directiveNames)) - Arb.graphQLTypes(names, minimalTypesConfig).map { directiveNames to it } - } - checkAll(typesPropertyConfig, cases) { (directiveNames, types) -> - types.directives.keys.shouldContainAll(directiveNames) - } - } +class GraphQLTypesTest : KotestPropertyBase() { + /** + * 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) - test("DirectiveIsRepeatable controls custom directives") { - val cases = Arb.of(0.0, 1.0).flatMap { weight -> - val config = minimalTypesConfig + - (DirectiveIsRepeatable to weight) + - (IncludeBuiltinDirectives to false) - Arb.graphQLTypes(config).map { weight to it } - } - checkAll(typesPropertyConfig, cases) { (weight, types) -> - types.directives.values.all { it.isRepeatable == (weight == 1.0) }.shouldBe(true) - } - } + private fun mkGen( + cfg: Config = minimalConfig, + names: GraphQLNames = GraphQLNames.empty, + random: RandomSource = RandomSource.default() + ): GraphQLTypesGen = GraphQLTypesGen(cfg, names, random) - test("ListTypeWeight and NonNullableTypeWeight decorate types") { - val compoundingWeights = Arb.pair(Arb.of(0.0, 1.0), Arb.int(0..5)) - .map { (weight, maximum) -> CompoundingWeight(weight, maximum) } - checkAll(typesPropertyConfig, compoundingWeights, Arb.of(0.0, 1.0), Arb.long()) { - listWeight, nonNullWeight, seed -> - val names = GraphQLNames(mapOf(TypeType.Scalar to setOf("Int"))) - val config = minimalTypesConfig + - (ListTypeWeight to listWeight) + - (NonNullableTypeWeight to nonNullWeight) - val type = GraphQLTypesGen(config, names, RandomSource.seeded(seed)) - .decorate(Scalars.GraphQLInt) - val (lists, nonNulls) = type.decorationCounts() - lists.shouldBe(if (listWeight.weight == 1.0) listWeight.max else 0) - nonNulls.shouldBe(if (nonNullWeight == 1.0) lists + 1 else 0) - } - } + private fun Arb.Companion.compoundingWeight( + weights: List = listOf(0.0, 1.0), + max: IntRange = 0..10 + ): Arb = + Arb + .pair(Arb.of(weights), Arb.int(max)) + .map { (weight, max) -> CompoundingWeight(weight, max) } - test("DescriptionLength controls generated descriptions") { - checkAll(typesPropertyConfig, Arb.int(0..50), Arb.long()) { length, seed -> - val config = minimalTypesConfig + (DescriptionLength to length..length) - GraphQLTypesGen(config, GraphQLNames.empty, RandomSource.seeded(seed)) - .genDescription() - .length - .shouldBe(length) + @Test + fun `GraphQLTypesGen -- genDirectives`(): Unit = + runBlocking { + Arb + .set(Arb.graphQLName()) + .flatMap { dirNames -> + val names = GraphQLNames(mapOf(TypeType.Directive to dirNames)) + val cfg = minimalConfig + (DirectiveHasArgs to CompoundingWeight.Never) + Arb.graphQLTypes(names, cfg).map { dirNames to it } + }.checkInvariants { (dirNames, types), check -> + check.containsAtLeastElementsIn( + types.directives.keys, + dirNames, + "Missing directives" + ) + } } - } - test("FieldArgumentWeight controls field arguments") { - val cases = Arb.pair(Arb.of(0.0, 1.0), Arb.int(0..3)).flatMap { (weight, maximum) -> - val compoundingWeight = CompoundingWeight(weight, maximum) - val config = minimalTypesConfig + (FieldArgumentWeight to compoundingWeight) - Arb.graphQLTypes(config).map { compoundingWeight to it } + @Test + fun `GraphQLTypesGen -- DirectiveIsRepeatable`(): Unit = + runBlocking { + Arb + .of(0.0, 1.0) + .flatMap { weight -> + val cfg = minimalConfig + (DirectiveIsRepeatable to weight) + Arb.graphQLTypes(cfg).map { weight to it } + }.forAll { (weight, types) -> + types.directives.values + .filterNot { builtinDirectives.containsKey(it.name) } + .all { it.isRepeatable == (weight == 1.0) } + } } - checkAll(typesPropertyConfig, cases) { (weight, types) -> - val fields = (types.objects.values + types.interfaces.values).flatMap { it.fields } - if (weight.weight == 1.0 && weight.max > 0 && fields.isNotEmpty()) { - fields.any { it.arguments.isNotEmpty() }.shouldBe(true) - } else { - fields.all { it.arguments.isEmpty() }.shouldBe(true) + + @Test + fun `GraphQLTypesGen -- ListTypeWeight and NonNullableTypeWeight`(): Unit = + runBlocking { + fun GraphQLType.countDecorations(): Pair { + tailrec fun loop( + lists: Int, + nonNulls: Int, + t: GraphQLType + ): Pair = + if (t is GraphQLList) { + loop(lists + 1, nonNulls, t.originalWrappedType) + } else if (t is GraphQLNonNull) { + loop(lists, nonNulls + 1, t.originalWrappedType) + } else { + Pair(lists, nonNulls) + } + return loop(0, 0, this) } - } - } - test("IncludeBuiltinScalars controls built-in scalars") { - val cases = Arb.of(true, false).flatMap { include -> - val config = minimalTypesConfig + (IncludeBuiltinScalars to include) - Arb.graphQLTypes(config).map { include to it } - } - checkAll(typesPropertyConfig, cases) { (include, types) -> - val present = builtinScalars.keys.intersect(types.scalars.keys) - if (include) present.shouldContainAll(builtinScalars.keys) else present.shouldBeEmpty() - } - } + val names = GraphQLNames(mapOf(TypeType.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) - test("IncludeBuiltinDirectives controls built-in directives") { - val cases = Arb.of(true, false).flatMap { include -> - val config = minimalTypesConfig + (IncludeBuiltinDirectives to include) - Arb.graphQLTypes(config).map { include to it } - } - checkAll(typesPropertyConfig, cases) { (include, types) -> - val present = builtinDirectives.keys.intersect(types.directives.keys) - if (include) present.shouldContainAll(builtinDirectives.keys) else present.shouldBeEmpty() - } - } + val (lists, nonNulls) = mkGen(cfg, names) + .decorate(Scalars.GraphQLInt) + .countDecorations() - test("DirectiveHasArgs controls directive arguments") { - val cases = Arb.pair(Arb.of(0.0, 1.0), Arb.int(0..3)).flatMap { (weight, maximum) -> - val compoundingWeight = CompoundingWeight(weight, maximum) - val config = minimalTypesConfig + - (DirectiveHasArgs to compoundingWeight) + - (IncludeBuiltinDirectives to false) - Arb.graphQLTypes(config).map { compoundingWeight to it } - } - checkAll(typesPropertyConfig, cases) { (weight, types) -> - if (weight.weight == 0.0 || weight.max == 0) { - types.directives.values.all { it.arguments.isEmpty() }.shouldBe(true) - } else if (types.directives.isNotEmpty()) { - types.directives.values.any { it.arguments.isNotEmpty() }.shouldBe(true) - } + if (listTypeWeight.weight == 1.0 && lists < listTypeWeight.max) { + markFailure() + } else if (nonNullableTypeWeight == 1.0 && nonNulls != (lists + 1)) { + markFailure() + } else if (listTypeWeight.weight == 0.0 && lists != 0) { + markFailure() + } else if (nonNullableTypeWeight == 0.0 && nonNulls != 0) { + markFailure() + } else { + markSuccess() + } + } } - } - test("FieldNameLength controls field-like names") { - val cases = Arb.int(2..20).flatMap { length -> - val config = minimalTypesConfig + (FieldNameLength to length..length) - Arb.graphQLTypes(config).map { length to it } - } - checkAll(typesPropertyConfig, cases) { (length, types) -> - val names = types.objects.values.flatMap { it.fields }.map { it.name } + - types.inputs.values.flatMap { it.fields }.map { it.name } + - types.enums.values.flatMap { it.values }.map { it.name } - names.filterNot { it.startsWith("escape_") }.all { it.length == length }.shouldBe(true) + @Test + fun `GraphQLTypesGen - genDescription`(): Unit = + runBlocking { + Arb + .intRange(0..100) + .nonEmpty() + .checkAll { range -> + val desc = mkGen(cfg = minimalConfig + (DescriptionLength to range)).genDescription() + if (range.contains(desc.length)) { + markSuccess() + } else { + markFailure() + } + } } - } - test("InputObjectTypeSize is an upper bound") { - val cases = Arb.int(1..10).flatMap { size -> - val config = minimalTypesConfig + (InputObjectTypeSize to size..size) - Arb.graphQLTypes(config).map { size to it } - } - checkAll(typesPropertyConfig, cases) { (size, types) -> - types.inputs.values.all { it.fields.size <= size }.shouldBe(true) + @Test + fun `GraphQLTypes - FieldArgumentWeight`(): Unit = + runBlocking { + Arb + .compoundingWeight() + .flatMap { cw -> + val cfg = minimalConfig + (FieldArgumentWeight to cw) + Arb.graphQLTypes(cfg).map { cw to it } + }.checkInvariants { (cw, types), check -> + val fields = (types.objects.values + types.interfaces.values) + .flatMap { it.fields } + + if (cw.weight == 1.0 && cw.max > 0 && fields.isNotEmpty()) { + check.isTrue( + fields.any { it.arguments.isNotEmpty() }, + "No field arguments generated with FieldArgumentWeight $cw", + ) + } else { + fields.forEach { f -> + check.isTrue( + f.arguments.isEmpty(), + "Unexpected arguments on field ${f.name} with FieldArgumentWeight $cw", + ) + } + } + } } - } - test("InterfaceTypeSize is an upper bound") { - val cases = Arb.int(1..10).flatMap { size -> - val config = minimalTypesConfig + (InterfaceTypeSize to size..size) - Arb.graphQLTypes(config).map { size to it } + @Test + fun `GraphQLTypes - IncludeBuiltinScalars`(): Unit = + runBlocking { + Arb + .of(true, false) + .flatMap { incl -> + val cfg = minimalConfig + (IncludeBuiltinScalars to incl) + Arb.graphQLTypes(cfg).map { incl to it } + }.checkInvariants { (incl, types), check -> + if (incl) { + builtinScalars.keys.subtract(types.scalars.keys).let { missing -> + check.isEmpty(missing, "Missing scalars: $missing") + } + } else { + builtinScalars.keys.intersect(types.scalars.keys).let { intersect -> + check.isEmpty( + intersect, + "Expected no included directives but found $intersect" + ) + } + } + } } - checkAll(typesPropertyConfig, cases) { (size, types) -> - types.interfaces.values.all { it.fields.size <= size }.shouldBe(true) + + @Test + fun `GraphQLTypes - IncludeBuiltinDirectives`(): Unit = + runBlocking { + Arb + .of(true, false) + .flatMap { incl -> + val cfg = minimalConfig + (IncludeBuiltinDirectives to incl) + Arb.graphQLTypes(cfg).map { incl to it } + }.checkInvariants { (incl, types), check -> + if (incl) { + check.isTrue( + types.directives.keys.containsAll(builtinDirectives.keys), + "Missing directives" + ) + } else { + types.directives.keys.intersect(builtinDirectives.keys).let { intersect -> + check.isEmpty( + intersect, + "Expected no included directives but found $intersect" + ) + } + } + } } - } - test("ObjectTypeSize is an upper bound") { - val cases = Arb.int(1..10).flatMap { size -> - val config = minimalTypesConfig + (ObjectTypeSize to size..size) - Arb.graphQLTypes(config).map { size to it } + @Test + fun `GraphQLTypes - DirectiveHasArgs`(): Unit = + runBlocking { + Arb + .compoundingWeight() + .flatMap { cw -> + val cfg = minimalConfig + + (DirectiveHasArgs to cw) + + (IncludeBuiltinDirectives to false) + Arb.graphQLTypes(cfg).map { cw to it } + }.checkInvariants { (cw, types), check -> + if (cw.weight == 0.0 || cw.max == 0) { + types.directives.values.forEach { dir -> + check.isTrue( + dir.arguments.isEmpty(), + "Unexpected arguments on directive {0} with DirectiveHasArgs {1}: {2}", + arrayOf(dir.name, cw.toString(), dir.arguments.toString()) + ) + } + } else if (types.directives.isNotEmpty()) { + check.isTrue( + types.directives.values.any { it.arguments.isNotEmpty() }, + "No directives with arguments generated with DirectiveHasArgs {0}", + arrayOf(cw.toString()) + ) + } + } } - checkAll(typesPropertyConfig, cases) { (size, types) -> - types.objects.values.all { it.fields.size <= size }.shouldBe(true) + + @Test + fun `GraphQLTypes - FieldNameLength`(): Unit = + runBlocking { + Arb + .intRange(2..100) + .nonEmpty() + .flatMap { range -> + val cfg = minimalConfig + (FieldNameLength to range) + Arb.graphQLTypes(cfg).map { range to it } + }.checkInvariants { (range, types), check -> + val names = types.objects.values + .flatMap { it.fields } + .map { it.name } + + types.inputs.values + .flatMap { it.fields } + .map { it.name } + + types.enums.values + .flatMap { it.values } + .map { it.name } + + names.forEach { name -> + check.isTrue( + range.contains(name.length), + "field name `{0}` has length {1} when range = {2}", + arrayOf(name, name.length.toString(), range.toString()) + ) + } + } } - } - test("UnionTypeSize is an upper bound") { - val cases = Arb.int(1..10).flatMap { size -> - val config = minimalTypesConfig + (UnionTypeSize to size..size) - Arb.graphQLTypes(config).map { size to it } + @Test + fun `GraphQLTypes - InputObjectTypeSize`(): Unit = + runBlocking { + Arb + .intRange(1..100) + .nonEmpty() + .flatMap { range -> + val cfg = minimalConfig + (InputObjectTypeSize to range) + Arb.graphQLTypes(cfg).map { range to it } + }.checkInvariants { (range, types), check -> + types.inputs.values.forEach { inp -> + check.isTrue( + inp.fields.size <= range.last, + "input {0} has fields {1} outside of range {2}", + arrayOf(inp.name, inp.fields.size.toString(), range.toString()) + ) + } + } } - checkAll(typesPropertyConfig, cases) { (size, types) -> - types.unions.values.all { it.types.size <= size }.shouldBe(true) + + @Test + fun `GraphQLTypes - OneOfTypeWeight`(): Unit = + runBlocking { + // disabled + Arb.graphQLTypes(minimalConfig + (OneOfTypeWeight to 0.0)) + .forAll { types -> + types.inputs.values.none { it.isOneOf } + } + + // enabled + Arb.graphQLTypes(minimalConfig + (OneOfTypeWeight to 1.0)) + .forAll { types -> + types.inputs.values.all { inp -> + inp.isOneOf && inp.fields.all { field -> + GraphQLTypeUtil.isNullable(field.type) && + !field.hasSetDefaultValue() + } + } + } } - } - test("OneOfTypeWeight creates nullable oneOf fields without defaults") { - checkAll(typesPropertyConfig, Arb.graphQLTypes(minimalTypesConfig + (OneOfTypeWeight to 1.0))) { types -> - types.inputs.values.all { input -> - input.isOneOf && input.fields.all { field -> - GraphQLTypeUtil.isNullable(field.type) && !field.hasSetDefaultValue() + @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)) + .forAll { types -> + types.enums.values.all { e -> + e.values.all { v -> + v.value == v.name + } + } } - }.shouldBe(true) } - } - test("enum values use their names as backing values") { - val names = GraphQLNames(mapOf(TypeType.Enum to setOf("E"))) - checkAll(typesPropertyConfig, Arb.graphQLTypes(names, minimalTypesConfig + (EnumTypeSize to 1..3))) { types -> - types.enums.values.all { enum -> enum.values.all { it.value == it.name } }.shouldBe(true) + @Test + fun `GraphQLTypes - InterfaceTypeSize`(): Unit = + runBlocking { + Arb + .intRange(1..100) + .nonEmpty() + .flatMap { range -> + val cfg = minimalConfig + (InterfaceTypeSize to range) + Arb.graphQLTypes(cfg).map { range to it } + }.checkInvariants { (range, types), check -> + types.interfaces.values.forEach { iface -> + check.isTrue( + iface.fields.size <= range.last, + "interface {0} has fields {1} outside of range {2}", + arrayOf(iface.name, iface.fields.size.toString(), range.toString()) + ) + } + } } - } - test("ObjectImplementsInterface controls object interfaces") { - val names = GraphQLNames( - mapOf( - TypeType.Interface to setOf("I"), - TypeType.Object to setOf("O"), - TypeType.Scalar to setOf("Int") - ) - ) - checkAll(typesPropertyConfig, Arb.of(false, true), Arb.long()) { enabled, seed -> - val config = minimalTypesConfig + - (ObjectImplementsInterface to if (enabled) CompoundingWeight.Once else CompoundingWeight.Never) - val types = GraphQLTypesGen(config, names, RandomSource.seeded(seed)).gen() - types.objects.values.all { it.interfaces.isNotEmpty() == enabled }.shouldBe(true) + @Test + fun `GraphQLTypes - ObjectTypeSize`(): Unit = + runBlocking { + Arb + .intRange(1..100) + .nonEmpty() + .flatMap { range -> + val cfg = minimalConfig + (ObjectTypeSize to range) + Arb.graphQLTypes(cfg).map { range to it } + }.checkInvariants { (range, types), check -> + types.objects.values.forEach { obj -> + check.isTrue( + obj.fields.size <= range.last, + "object {0} has fields {1} outside of range {2}", + arrayOf(obj.name, obj.fields.size.toString(), range.toString()) + ) + } + } } - } - test("resolve finds present references") { - val cases = Arb.graphQLNames().flatMap { names -> - Arb.graphQLTypes(names, minimalTypesConfig).map { names to it } + @Test + fun `GraphQLTypes - ObjectImplementsInterface`(): Unit = + runBlocking { + // ensure that types includes at least 1 interface + val baseTypes = GraphQLTypes.empty + GraphQLInterfaceType + .newInterface() + .name("I") + .field( + GraphQLFieldDefinition + .newFieldDefinition() + .name("f") + .type(Scalars.GraphQLInt) + .build() + ).build() + + Arb + .compoundingWeight() + .flatMap { cw -> + val cfg = minimalConfig + + (ObjectImplementsInterface to cw) + (IncludeTypes to baseTypes) + Arb.graphQLTypes(cfg).map { cw to it } + }.checkInvariants { (cw, types), check -> + 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() + ) + } + } } - checkAll(typesPropertyConfig, cases) { (names, types) -> - names.names - .filterKeys { it != TypeType.Directive } - .values - .flatten() - .map(GraphQLTypeReference::typeRef) - .all { types.resolve(it) != null } - .shouldBe(true) + + @Test + fun `GraphQLTypes - UnionTypeSize`(): Unit = + runBlocking { + Arb + .intRange(1..100) + .nonEmpty() + .flatMap { range -> + val cfg = minimalConfig + (UnionTypeSize to range) + Arb.graphQLTypes(cfg).map { range to it } + }.checkInvariants { (range, types), check -> + // Due to collisions, union types may contain fewer members than the + // lowest range value + types.unions.values.forEach { union -> + check.isTrue( + union.types.size <= range.last, + "union {0} has members {1} greater than range range {2}", + arrayOf(union.name, union.types.size.toString(), range.toString()) + ) + } + } } - } - test("resolve rejects missing references") { - checkAll(typesPropertyConfig, Arb.graphQLTypes(minimalTypesConfig), Arb.graphQLName()) { types, name -> - if (name !in types.names) { - (types.resolve(GraphQLTypeReference.typeRef(name)) == null).shouldBe(true) - } + @Test + fun `GraphQLTypes - resolve present references`(): Unit = + runBlocking { + // resolve present types + Arb + .graphQLNames() + .map { names -> + val types = mkGen(names = names).gen() + names to types + }.forAll { (names, types) -> + val refs = names.names + .filter { + it.key != TypeType.Directive + }.flatMap { it.value } + .map(GraphQLTypeReference::typeRef) + + refs.all { ref -> + types.resolve(ref) != null + } + } } - } - test("non-null recursive inputs receive escape fields") { - val names = GraphQLNames(mapOf(TypeType.Input to setOf("A"))) - val config = minimalTypesConfig + (NonNullableTypeWeight to 1.0) - checkAll(typesPropertyConfig, Arb.graphQLTypes(names, config)) { types -> - types.inputs.getValue("A").fields.any { - it.name.startsWith("escape_") && it.type is GraphQLScalarType - }.shouldBe(true) + @Test + fun `GraphQLTypes - resolve missing references`(): Unit = + runBlocking { + // resolve present types + Arb + .graphQLNames() + .map { names -> + val types = mkGen(names = names).gen() + names to types + }.forAll { (names, types) -> + val otherRefs = Arb + .set(Arb.graphQLName(), 1..100) + .map { otherNames -> + otherNames + .filterNot(names.allNames::contains) + .map(GraphQLTypeReference::typeRef) + }.bind() + + otherRefs.all { types.resolve(it) == null } + } } - } - test("uninhabited oneOf inputs receive escape fields") { - val names = GraphQLNames(mapOf(TypeType.Input to setOf("A"))) - val config = minimalTypesConfig + (OneOfTypeWeight to 1.0) - checkAll(typesPropertyConfig, Arb.graphQLTypes(names, config)) { types -> - types.inputs.getValue("A").fields.any { - it.name.startsWith("escape_") && it.type is GraphQLScalarType - }.shouldBe(true) + @Test + 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)) + .forAll { types -> + types.inputs.values.all { inp -> + inp.fields.isNotEmpty() + } + } } - } - test("IncludeTypes retains every included type") { - val cases = Arb.graphQLTypes(minimalTypesConfig).flatMap { included -> - val config = minimalTypesConfig + (IncludeTypes to included) - Arb.graphQLTypes(config).map { included to it } + @Test + fun `GraphQLTypes -- generates escape fields for OneOf inputs`(): Unit = + runBlocking { + // OneOf types may require additional "escape" fields to ensure that they are inhabited. + // For example, for this uninhabited type: + // input A @oneOf { a:A } + // We would expect the generator to add an escape field to ensure that values can be constructed: + // input A @oneOf { a:A, escape:Int } + + Arb.graphQLTypes( + GraphQLNames(mapOf(TypeType.Input to setOf("A"))), + minimalConfig + (OneOfTypeWeight to 1.0) + ).forAll { types -> + val a = types.inputs["A"]!! + a.fields.any { it.name.startsWith("escape") && it.type is GraphQLScalarType } + } } - checkAll(typesPropertyConfig, cases) { (included, generated) -> - generated.names.containsAll(included.names).shouldBe(true) + + @Test + fun `GraphQLTypes -- IncludeTypes`(): Unit = + runBlocking { + Arb + .graphQLTypes(minimalConfig) + .flatMap { a -> + val cfg = minimalConfig + (IncludeTypes to a) + Arb.graphQLTypes(cfg).map { b -> a to b } + }.forAll { (a, b) -> + b.names.containsAll(a.names) + } } - } - test("GenInterfaceStubsIfNeeded implements every interface") { - val config = minimalTypesConfig + (GenInterfaceStubsIfNeeded to true) - checkAll(typesPropertyConfig, Arb.graphQLTypes(config)) { types -> - val implemented = types.objects.values.flatMap { objectType -> - objectType.interfaces.map { it.name } + @Test + fun `GraphQLTypes -- GenInterfaceStubsIfNeeded`(): Unit = + runBlocking { + val cfg = minimalConfig + (GenInterfaceStubsIfNeeded to true) + Arb.graphQLTypes(cfg).forAll { types -> + val ifaces = types.interfaces.keys + val implemented = types.objects.values.flatMap { it.interfaces.map { it.name } } + val unimplemented = ifaces - implemented + unimplemented.isEmpty() } - (types.interfaces.keys - implemented.toSet()).shouldBeEmpty() } - } -}) - -private val typesPropertyConfig = PropTestConfig(iterations = 100) - -/** - * Most generation features are disabled so each property can enable only the behavior it tests. - */ -private val minimalTypesConfig = 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 fun GraphQLType.decorationCounts(): Pair { - var lists = 0 - var nonNulls = 0 - var current = this - while (GraphQLTypeUtil.isWrapped(current)) { - when (current) { - is GraphQLList -> lists++ - is GraphQLNonNull -> nonNulls++ - } - current = GraphQLTypeUtil.unwrapOne(current) - } - return lists to nonNulls } + +private val GraphQLType.listSomewhere: Boolean + get() = when (this) { + is GraphQLList -> true + is GraphQLNonNull -> wrappedType.listSomewhere + else -> false + } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBase.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBase.kt new file mode 100644 index 000000000..f2edcad07 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBase.kt @@ -0,0 +1,116 @@ +@file:OptIn(ExperimentalKotest::class) + +package graphql.schema.property + +import io.kotest.common.ExperimentalKotest +import io.kotest.property.Gen +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 + +/** + * Abstract base class for kotest-property test suites. + * + * 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 + * [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: + * - gradle: + * Set these properties in the build.gradle.kts of the module that owns the test suite: + * ```kotlin + * tasks.withType().configureEach { + * systemProperty("junit.jupiter.execution.parallel.enabled", "true") + * systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent") + * systemProperty("junit.jupiter.execution.parallel.mode.classes.default", "concurrent") + * } + * ``` + * + * - bazel: + * configure your java_test target with `enable_parallel_test_execution`: + * ``` + * java_test( + * name = "my-tests", + * enable_parallel_test_execution = True, #keep + * ... + * ) + * ``` + * + * @param seed override the testing seed. This is useful for debugging property test failures. + * To preserve the randomness of property testing, subclasses should not permanently override + * the seed value. + * @param iterations a default number of iterations to apply to [forAll], [checkAll], + * [forNone], etc + */ +@Execution(ExecutionMode.CONCURRENT) +abstract class KotestPropertyBase( + val seed: Long = Random.nextLong(), + val iterations: Int = PropertyTesting.defaultIterationCount +) { + /** A [RandomSource] seeded with this instance's [seed], for use in explicit generator calls. */ + val randomSource: RandomSource = RandomSource.seeded(seed) + + init { + println("[KotestPropertyBase] ${this::class.simpleName} seed=$seed") + } + + private fun config(iterations: Int? = this.iterations): PropTestConfig = + PropTestConfig( + seed = seed, + iterations = iterations, + // The original suites deliberately use assume() for roughly half of some generated + // documents. Kotest 6 enforces its 20% default discard ceiling for those properties. + maxDiscardPercentage = 90 + ) + + /** Apply [io.kotest.property.forAll] using the configured seed and iterations */ + suspend fun Gen.forAll(property: suspend PropertyContext.(A) -> Boolean) = io.kotest.property.forAll(config(), this, property) + + /** Apply [io.kotest.property.forAll] using the configured seed and iterations */ + suspend fun Gen.forAll( + iterations: Int, + property: suspend PropertyContext.(A) -> Boolean + ) = io.kotest.property.forAll(config(iterations), this, property) + + /** Apply [io.kotest.property.checkAll] using the configured seed and iterations */ + suspend fun Gen.checkAll(property: suspend PropertyContext.(A) -> Unit) = io.kotest.property.checkAll(config(), this, property) + + /** Apply [io.kotest.property.checkAll] using the configured seed and iterations */ + suspend fun Gen.checkAll( + iterations: Int, + property: suspend PropertyContext.(A) -> Unit + ) = io.kotest.property.checkAll(iterations, config(), this, property) + + /** Apply [io.kotest.property.forNone] using the configured seed and iterations */ + suspend fun Gen.forNone(property: suspend PropertyContext.(A) -> Boolean) = io.kotest.property.forNone(config(), this, property) + + /** Apply [io.kotest.property.forNone] using the configured seed and iterations */ + suspend fun Gen.forNone( + iterations: Int, + 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/KotestPropertyBaseTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBaseTest.kt new file mode 100644 index 000000000..62f230ecd --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/KotestPropertyBaseTest.kt @@ -0,0 +1,130 @@ +@file:Suppress("ForbiddenImport") + +package graphql.schema.property + +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 +import kotlinx.coroutines.awaitAll +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") + } + } + + @Test + fun `configures a seed from ctor`() { + val base = object : KotestPropertyBase(1L) {} + assertEquals(1L, base.seed) + assertEquals(1L, base.randomSource.seed) + } + + @Test + fun `configures a random seed by default`() { + val base = object : KotestPropertyBase() {} + assertEquals(base.seed, base.randomSource.seed) + } + + @Test + fun `randomSource is seeded deterministically`() { + val a = object : KotestPropertyBase(42L) {} + val b = object : KotestPropertyBase(42L) {} + assertEquals( + Arb.int().generate(a.randomSource).take(10).map { it.value }.toList(), + Arb.int().generate(b.randomSource).take(10).map { it.value }.toList() + ) + } + + @Test + 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) {} + + // Collect values generated by forAll from each instance + val values1 = mutableListOf() + val values2 = mutableListOf() + + with(base1) { + Arb.int().forAll(10) { v -> + values1.add(v) + true + } + } + with(base2) { + Arb.int().forAll(10) { v -> + values2.add(v) + true + } + } + + // Same seed produces same values + val values1Again = mutableListOf() + with(object : KotestPropertyBase(1L) {}) { + Arb.int().forAll(10) { v -> + values1Again.add(v) + true + } + } + assertEquals(values1, values1Again, "Same seed should produce same values") + assertNotEquals(values1, values2, "Different seeds should produce different values") + } + + @Test + fun `concurrent instances do not interfere with each other`() = + runBlocking { + // Simulate parallel test execution: multiple KotestPropertyBase 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) { + suspend fun test(): List { + val values = mutableListOf() + Arb.int().forAll(count) { v -> + values.add(v) + true + } + return values + } + } + + // run each seed concurrently in separate coroutines + val results = seeds.map { seed -> + async(Dispatchers.Default) { + seed to Tester(seed).test() + } + }.awaitAll().toMap() + + // re-run each seed and verify that it produces the same output + seeds.forEach { seed -> + val expected = Tester(seed).test() + assertEquals(expected, results[seed], "Seed $seed produced non-deterministic results under concurrency") + } + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt index d84319ead..09d137ef5 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestFixtures.kt @@ -13,3 +13,20 @@ internal fun parseTestSchema( SchemaParser().parse(sdl), runtimeWiring ) + +/** + * 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. + */ +internal val String.asPermissiveTestSchema: GraphQLSchema + get() { + val normalized = replace("extend type Query", "type Query") + .replace("extend type Mutation", "type Mutation") + .replace("extend type Subscription", "type Subscription") + val withQuery = if (Regex("(?m)^\\s*type\\s+Query\\b").containsMatchIn(normalized)) { + normalized + } else { + "$normalized\ntype Query { propertyTestPlaceholder: Boolean }" + } + return parseTestSchema(withQuery) + } diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtils.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtils.kt deleted file mode 100644 index 489511b96..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtils.kt +++ /dev/null @@ -1,128 +0,0 @@ -package graphql.schema.property - -import io.kotest.property.Arb -import io.kotest.property.PropertyTesting -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 -import java.io.PrintStream - -/** - * Flatten this generator into a generator of its elements while preserving the order within - * every generated iterable. - * - * The underlying generator must eventually produce a non-empty iterable. - */ -internal fun Arb>.flatten(): Arb = FlattenArb(map { it.iterator() }) - -/** Transform this generator, dropping values for which [transform] returns null. */ -internal fun Arb.mapNotNull(transform: (T) -> R?): Arb = - map(transform).filter { it != null }.map { requireNotNull(it) } - -private class FlattenArb(private val underlying: Arb>) : Arb() { - private var chunk: Iterator? = null - - override fun edgecase(rs: RandomSource): Sample? { - val current = chunk - if (current != null && current.hasNext()) return null - val next = underlying.edgecase(rs)?.value ?: return null - if (!next.hasNext()) return null - chunk = next - return next.next().asSample() - } - - override fun sample(rs: RandomSource): Sample { - var current = chunk - while (current == null || !current.hasNext()) { - current = underlying.sample(rs).value - chunk = current - } - return current.next().asSample() - } -} - -/** Throw a property failure that includes [seed] when supplied. */ -internal fun failProperty( - message: String, - cause: Throwable? = null, - seed: Long? = null -): Nothing = - throw AssertionError( - buildString { - appendLine(if (seed == null) "Property failed" else "Property failed with seed $seed") - append(message) - }, - cause - ) - -/** An arbitrary generator paired with a property that its generated values must satisfy. */ -internal class CheckedArb( - val arb: Arb, - val check: (T) -> Unit -) { - /** Return the minimum violation found in [iterations] samples, if any. */ - fun minViolation( - comparator: Comparator, - rs: RandomSource, - iter: Int = PropertyTesting.defaultIterationCount, - printEvery: Int = 1_000, - out: PrintStream = System.out - ): Violation? = - arb.asSequence(rs) - .take(iter) - .foldIndexed(null as Violation?) { index, minimum, value -> - if (printEvery > 0 && index % printEvery == 0) out.println("Iteration $index...") - checkValue(value, minimum, comparator, rs.seed, index, out) - } - - /** March through deterministic seeds and return the first violation, if any. */ - fun seedMarch( - startingSeed: Long = 0, - maxIter: Int = 100_000_000, - printEvery: Int = 1_000, - out: PrintStream = System.out - ): Violation? { - repeat(maxIter) { index -> - val seed = startingSeed + index - if (printEvery > 0 && index % printEvery == 0) out.println("Seed $seed...") - var value: T? = null - try { - value = arb.next(RandomSource.seeded(seed)) - check(value) - } catch (error: Throwable) { - return Violation(value, error, seed) - } - } - return null - } - - @Suppress("UNCHECKED_CAST") - private fun checkValue( - value: T, - minimum: Violation?, - comparator: Comparator, - seed: Long, - index: Int, - out: PrintStream - ): Violation? { - return try { - check(value) - minimum - } catch (error: Throwable) { - if (minimum == null) return Violation(value, error, seed) - val minimumValue = minimum.value as T - if (comparator.compare(value, minimumValue) >= 0) return minimum - out.println("Found new min violation at iteration $index") - Violation(value, error, seed) - } - } -} - -/** A generated value that violated a property, with its error and source seed. */ -internal data class Violation(val value: T?, val err: Throwable, val seed: Long) - -/** Pair this generator with [check] for failure mining and seed marching. */ -internal fun Arb.withCheck(check: (T) -> Unit): CheckedArb = CheckedArb(this, check) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtilsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtilsTest.kt deleted file mode 100644 index 9e1c0cad6..000000000 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/PropertyTestUtilsTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package graphql.schema.property - -import graphql.ExecutionInput -import graphql.parser.Parser -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain -import io.kotest.property.Arb -import io.kotest.property.RandomSource -import io.kotest.property.arbitrary.constant -import io.kotest.property.arbitrary.map -import io.kotest.property.arbitrary.of -import java.io.ByteArrayOutputStream -import java.io.PrintStream - -class PropertyTestUtilsTest : FunSpec({ - test("flatten preserves order within each generated iterable") { - val values = Arb.constant(listOf(1, 2, 3)).flatten() - .asSequence(RandomSource.seeded(1)) - .take(9) - .toList() - values.shouldBe(listOf(1, 2, 3, 1, 2, 3, 1, 2, 3)) - } - - test("mapNotNull drops null transformations") { - val values = Arb.of(-2, -1, 0, 1, 2) - .mapNotNull { value -> value.takeIf { it > 0 } } - .asSequence(RandomSource.seeded(2)) - .take(30) - .toList() - values.all { it > 0 }.shouldBe(true) - } - - test("failProperty includes its seed and cause") { - val cause = IllegalStateException("cause") - val error = shouldThrow { failProperty("details", cause, 42) } - error.message.shouldContain("Property failed with seed 42") - error.message.shouldContain("details") - error.cause.shouldBe(cause) - } - - test("minViolation returns a failing generated value") { - val output = PrintStream(ByteArrayOutputStream()) - val violation = Arb.constant(7) - .withCheck { error("failure") } - .minViolation(Comparator.naturalOrder(), RandomSource.seeded(9), iter = 5, out = output) - violation?.value.shouldBe(7) - violation?.seed.shouldBe(9) - } - - test("seedMarch reports the first deterministic failing seed") { - val output = PrintStream(ByteArrayOutputStream()) - val violation = Arb.constant("value") - .withCheck { error("failure") } - .seedMarch(startingSeed = 15, maxIter = 2, printEvery = 0, out = output) - violation?.value.shouldBe("value") - violation?.seed.shouldBe(15) - } - - test("document and execution input comparators count AST descendants") { - val small = Parser().parseDocument("{ x }") - val large = Parser().parseDocument("{ x { y z } }") - (DocumentComparator.compare(small, large) < 0).shouldBe(true) - - val smallInput = ExecutionInput.newExecutionInput().query("{ x }").build() - val largeInput = ExecutionInput.newExecutionInput().query("{ x { y z } }").build() - (ExecutionInputComparator.compare(smallInput, largeInput) < 0).shouldBe(true) - } -}) diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemasTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemasTest.kt new file mode 100644 index 000000000..54b92f0c8 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemasTest.kt @@ -0,0 +1,39 @@ +package graphql.schema.property + +import graphql.Directives +import graphql.introspection.Introspection.DirectiveLocation +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SchemasTest { + @Test + fun `directivesByLocation -- empty`() { + val schemas = Schemas("type Query { x: Int }".asSchema) + // GraphQL built in directives change somewhat often. + // To keep this test low maintenance, let's just spot check a couple key ones + listOf( + Directives.OneOfDirective, + Directives.SkipDirective, + Directives.IncludeDirective, + Directives.DeprecatedDirective + ).forEach { dir -> + dir.validLocations().forEach { loc -> + assertTrue(schemas.directivesByLocation[loc]!!.any { it.name == dir.name }) + } + } + } + + @Test + fun `directivesByLocation -- custom`() { + val schemas = Schemas( + """ + directive @foo on FIELD + directive @bar on FIELD | OBJECT + type Query { x: Int } + """.trimIndent().asSchema + ) + schemas.directivesByLocation[DirectiveLocation.FIELD]!!.any { it.name == "foo" } + schemas.directivesByLocation[DirectiveLocation.FIELD]!!.any { it.name == "bar" } + schemas.directivesByLocation[DirectiveLocation.OBJECT]!!.any { it.name == "bar" } + } +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/UtilTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/UtilTest.kt new file mode 100644 index 000000000..27ed43187 --- /dev/null +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/UtilTest.kt @@ -0,0 +1,370 @@ +@file:Suppress("ForbiddenImport") +@file:OptIn(ExperimentalTime::class) + +package graphql.schema.property + +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.arbitrary.arbitrary +import io.kotest.property.arbitrary.char +import io.kotest.property.arbitrary.constant +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.map +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 kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime +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() { + @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 `Arb-set subset`(): Unit = + runBlocking { + Arb + .set(Arb.int()) + .forAll { set -> + val subset = Arb.constant(set).subset().bind() + set.containsAll(subset) + } + } + + @Test + fun `Arb-set subset with range`(): Unit = + runBlocking { + Arb + .pair( + Arb.set(Arb.int()), + Arb.intRange(0 until Int.MAX_VALUE).nonEmpty() + ).checkAll { (set, range) -> + val subset = + Arb + .constant(set) + .subset(range) + .bind() + + if (range.first > set.size && subset.size != set.size) { + markFailure() + } else if (range.last > set.size && subset.size != set.size) { + markFailure() + } else { + markSuccess() + } + } + } + + @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 { + val range = 0..100 + Arb + .int(range) + .orNull() + .filterNotNull() + .forAll { it in range } + } + + @Test + fun zip(): Unit = + runBlocking { + Arb + .pair(Arb.int(), Arb.string()) + .flatMap { (int, string) -> + Arb + .constant(int) + .zip(Arb.constant(string)) + .map { pair -> pair to (int to string) } + }.forAll { (pair1, pair2) -> + pair1 == pair2 + } + } + + @Test + fun `weightedChoose -- with fallback`(): Unit = + runBlocking { + val weighted = Arb.constant(true) + val fallback = Arb.constant(false) + + Arb + .element(setOf(0.0, 1.0)) + .flatMap { weight -> + val arb = Arb.weightedChoose(weight to weighted, fallback) + arb.zip(Arb.constant(weight)) + }.forAll { (choseWeighted, weight) -> + (weight == 1.0) == choseWeighted + } + } + + @Test + fun `weightedChoose -- list`(): Unit = + runBlocking { + // test that weights can sum to over 1.0 and that 0.0 weights are never chosen + val arb = Arb.weightedChoose( + listOf( + 0.0 to Arb.constant(0), + 1.0 to Arb.constant(1), + 1.0 to Arb.constant(2), + 0.0 to Arb.constant(3), + ) + ) + + arb.forAll { it == 1 || it == 2 } + } + + @Test + fun `weightedChoose -- empty list -- empty`() { + assertThrows { + Arb.weightedChoose(emptyList()) + } + assertThrows { + Arb.weightedChoose(listOf(0.0 to Arb.unit())) + } + } + + @Test + fun `weightedChoose -- singleton list`() { + val arb = Arb.unit() + assertEquals(arb, Arb.weightedChoose(listOf(0.1 to arb))) + } + + @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 { + // always true + arbitrary { rs -> rs.sampleWeight(1.0) } + .forAll { it } + + // always false + arbitrary { rs -> rs.sampleWeight(0.0) } + .forNone { it } + } + + @Test + fun `RandomSource count`(): Unit = + runBlocking { + arbitrary { rs -> rs.count(CompoundingWeight.Never) } + .forAll { it == 0 } + + Arb.int(0..100).forAll { i -> + val count = randomSource.count(CompoundingWeight(1.0, i)) + count == i + } + } + + @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 `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)) + } + + @Test + fun `maybeDelay`(): Unit = + runBlocking { + // no delay + run { + val time = measureTime { + randomSource.maybeDelay(0.asLongRange()) + } + // pick a number greater than 0 to accommodate slow CI machines + assertTrue(time < 50.milliseconds) + } + + // delay + run { + val time = measureTime { + randomSource.maybeDelay(100.asLongRange()) + } + assertTrue(time >= 100.milliseconds) + } + } + + @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.default + (key to 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") + } +} From c3a1994f8bc628d15f625c8d1fa3d8e5bf8fcad1 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Wed, 5 Aug 2026 09:59:17 +1000 Subject: [PATCH 3/5] Use JUnit for property testing suites --- graphql-java-property-testing/build.gradle | 1 - .../GraphQLExternalInputValueGeneratorTest.kt | 21 +-- .../property/GraphQLOutputValuesTest.kt | 131 ++++++++++-------- .../schema/property/GraphQLTestDataTest.kt | 52 +++---- .../property/SchemaRelationshipsTest.kt | 19 +-- 5 files changed, 122 insertions(+), 102 deletions(-) diff --git a/graphql-java-property-testing/build.gradle b/graphql-java-property-testing/build.gradle index 12e27d1f8..085b5263b 100644 --- a/graphql-java-property-testing/build.gradle +++ b/graphql-java-property-testing/build.gradle @@ -46,7 +46,6 @@ dependencies { api "org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}" api "io.kotest:kotest-property:${kotestVersion}" - testImplementation "io.kotest:kotest-runner-junit5:${kotestVersion}" 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' 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 index 4881a2a2b..f7b914fc4 100644 --- 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 @@ -6,14 +6,15 @@ import graphql.schema.GraphQLList import graphql.schema.GraphQLNonNull import graphql.schema.GraphQLScalarType import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.FunSpec 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 : FunSpec({ - test("nullable and non-null inputs honor ExplicitNullValueWeight") { +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) @@ -24,7 +25,8 @@ class GraphQLExternalInputValueGeneratorTest : FunSpec({ (nonNull.generate(GraphQLNonNull.nonNull(scalar)) is Int).shouldBe(true) } - test("lists honor configured size and item nullability") { + @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))) @@ -37,7 +39,8 @@ class GraphQLExternalInputValueGeneratorTest : FunSpec({ value.all { it is Int }.shouldBe(true) } - test("oneOf values contain exactly one non-null member") { + @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 }" ) @@ -49,7 +52,8 @@ class GraphQLExternalInputValueGeneratorTest : FunSpec({ (value.values.single() != null).shouldBe(true) } - test("recursive input values stop at MaxValueDepth") { + @Test + fun `recursive input values stop at MaxValueDepth`() { val schema = parseTestSchema( "input Recursive { next: Recursive } type Query { x(value: Recursive): Int }" ) @@ -64,7 +68,8 @@ class GraphQLExternalInputValueGeneratorTest : FunSpec({ nested.shouldBe(mapOf("next" to null)) } - test("custom scalars require and use ScalarValueOverrides") { + @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 { @@ -79,4 +84,4 @@ class GraphQLExternalInputValueGeneratorTest : FunSpec({ .generate(scalar) .shouldBe("custom-value") } -}) +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt index ecc87536e..9048e2ac1 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/GraphQLOutputValuesTest.kt @@ -9,82 +9,91 @@ import graphql.schema.GraphQLList import graphql.schema.GraphQLNonNull import graphql.schema.GraphQLOutputType import graphql.schema.GraphQLUnionType -import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.property.Arb -import io.kotest.property.PropTestConfig import io.kotest.property.arbitrary.constant -import io.kotest.property.checkAll +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test -class GraphQLOutputValuesTest : FunSpec({ - test("nullable and non-null scalar values honor ExplicitNullValueWeight") { - val schema = parseTestSchema("type Query { x: Int }") - val scalar = schema.getType("Int") as GraphQLOutputType - val nullConfig = Config.default + (ExplicitNullValueWeight to 1.0) - checkAll(PropTestConfig(iterations = 20), Arb.graphQLOutputValue(schema, scalar, null, config = nullConfig)) { - it.shouldBe(null) +class GraphQLOutputValuesTest : KotestPropertyBase() { + @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) + Arb.graphQLOutputValue(schema, scalar, null, config = nullConfig).checkAll(20) { + it.shouldBe(null) + } + val nonNull = GraphQLNonNull.nonNull(scalar) + Arb.graphQLOutputValue(schema, nonNull, null, config = nullConfig).checkAll(20) { + it.shouldBeInstanceOf() + } } - val nonNull = GraphQLNonNull.nonNull(scalar) - checkAll(PropTestConfig(iterations = 20), Arb.graphQLOutputValue(schema, nonNull, null, config = nullConfig)) { - it.shouldBeInstanceOf() - } - } - test("list wrappers preserve configured size and element nullability") { - 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) - checkAll(PropTestConfig(iterations = 20), Arb.graphQLOutputValue(schema, type, null, config = config)) { value -> - val list = value.shouldBeInstanceOf>() - list.size.shouldBe(2) - list.all { it is Int }.shouldBe(true) + @Test + fun `list wrappers preserve configured size and element nullability`(): Unit = + runBlocking { + 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) + Arb.graphQLOutputValue(schema, type, null, config = config).checkAll(20) { value -> + val list = value.shouldBeInstanceOf>() + list.size.shouldBe(2) + list.all { it is Int }.shouldBe(true) + } } - } - test("objects honor aliases, inline fragments, and named fragments") { - val schema = parseTestSchema("type Obj { x: Int!, y: Int! } type Query { obj: Obj! }") - 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) - checkAll(PropTestConfig(iterations = 30), Arb.graphQLOutputValue(schema, document, config)) { root -> - root.typeName.shouldBe("Query") - val obj = root.fields.getValue("obj").shouldBeInstanceOf() - obj.fields.keys.toList().shouldContainExactly("alias", "y", "__typename") - obj.fields["__typename"].shouldBe("Obj") + @Test + fun `objects honor aliases, inline fragments, and named fragments`(): Unit = + runBlocking { + val schema = parseTestSchema("type Obj { x: Int!, y: Int! } type Query { obj: Obj! }") + 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) + Arb.graphQLOutputValue(schema, document, config).checkAll(30) { root -> + root.typeName.shouldBe("Query") + val obj = root.fields.getValue("obj").shouldBeInstanceOf() + obj.fields.keys.toList().shouldContainExactly("alias", "y", "__typename") + obj.fields["__typename"].shouldBe("Obj") + } } - } - test("SelectedTypeBias chooses a concrete type selected by a fragment") { - val schema = parseTestSchema( - "type A { x: Int } type B { x: Int } union U = A | B type Query { u: U }" - ) - 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) - checkAll(PropTestConfig(iterations = 50), Arb.graphQLOutputValue(schema, union, selections, config = config)) { value -> - value.shouldBeInstanceOf().typeName.shouldBe("A") + @Test + fun `SelectedTypeBias chooses a concrete type selected by a fragment`(): Unit = + runBlocking { + val schema = parseTestSchema( + "type A { x: Int } type B { x: Int } union U = A | B type Query { u: U }" + ) + 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) + Arb.graphQLOutputValue(schema, union, selections, config = config).checkAll(50) { value -> + value.shouldBeInstanceOf().typeName.shouldBe("A") + } } - } - test("custom scalar values use ScalarValueOverrides") { - 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"))) - checkAll(PropTestConfig(iterations = 20), Arb.graphQLOutputValue(schema, scalar, null, config = config)) { - it.shouldBe("custom-value") + @Test + fun `custom scalar values use ScalarValueOverrides`(): Unit = + 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"))) + Arb.graphQLOutputValue(schema, scalar, null, config = config).checkAll(20) { + it.shouldBe("custom-value") + } } - } -}) +} private fun outputSelections(fragment: String): SelectionSet = Parser().parseDocument(fragment) 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 index 2538ba220..83e7964fc 100644 --- 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 @@ -4,37 +4,41 @@ package graphql.schema.property import graphql.ParseAndValidate import graphql.schema.idl.SchemaParser -import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.shouldBe import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.checkAll +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test -class GraphQLTestDataTest : FunSpec({ - test("graphQLTestData generates round-trippable schemas and valid queries") { - val config = Config.default + (SchemaSize to 10) - checkAll(PropTestConfig(iterations = 30), Arb.graphQLTestData(config)) { 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) +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("minimum query length filters generated test data") { - val config = Config.default + - (SchemaSize to 5) + - (MinQueryLength to 10) - checkAll(PropTestConfig(iterations = 10), Arb.graphQLTestData(config)) { data -> - (data.query.length >= 10).shouldBe(true) + @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("GraphQLTestDataStats counts schema and query constructs") { + @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 }", @@ -48,4 +52,4 @@ class GraphQLTestDataTest : FunSpec({ stats.queryStats["external variables"].shouldBe(1) stats.format().lines().shouldContain(" external variables: 1") } -}) +} diff --git a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt index ebf2aa3ad..8b91af894 100644 --- a/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt +++ b/graphql-java-property-testing/src/test/kotlin/graphql/schema/property/SchemaRelationshipsTest.kt @@ -2,12 +2,12 @@ package graphql.schema.property import graphql.introspection.Introspection.DirectiveLocation import graphql.schema.GraphQLCompositeType -import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test -class SchemaRelationshipsTest : FunSpec({ - val schema = parseTestSchema( +class SchemaRelationshipsTest { + private val schema = parseTestSchema( """ directive @fieldDirective on FIELD interface I { x: Int } @@ -18,9 +18,10 @@ class SchemaRelationshipsTest : FunSpec({ type Query { i: I, u: U } """.trimIndent() ) - val relationships = SchemaRelationships(schema) + private val relationships = SchemaRelationships(schema) - test("possibleObjectTypes expands objects, interfaces, and unions") { + @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 @@ -29,7 +30,8 @@ class SchemaRelationshipsTest : FunSpec({ relationships.possibleObjectTypes(objectType).map { it.name }.shouldBe(setOf("A")) } - test("spreadability follows overlap of possible concrete types") { + @Test + fun `spreadability follows overlap of possible concrete types`() { val interfaceType = schema.getType("I") as GraphQLCompositeType val unionType = schema.getType("U") as GraphQLCompositeType val a = schema.getType("A") as GraphQLCompositeType @@ -40,10 +42,11 @@ class SchemaRelationshipsTest : FunSpec({ relationships.spreadableTypes(a).shouldContain(interfaceType) } - test("Schemas indexes directives by valid location") { + @Test + fun `Schemas indexes directives by valid location`() { val schemas = Schemas(schema) schemas.directivesByLocation.getValue(DirectiveLocation.FIELD) .map { it.name } .shouldContain("fieldDirective") } -}) +} From 4acaf70ddc5e52bfeb815486058fbb8e0ab1a44d Mon Sep 17 00:00:00 2001 From: James Bellenger Date: Sun, 9 Aug 2026 10:46:42 -0700 Subject: [PATCH 4/5] 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' From a45f839a47e6aaea5b9bcdd08cb475a023f19ac7 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Mon, 10 Aug 2026 09:44:09 +1000 Subject: [PATCH 5/5] Test graphql-java with arbitrary inputs --- .../GraphQLJavaCoercionPropertyTest.kt | 44 +++++++++++++++ .../GraphQLJavaDocumentPropertyTest.kt | 53 +++++++++++++++++++ .../GraphQLJavaExecutionPropertyTest.kt | 40 ++++++++++++++ .../GraphQLJavaPropertyFixtures.kt | 30 +++++++++++ .../GraphQLJavaSchemaPropertyTest.kt | 44 +++++++++++++++ 5 files changed, 211 insertions(+) create mode 100644 graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaCoercionPropertyTest.kt create mode 100644 graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaDocumentPropertyTest.kt create mode 100644 graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaExecutionPropertyTest.kt create mode 100644 graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaPropertyFixtures.kt create mode 100644 graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaSchemaPropertyTest.kt diff --git a/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaCoercionPropertyTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaCoercionPropertyTest.kt new file mode 100644 index 000000000..facefb462 --- /dev/null +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaCoercionPropertyTest.kt @@ -0,0 +1,44 @@ +package graphql.arbitrary.graphqljava + +import graphql.arbitrary.ArbPropertyBase +import graphql.execution.RawVariables +import graphql.execution.ValuesResolver +import graphql.language.OperationDefinition +import graphql.parser.Parser +import io.kotest.property.Arb +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GraphQLJavaCoercionPropertyTest : ArbPropertyBase(iterations = 100) { + @Test + fun `generated external variables can be coerced`(): Unit = + runBlocking { + Arb.graphQLJavaExecutionCase().checkAll { (schema, input) -> + val document = Parser().parseDocument(input.query) + val operation = selectOperation( + document.getDefinitionsOfType(OperationDefinition::class.java), + input.operationName + ) + val coercedVariables = ValuesResolver.coerceVariableValues( + schema, + operation.variableDefinitions, + RawVariables.of(input.variables), + input.graphQLContext, + input.locale + ) + + assertTrue(coercedVariables.toMap().keys.containsAll(input.variables.keys)) + } + } + + private fun selectOperation( + operations: List, + operationName: String? + ): OperationDefinition { + if (operationName == null) { + return operations.single() + } + return operations.single { it.name == operationName } + } +} diff --git a/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaDocumentPropertyTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaDocumentPropertyTest.kt new file mode 100644 index 000000000..0462e857f --- /dev/null +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaDocumentPropertyTest.kt @@ -0,0 +1,53 @@ +package graphql.arbitrary.graphqljava + +import graphql.ExecutionInput +import graphql.ParseAndValidate +import graphql.arbitrary.ArbPropertyBase +import graphql.arbitrary.graphQLDocument +import graphql.arbitrary.graphQLSchema +import graphql.language.AstComparator +import graphql.language.AstPrinter +import graphql.parser.Parser +import io.kotest.property.Arb +import io.kotest.property.arbitrary.flatMap +import io.kotest.property.arbitrary.map +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GraphQLJavaDocumentPropertyTest : ArbPropertyBase(iterations = 100) { + @Test + fun `document printer output parses to an equivalent AST`(): Unit = + runBlocking { + Arb.graphQLSchema(graphQLJavaPropertyConfig) + .flatMap { schema -> + Arb.graphQLDocument(schema, graphQLJavaPropertyConfig) + }.checkAll { document -> + val printedDocument = AstPrinter.printAst(document) + val parsedDocument = Parser().parseDocument(printedDocument) + + assertTrue(AstComparator.isEqual(document, parsedDocument)) { + printedDocument + } + } + } + + @Test + fun `generated documents pass graphql-java parsing and validation`(): Unit = + runBlocking { + Arb.graphQLSchema(graphQLJavaPropertyConfig) + .flatMap { schema -> + Arb.graphQLDocument(schema, graphQLJavaPropertyConfig).map { document -> + schema to AstPrinter.printAst(document) + } + }.checkAll { (schema, query) -> + val input = ExecutionInput.newExecutionInput(query).build() + val result = ParseAndValidate.parseAndValidate(schema, input) + + assertFalse(result.isFailure) { + result.errors.joinToString("\n") + } + } + } +} diff --git a/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaExecutionPropertyTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaExecutionPropertyTest.kt new file mode 100644 index 000000000..baf56ac92 --- /dev/null +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaExecutionPropertyTest.kt @@ -0,0 +1,40 @@ +package graphql.arbitrary.graphqljava + +import graphql.arbitrary.ArbPropertyBase +import graphql.arbitrary.arbGraphQL +import io.kotest.property.Arb +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GraphQLJavaExecutionPropertyTest : ArbPropertyBase(iterations = 100) { + @Test + fun `generated execution inputs execute without errors`(): Unit = + runBlocking { + Arb.graphQLJavaExecutionCase().checkAll { (schema, input) -> + val result = arbGraphQL(schema, seed, graphQLJavaPropertyConfig).execute(input) + + assertTrue(result.errors.isEmpty()) { + buildString { + appendLine(result.errors.joinToString("\n")) + appendLine(input.query) + appendLine("operationName=${input.operationName}") + append("variables=${input.variables}") + } + } + } + } + + @Test + fun `repeated execution produces the same result`(): Unit = + runBlocking { + Arb.graphQLJavaExecutionCase().checkAll { (schema, input) -> + val graphQL = arbGraphQL(schema, seed, graphQLJavaPropertyConfig) + val firstResult = graphQL.execute(input).toSpecification() + val secondResult = graphQL.execute(input).toSpecification() + + assertEquals(firstResult, secondResult) + } + } +} diff --git a/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaPropertyFixtures.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaPropertyFixtures.kt new file mode 100644 index 000000000..ac7a6d413 --- /dev/null +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaPropertyFixtures.kt @@ -0,0 +1,30 @@ +package graphql.arbitrary.graphqljava + +import graphql.ExecutionInput +import graphql.arbitrary.Config +import graphql.arbitrary.GenInterfaceStubsIfNeeded +import graphql.arbitrary.MaxSelectionSetDepth +import graphql.arbitrary.NullNonNullableWeight +import graphql.arbitrary.ResolverExceptionWeight +import graphql.arbitrary.SchemaSize +import graphql.arbitrary.graphQLExecutionInput +import graphql.arbitrary.graphQLSchema +import graphql.schema.GraphQLSchema +import io.kotest.property.Arb +import io.kotest.property.arbitrary.flatMap +import io.kotest.property.arbitrary.map + +internal val graphQLJavaPropertyConfig = Config( + SchemaSize(15), + GenInterfaceStubsIfNeeded(true), + MaxSelectionSetDepth(5), + NullNonNullableWeight(0.0), + ResolverExceptionWeight(0.0) +) + +internal fun Arb.Companion.graphQLJavaExecutionCase(): Arb> = + graphQLSchema(graphQLJavaPropertyConfig).flatMap { schema -> + graphQLExecutionInput(schema, graphQLJavaPropertyConfig).map { input -> + schema to input + } + } diff --git a/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaSchemaPropertyTest.kt b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaSchemaPropertyTest.kt new file mode 100644 index 000000000..1668dd3db --- /dev/null +++ b/graphql-java-arbitrary/src/test/kotlin/graphql/arbitrary/graphqljava/GraphQLJavaSchemaPropertyTest.kt @@ -0,0 +1,44 @@ +package graphql.arbitrary.graphqljava + +import graphql.arbitrary.ArbPropertyBase +import graphql.arbitrary.arbGraphQL +import graphql.arbitrary.graphQLSchema +import graphql.introspection.IntrospectionQuery +import graphql.schema.idl.SchemaParser +import graphql.schema.idl.SchemaPrinter +import graphql.schema.idl.UnExecutableSchemaGenerator +import io.kotest.property.Arb +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GraphQLJavaSchemaPropertyTest : ArbPropertyBase(iterations = 100) { + @Test + fun `schema printer output can be parsed and rebuilt`(): Unit = + runBlocking { + val printer = SchemaPrinter() + Arb.graphQLSchema(graphQLJavaPropertyConfig).checkAll { schema -> + val printedSchema = printer.print(schema) + val typeRegistry = SchemaParser().parse(printedSchema) + val rebuiltSchema = UnExecutableSchemaGenerator.makeUnExecutableSchema(typeRegistry) + + assertEquals(printedSchema, printer.print(rebuiltSchema)) + } + } + + @Test + fun `generated schemas support introspection`(): Unit = + runBlocking { + Arb.graphQLSchema(graphQLJavaPropertyConfig).checkAll { schema -> + val result = arbGraphQL(schema, seed, graphQLJavaPropertyConfig) + .execute(IntrospectionQuery.INTROSPECTION_QUERY) + + assertTrue(result.errors.isEmpty()) { + result.errors.joinToString("\n") + } + val data = requireNotNull(result.getData>()) + assertTrue(data.containsKey("__schema")) + } + } +}