diff --git a/modules/core/src/test/scala/conformance/ConformanceSuite.scala b/modules/core/src/test/scala/conformance/ConformanceSuite.scala new file mode 100644 index 00000000..5459db05 --- /dev/null +++ b/modules/core/src/test/scala/conformance/ConformanceSuite.scala @@ -0,0 +1,190 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import cats.effect.IO +import io.circe.Json +import munit.{CatsEffectSuite, Location, TestOptions} + +import grackle._ +import grackle.QueryCompiler.SelectElaborator + +/** + * Base class for the GraphQL conformance suites. + * + * Each suite covers one subject of the September 2025 specification. Each test case corresponds + * to one example or one counter-example in the specification text. + * + * A Scala triple-quoted string cannot hold a GraphQL block string delimiter. Write `'''` in a + * document instead. Every helper below replaces that marker with three double quotes. + * + * @see + * https://spec.graphql.org/September2025/ + */ +abstract protected[conformance] class ConformanceSuite extends CatsEffectSuite { + import ConformanceSuite._ + + /** + * The schema which the query test cases of this suite run against. + * + * Override this in a suite which tests queries. A suite which tests documents or schemas only + * can leave the default in place. + */ + lazy val defaultSchema: Schema = mkSchema("type Query { placeholder: Boolean }") + + // -- Documents ------------------------------------------------------------------------------- + + /** + * Registers a test case which requires that `doc` parses. + * + * Use this for an example which no schema in the specification covers. + */ + def parses(name: TestOptions)(doc: String)(implicit loc: Location): Unit = + test(name) { + val res = graphQLParser.parseText(gql(doc)) + assert(res.hasValue, problems("the document did not parse", res)) + } + + // -- Schemas --------------------------------------------------------------------------------- + + /** + * Registers a test case which requires that `text` is a valid schema. + */ + def validSchema(name: TestOptions)(text: String)(implicit loc: Location): Unit = + test(name) { + val res = Schema(gql(text)) + assert(res.hasValue, problems("the schema was rejected", res)) + } + + /** + * Registers a test case which requires that `text` is not a valid schema. + */ + def invalidSchema(name: TestOptions)(text: String)(implicit loc: Location): Unit = + test(name) { + val res = Schema(gql(text)) + assert(!res.hasValue, "the schema was accepted, but the specification forbids it") + } + + // -- Queries --------------------------------------------------------------------------------- + + /** + * Registers a test case which requires that every operation in `query` compiles against + * `schema`. + * + * `schema` defaults to [[defaultSchema]]. Supply `vars` when an operation declares a + * non-nullable variable, because variable coercion runs before the query is complete. + */ + def validQuery( + name: TestOptions, + schema: => Schema = defaultSchema, + vars: Json = Json.obj() + )(query: String)(implicit loc: Location): Unit = + test(name) { + val res = compileDocument(schema, query, vars) + assert(res.hasValue, problems("the document was rejected", res)) + } + + /** + * Registers a test case which requires that `query` does not compile against `schema`. + * + * A document with more than one operation is rejected when any one of its operations is + * rejected. A counter-example which the specification writes as several operations or several + * fragments therefore needs one test case per operation or per fragment. One test case for + * the whole block passes while one sub-case fails, which hides the state of every other + * sub-case. + */ + def invalidQuery( + name: TestOptions, + schema: => Schema = defaultSchema, + vars: Json = Json.obj() + )(query: String)(implicit loc: Location): Unit = + test(name) { + val res = compileDocument(schema, query, vars) + assert(!res.hasValue, "the document compiled, but the specification forbids it") + } + + // -- Responses ------------------------------------------------------------------------------- + + /** + * Registers a test case which requires that `query` yields `expected` when it runs against + * `mapping`. + * + * Use this for an example whose section of the specification also states the response. + */ + def yields(name: TestOptions, mapping: => Mapping[IO], vars: Json = Json.obj())( + query: String)(expected: Json)(implicit loc: Location): Unit = + test(name) { + assertIO(mapping.compileAndRun(gql(query), untypedVars = Some(vars)), expected) + } + + /** + * Registers a test case which requires that the `data` entry of the response holds the + * response keys `expected`, in that order. + * + * Two JSON objects which hold the same entries in a different order are equal, so [[yields]] + * cannot observe the field order. Section 3.6, Field Ordering, states an order, so the test + * cases for that subject compare the keys as a list. + */ + def yieldsFieldOrder(name: TestOptions, mapping: => Mapping[IO], vars: Json = Json.obj())( + query: String)(expected: List[String])(implicit loc: Location): Unit = + test(name) { + val keys = + mapping + .compileAndRun(gql(query), untypedVars = Some(vars)) + .map(_.hcursor.downField("data").keys.map(_.toList)) + assertIO(keys, Some(expected)) + } + + /** + * Compiles every operation of `text` against `schema`. + */ + private def compileDocument(schema: Schema, text0: String, vars: Json): Result[Operation] = { + val text = gql(text0) + val compiler = new QueryCompiler(queryParser, schema, List(SelectElaborator.identity)) + val name = queryParser.parseText(text).toOption.flatMap(_._1.flatMap(_.name).headOption) + compiler.compile(text, name = name, untypedVars = Some(vars)) + } +} + +object ConformanceSuite { + val graphQLParser: GraphQLParser = GraphQLParser(GraphQLParser.defaultConfig) + val queryParser: QueryParser = QueryParser(graphQLParser) + + /** + * Builds a schema from `text`, or throws when `text` is not a valid schema. + */ + def mkSchema(text: String): Schema = + Schema(gql(text)) match { + case Result.Success(s) => s + case Result.Warning(_, s) => s + case other => throw new IllegalArgumentException(problems("invalid test schema", other)) + } + + /** + * Replaces each `'''` marker in `text` with a GraphQL block string delimiter. + * + * A Scala triple-quoted string cannot hold a GraphQL block string delimiter, so a test case + * writes `'''` where the specification writes three double quotes. + */ + def gql(text: String): String = + text.replace("'''", "\"\"\"") + + private[conformance] def problems(prefix: String, res: Result[Any]): String = + res.toProblems.toList match { + case Nil => prefix + case ps => ps.mkString(prefix + ": ", "; ", "") + } +} diff --git a/modules/core/src/test/scala/conformance/ExecutionMappings.scala b/modules/core/src/test/scala/conformance/ExecutionMappings.scala new file mode 100644 index 00000000..9a31a7c5 --- /dev/null +++ b/modules/core/src/test/scala/conformance/ExecutionMappings.scala @@ -0,0 +1,244 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import cats.effect.{IO, Ref} +import cats.implicits._ +import fs2.Stream + +import grackle._ +import grackle.Query.Binding +import grackle.QueryCompiler._ +import grackle.Value.IntValue +import grackle.syntax._ + +/** + * Mappings for the examples of section 6, Execution. + * + * The specification names the types and the fields of these examples in its prose. It supplies + * no data, so each mapping here chooses values which the response assertions then quote. + * + * @see + * https://spec.graphql.org/September2025/#sec-Execution + */ +object ExecutionMappings { + + val CollectionSchema: Schema = + schema""" + type Query { a: A b: String } + type A { subfield1: String subfield2: String } + """ + + val PersonSchema: Schema = + schema""" + type Query { birthday: Birthday address: Address } + type Mutation { + changeBirthday(birthday: String!): Birthday + changeAddress(address: String!): Address + } + type Birthday { month: String } + type Address { street: String } + """ + + val NumbersSchema: Schema = + schema""" + type Query { theNumber: Int! } + type Mutation { changeTheNumber(newNumber: Int!): NumberHolder! } + type NumberHolder { theNumber: Int! } + """ + + /** + * The chat application of section 6.2.3, Subscription. + * + * The specification states the sender and the text of the published message. + */ + object Chat extends ValueMapping[IO] { + case class Message(sender: String, text: String) + + val messages: Map[Int, List[Message]] = + Map(123 -> List(Message("Hagrid", "You're a wizard!"))) + + val schema = + schema""" + type Query { placeholder: Boolean } + type Subscription { newMessage(roomId: Int!): Message! } + type Message { sender: String! text: String! } + """ + + val QueryType = schema.ref("Query") + val SubscriptionType = schema.ref("Subscription") + val MessageType = schema.ref("Message") + + val typeMappings = + List( + ValueObjectMapping[Unit]( + tpe = QueryType, + fieldMappings = List(ValueField("placeholder", _ => Some(true)))), + ObjectMapping( + SubscriptionType, + List( + RootStream.computeCursor("newMessage")((path, env) => + Stream + .emits(env.get[Int]("roomId").toList.flatMap(messages.getOrElse(_, Nil))) + .map(m => Result(valueCursor(path, env, m)))) + ) + ), + ValueObjectMapping[Message]( + tpe = MessageType, + fieldMappings = List(ValueField("sender", _.sender), ValueField("text", _.text))) + ) + + override val selectElaborator: SelectElaborator = + SelectElaborator { + case (SubscriptionType, "newMessage", List(Binding("roomId", IntValue(n)))) => + Elab.env("roomId" -> n) + } + } + + /** + * The two fields of section 6.3.2, Field Collection. + * + * The specification collects two instances of the field `a` and one of the field `b`. The + * response holds one entry for `a`, with the subfields of both instances. + * + * The field `a` counts its own resolutions in `resolutions`, so a test case can read how many + * times the executor resolved it. + */ + final class Collection(resolutions: Ref[IO, Int]) extends ValueMapping[IO] { + case class A(subfield1: String, subfield2: String) + + val schema = CollectionSchema + + val QueryType = schema.ref("Query") + val AType = schema.ref("A") + + val typeMappings = + List( + ObjectMapping( + tpe = QueryType, + fieldMappings = List( + RootEffect.computeCursor("a")((path, env) => + resolutions + .update(_ + 1) + .as(Result(valueCursor(path, env, Some(A("one", "two")))))), + ValueField[Unit]("b", _ => Some("three")) + ) + ), + ValueObjectMapping[A]( + tpe = AType, + fieldMappings = List( + ValueField("subfield1", a => Some(a.subfield1)), + ValueField("subfield2", a => Some(a.subfield2)))) + ) + } + + /** + * The person of section 6.3.4, Normal and Serial Execution. + * + * `changeBirthday` and `changeAddress` both write to `state`, so a test case can read the + * order in which the executor ran them. + */ + final class Person(state: Ref[IO, List[String]]) extends ValueMapping[IO] { + case class Birthday(month: String) + case class Address(street: String) + + val schema = PersonSchema + + val QueryType = schema.ref("Query") + val MutationType = schema.ref("Mutation") + val BirthdayType = schema.ref("Birthday") + val AddressType = schema.ref("Address") + + val typeMappings = + List( + ValueObjectMapping[Unit]( + tpe = QueryType, + fieldMappings = List( + ValueField("birthday", _ => Some(Birthday("January"))), + ValueField("address", _ => Some(Address("Main Street"))) + )), + ObjectMapping( + MutationType, + List( + RootEffect.computeCursor("changeBirthday")((path, env) => + record(env, "changeBirthday") + .map(v => Result(valueCursor(path, env, Some(Birthday(v)))))), + RootEffect.computeCursor("changeAddress")((path, env) => + record(env, "changeAddress").map(v => + Result(valueCursor(path, env, Some(Address(v)))))) + ) + ), + ValueObjectMapping[Birthday]( + tpe = BirthdayType, + fieldMappings = List(ValueField("month", b => Some(b.month)))), + ValueObjectMapping[Address]( + tpe = AddressType, + fieldMappings = List(ValueField("street", a => Some(a.street)))) + ) + + override val selectElaborator: SelectElaborator = + SelectElaborator { + case (MutationType, "changeBirthday" | "changeAddress", List(Binding(_, arg))) => + Elab.env("arg" -> arg) + } + + private def record(env: Env, fieldName: String): IO[String] = { + val value = env.get[Value]("arg").collect { case Value.StringValue(s) => s }.orEmpty + state.update(_ :+ fieldName).as(value) + } + } + + /** + * The number holder of section 6.3.4, Normal and Serial Execution. + * + * `changeTheNumber` appends its argument to `log`, so a test case can read the order in which + * the executor ran the three aliases of the mutation. + */ + final class Numbers(log: Ref[IO, List[Int]]) extends ValueMapping[IO] { + case class NumberHolder(theNumber: Int) + + val schema = NumbersSchema + + val QueryType = schema.ref("Query") + val MutationType = schema.ref("Mutation") + val NumberHolderType = schema.ref("NumberHolder") + + val typeMappings = + List( + ValueObjectMapping[Unit]( + tpe = QueryType, + fieldMappings = List(ValueField("theNumber", _ => 0))), + ObjectMapping( + MutationType, + List( + RootEffect.computeCursor("changeTheNumber")((path, env) => { + val n = env.get[Int]("newNumber").getOrElse(0) + log.update(_ :+ n).as(Result(valueCursor(path, env, NumberHolder(n)))) + }) + ) + ), + ValueObjectMapping[NumberHolder]( + tpe = NumberHolderType, + fieldMappings = List(ValueField("theNumber", _.theNumber))) + ) + + override val selectElaborator: SelectElaborator = + SelectElaborator { + case (MutationType, "changeTheNumber", List(Binding("newNumber", IntValue(n)))) => + Elab.env("newNumber" -> n) + } + } +} diff --git a/modules/core/src/test/scala/conformance/ExecutionSuite.scala b/modules/core/src/test/scala/conformance/ExecutionSuite.scala new file mode 100644 index 00000000..66b9e731 --- /dev/null +++ b/modules/core/src/test/scala/conformance/ExecutionSuite.scala @@ -0,0 +1,247 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import cats.effect.{IO, Ref} +import io.circe.literal._ + +/** + * Conformance test cases for section 6, Execution. + * + * Each test case runs its document against a mapping in [[ExecutionMappings]] and asserts on + * the response. Where the specification states the response, the assertion quotes it. Where the + * specification states the outcome in prose, the assertion follows that prose. + * + * @see + * https://spec.graphql.org/September2025/#sec-Execution + */ +final class ExecutionSuite extends ConformanceSuite { + + // 6.2.3 Subscription + // https://spec.graphql.org/September2025/#sec-Subscription + + test("a subscription publishes one response per event") { + val responses = + ExecutionMappings + .Chat + .compileAndRunSubscription(""" + subscription NewMessages { + newMessage(roomId: 123) { + sender + text + } + } + """) + .compile + .toList + + assertIO( + responses, + List(json""" + { + "data": { + "newMessage": { + "sender": "Hagrid", + "text": "You're a wizard!" + } + } + } + """) + ) + } + + // 6.3.2 Field Collection + // https://spec.graphql.org/September2025/#sec-Field-Collection + + /** + * The document of section 6.3.2, which the specification states twice to state two outcomes. + */ + private val fieldCollectionDoc = """ + { + a { + subfield1 + } + ...ExampleFragment + } + + fragment ExampleFragment on Query { + a { + subfield2 + } + b + } + """ + + // The specification states the outcome in prose: field collection yields two entries, `a` and + // `b`, and the field set for `a` holds both instances of the field. + test("field collection yields one entry per response name") { + val prog = + for { + resolutions <- Ref[IO].of(0) + res <- new ExecutionMappings.Collection(resolutions).compileAndRun(fieldCollectionDoc) + } yield res + + assertIO( + prog, + json""" + { + "data": { + "a": { + "subfield1": "one", + "subfield2": "two" + }, + "b": "three" + } + } + """ + ) + } + + // The specification repeats the document above to state a second outcome: after the executor + // resolves `a`, it merges the two selection sets, so `subfield1` and `subfield2` resolve in + // the same phase against the same value. The response alone cannot show that outcome, so this + // test case counts how many times the executor resolved `a`. + test("the sub-selections of one response name merge into one phase") { + val prog = + for { + resolutions <- Ref[IO].of(0) + _ <- new ExecutionMappings.Collection(resolutions).compileAndRun(fieldCollectionDoc) + count <- resolutions.get + } yield count + + assertIO(prog, 1) + } + + // 6.3.4 Normal and Serial Execution + // https://spec.graphql.org/September2025/#sec-Normal-and-Serial-Execution + + test("the root fields of a query run in any order, and the response holds both") { + val prog = + for { + state <- Ref[IO].of(List.empty[String]) + res <- new ExecutionMappings.Person(state).compileAndRun(""" + { + birthday { + month + } + address { + street + } + } + """) + } yield res + + assertIO( + prog, + json""" + { + "data": { + "birthday": { + "month": "January" + }, + "address": { + "street": "Main Street" + } + } + } + """ + ) + } + + test("the root fields of a mutation run in serial, in document order") { + val prog = + for { + state <- Ref[IO].of(List.empty[String]) + res <- new ExecutionMappings.Person(state).compileAndRun( + """ + mutation ChangeBirthdayAndAddress($newBirthday: String!, $newAddress: String!) { + changeBirthday(birthday: $newBirthday) { + month + } + changeAddress(address: $newAddress) { + street + } + } + """, + untypedVars = Some(json"""{"newBirthday": "January", "newAddress": "Main Street"}""") + ) + order <- state.get + } yield (res, order) + + assertIO( + prog, + ( + json""" + { + "data": { + "changeBirthday": { + "month": "January" + }, + "changeAddress": { + "street": "Main Street" + } + } + } + """, + List("changeBirthday", "changeAddress")) + ) + } + + // The specification marks this block as a selection set of a mutation, not as a document. This + // test case wraps it in `mutation { ... }`, which is the smallest document which holds it. The + // expected data is the response which the specification states. + test("aliases let a mutation call one field more than once, in order") { + val prog = + for { + log <- Ref[IO].of(List.empty[Int]) + res <- new ExecutionMappings.Numbers(log).compileAndRun(""" + mutation { + first: changeTheNumber(newNumber: 1) { + theNumber + } + second: changeTheNumber(newNumber: 3) { + theNumber + } + third: changeTheNumber(newNumber: 2) { + theNumber + } + } + """) + order <- log.get + } yield (res, order) + + assertIO( + prog, + ( + json""" + { + "data": { + "first": { + "theNumber": 1 + }, + "second": { + "theNumber": 3 + }, + "third": { + "theNumber": 2 + } + } + } + """, + List(1, 3, 2)) + ) + } +} diff --git a/modules/core/src/test/scala/conformance/IntrospectionMappings.scala b/modules/core/src/test/scala/conformance/IntrospectionMappings.scala new file mode 100644 index 00000000..53d303bf --- /dev/null +++ b/modules/core/src/test/scala/conformance/IntrospectionMappings.scala @@ -0,0 +1,69 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import cats.effect.IO + +import grackle._ +import grackle.syntax._ + +/** + * The mapping for the example of section 4, Introspection. + * + * The specification states the response of one introspection request against the type `User`. + * The mapping holds no data, because an introspection request reads the schema only. + * + * @see + * https://spec.graphql.org/September2025/#sec-Introspection + */ +object IntrospectionMappings { + + case class User(id: String, name: String, birthday: String) + + object Site extends ValueMapping[IO] { + val schema = + schema""" + scalar Date + type User { + id: String + name: String + birthday: Date + } + + # Added to complete the example: a query root type. + type Query { user: User } + """ + + val QueryType = schema.ref("Query") + val UserType = schema.ref("User") + val DateType = schema.ref("Date") + + val typeMappings = + List( + ValueObjectMapping[Unit]( + tpe = QueryType, + fieldMappings = List(ValueField("user", _ => Option.empty[User]))), + ValueObjectMapping[User]( + tpe = UserType, + fieldMappings = List( + ValueField("id", u => Some(u.id)), + ValueField("name", u => Some(u.name)), + ValueField("birthday", u => Some(u.birthday)) + )), + LeafMapping[String](DateType) + ) + } +} diff --git a/modules/core/src/test/scala/conformance/IntrospectionSuite.scala b/modules/core/src/test/scala/conformance/IntrospectionSuite.scala new file mode 100644 index 00000000..01b4574a --- /dev/null +++ b/modules/core/src/test/scala/conformance/IntrospectionSuite.scala @@ -0,0 +1,91 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import io.circe.literal._ + +/** + * Conformance test cases for section 4, Introspection. + * + * @see + * https://spec.graphql.org/September2025/#sec-Introspection + */ +final class IntrospectionSuite extends ConformanceSuite { + + // 4 Introspection + // https://spec.graphql.org/September2025/#sec-Introspection + + validSchema("an object type which introspection can describe")(""" + type User { + id: String + name: String + birthday: Date + } + + # Added to complete the example: the `Date` scalar and a query root type. + scalar Date + type Query { user: User } + """) + + yields("the __type meta-field describes a named type", IntrospectionMappings.Site)(""" + { + __type(name: "User") { + name + fields { + name + type { + name + } + } + } + } + """)(json""" + { + "data": { + "__type": { + "name": "User", + "fields": [ + { + "name": "id", + "type": { "name": "String" } + }, + { + "name": "name", + "type": { "name": "String" } + }, + { + "name": "birthday", + "type": { "name": "Date" } + } + ] + } + } + } + """) + + // 4.2.2 The __Type Type + // https://spec.graphql.org/September2025/#sec-The-__Type-Type + + validSchema("an input object type which introspection can describe")(""" + input Point { + x: Int + y: Int + } + + # Added to complete the example: a query root type. + type Query { nearest(point: Point): String } + """) +} diff --git a/modules/core/src/test/scala/conformance/LanguageMappings.scala b/modules/core/src/test/scala/conformance/LanguageMappings.scala new file mode 100644 index 00000000..eb63a7c9 --- /dev/null +++ b/modules/core/src/test/scala/conformance/LanguageMappings.scala @@ -0,0 +1,142 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import cats.effect.IO + +import grackle._ +import grackle.Predicate.{Const, Eql, In} +import grackle.Query.{Binding, Filter, Unique} +import grackle.QueryCompiler._ +import grackle.Value.{IntValue, ListValue, StringValue} +import grackle.syntax._ + +/** + * The mapping for the examples of section 1, Overview, and section 2, Language. + * + * The specification defines no schema for these examples. The schema here holds the types and + * the fields which the examples select, and the data holds the values which the specification + * states in its response examples. + * + * @see + * https://spec.graphql.org/September2025/#sec-Language + */ +object LanguageMappings { + + sealed trait Profile { + def handle: String + } + + case class Connection(count: Int) + + case class User(id: Int, name: String, handle: String, friends: Connection) extends Profile + + case class Page(handle: String, likers: Connection) extends Profile + + /** + * The two profiles which the examples name, in the order of the example of section 2.9.1. + */ + val profiles: List[Profile] = + List( + User(4, "Mark Zuckerberg", "zuck", Connection(1234)), + Page("coca-cola", Connection(90234512)) + ) + + val users: List[User] = profiles.collect { case u: User => u } + + /** + * The picture of the user `id` at `size`, in the form which section 2.8 states. + */ + def profilePic(id: Int, size: Int): String = + s"https://cdn.site.io/pic-$id-$size.jpg" + + object Site extends ValueMapping[IO] { + val schema = + schema""" + type Query { + user(id: Int!): User + profiles(handles: [String!]!): [Profile!]! + } + interface Profile { + handle: String! + } + type User implements Profile { + id: Int! + name: String! + handle: String! + profilePic(size: Int): String! + friends: Connection! + } + type Page implements Profile { + handle: String! + likers: Connection! + } + type Connection { + count: Int! + } + """ + + val QueryType = schema.ref("Query") + val ProfileType = schema.ref("Profile") + val UserType = schema.ref("User") + val PageType = schema.ref("Page") + val ConnectionType = schema.ref("Connection") + + val typeMappings = + List( + ValueObjectMapping[Unit]( + tpe = QueryType, + fieldMappings = List( + ValueField("user", _ => users), + ValueField("profiles", _ => profiles) + )), + ValueObjectMapping[Profile]( + tpe = ProfileType, + fieldMappings = List(ValueField("handle", _.handle))), + ValueObjectMapping[User]( + tpe = UserType, + fieldMappings = List( + ValueField("id", _.id), + ValueField("name", _.name), + ValueField("friends", _.friends), + CursorField("profilePic", picture) + )), + ValueObjectMapping[Page]( + tpe = PageType, + fieldMappings = List(ValueField("likers", _.likers))), + ValueObjectMapping[Connection]( + tpe = ConnectionType, + fieldMappings = List(ValueField("count", _.count))) + ) + + override val selectElaborator: SelectElaborator = + SelectElaborator { + case (QueryType, "user", List(Binding("id", IntValue(id)))) => + Elab.transformChild(child => Unique(Filter(Eql(UserType / "id", Const(id)), child))) + case (QueryType, "profiles", List(Binding("handles", ListValue(handles)))) => + val hs = handles.collect { case StringValue(h) => h } + Elab.transformChild(child => Filter(In(ProfileType / "handle", hs), child)) + case (UserType, "profilePic", List(Binding("size", IntValue(size)))) => + Elab.env("size" -> size) + } + + private def picture(c: Cursor): Result[String] = + for { + user <- c.as[User] + size <- c.envR[Int]("size") + } yield profilePic(user.id, size) + } +} diff --git a/modules/core/src/test/scala/conformance/LanguageSuite.scala b/modules/core/src/test/scala/conformance/LanguageSuite.scala new file mode 100644 index 00000000..ae8fa759 --- /dev/null +++ b/modules/core/src/test/scala/conformance/LanguageSuite.scala @@ -0,0 +1,480 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import conformance.LanguageMappings.Site +import io.circe.literal._ + +/** + * Conformance test cases for section 2, Language. + * + * The specification defines no schema for these examples, so most test cases check the grammar + * only. Where the specification states a response, the test case runs the document against + * [[LanguageMappings.Site]] and asserts that response. + * + * @see + * https://spec.graphql.org/September2025/#sec-Language + */ +final class LanguageSuite extends ConformanceSuite { + + // 1 Overview + // https://spec.graphql.org/September2025/#sec-Overview + + yields("a request selects a field of an object and a field of that object", Site)(""" + { + user(id: 4) { + name + } + } + """)(json""" + { + "data": { + "user": { + "name": "Mark Zuckerberg" + } + } + } + """) + + // 2.2 Descriptions + // https://spec.graphql.org/September2025/#sec-Descriptions + + parses("an operation, a variable and a fragment can carry a description".fail)(""" + ''' + Request the current status of a time machine and its operator. + You can also check the status for a particular year. + **Warning:** certain years may trigger an anomaly in the space-time continuum. + ''' + query GetTimeMachineStatus( + "The unique serial number of the time machine to inspect." + $machineId: ID! + "The year to check the status for." + $year: Int + ) { + timeMachine(id: $machineId) { + ...TimeMachineDetails + status(year: $year) + } + } + + "Details about a time machine and its operator." + fragment TimeMachineDetails on TimeMachine { + id + model + lastMaintenance + operator { + name + licenseLevel + } + } + """) + + // 2.4 Operations + // https://spec.graphql.org/September2025/#sec-Language.Operations + + parses("a mutation operation can carry a description".fail)(""" + ''' + Mark story 12345 as "liked" + and return the updated number of likes on the story + ''' + mutation { + likeStory(storyID: 12345) { + story { + likeCount + } + } + } + """) + + parses("a query with no name and no variable definitions can use the shorthand form")(""" + { + field + } + """) + + // 2.5 Selection Sets + // https://spec.graphql.org/September2025/#sec-Selection-Sets + + parses("a selection set requests a set of information")(""" + { + id + firstName + lastName + } + """) + + // 2.6 Fields + // https://spec.graphql.org/September2025/#sec-Language.Fields + + parses("a field can select a nested selection set")(""" + { + me { + id + firstName + lastName + birthday { + month + day + } + friends { + name + } + } + } + """) + + parses("a comment runs to the end of the line")(""" + # `me` could represent the currently logged in viewer. + { + me { + name + } + } + """) + + parses("a field can take an argument")(""" + # `user` represents one of many users in a graph of data, referred to by a + # unique identifier. + { + user(id: 4) { + name + } + } + """) + + // 2.7 Arguments + // https://spec.graphql.org/September2025/#sec-Language.Arguments + + parses("an argument names a value")(""" + { + user(id: 4) { + id + name + profilePic(size: 100) + } + } + """) + + parses("a field can take more than one argument")(""" + { + user(id: 4) { + id + name + profilePic(width: 100, height: 50) + } + } + """) + + parses("arguments in one order")(""" + { + picture(width: 200, height: 100) + } + """) + + parses("the same arguments in the reverse order, which is equivalent")(""" + { + picture(height: 100, width: 200) + } + """) + + // 2.8 Field Alias + // https://spec.graphql.org/September2025/#sec-Field-Alias + + yields("an alias renames the response key of a field", Site)(""" + { + user(id: 4) { + id + name + smallPic: profilePic(size: 64) + bigPic: profilePic(size: 1024) + } + } + """)(json""" + { + "data": { + "user": { + "id": 4, + "name": "Mark Zuckerberg", + "smallPic": "https://cdn.site.io/pic-4-64.jpg", + "bigPic": "https://cdn.site.io/pic-4-1024.jpg" + } + } + } + """) + + yields("an alias applies to a top level field", Site)(""" + { + zuck: user(id: 4) { + id + name + } + } + """)(json""" + { + "data": { + "zuck": { + "id": 4, + "name": "Mark Zuckerberg" + } + } + } + """) + + // 2.9 Fragments + // https://spec.graphql.org/September2025/#sec-Language.Fragments + + parses("a query which repeats a selection set")(""" + query noFragments { + user(id: 4) { + friends(first: 10) { + id + name + profilePic(size: 50) + } + mutualFriends(first: 10) { + id + name + profilePic(size: 50) + } + } + } + """) + + parses("a fragment factors out a repeated selection set".fail)(""" + query withFragments { + user(id: 4) { + friends(first: 10) { + ...friendFields + } + mutualFriends(first: 10) { + ...friendFields + } + } + } + + "Common fields for a user's friends." + fragment friendFields on User { + id + name + profilePic(size: 50) + } + """) + + parses("a fragment can spread another fragment")(""" + query withNestedFragments { + user(id: 4) { + friends(first: 10) { + ...friendFields + } + mutualFriends(first: 10) { + ...friendFields + } + } + } + + fragment friendFields on User { + id + name + ...standardProfilePic + } + + fragment standardProfilePic on User { + profilePic(size: 50) + } + """) + + // 2.9.1 Type Conditions + // https://spec.graphql.org/September2025/#sec-Type-Conditions + + yields("a fragment declares the type it applies to", Site)(""" + query FragmentTyping { + profiles(handles: ["zuck", "coca-cola"]) { + handle + ...userFragment + ...pageFragment + } + } + + fragment userFragment on User { + friends { + count + } + } + + fragment pageFragment on Page { + likers { + count + } + } + """)(json""" + { + "data": { + "profiles": [ + { + "handle": "zuck", + "friends": { "count": 1234 } + }, + { + "handle": "coca-cola", + "likers": { "count": 90234512 } + } + ] + } + } + """) + + // 2.9.2 Inline Fragments + // https://spec.graphql.org/September2025/#sec-Inline-Fragments + + parses("an inline fragment applies a type condition without a fragment definition")(""" + query inlineFragmentTyping { + profiles(handles: ["zuck", "coca-cola"]) { + handle + ... on User { + friends { + count + } + } + ... on Page { + likers { + count + } + } + } + } + """) + + parses("an inline fragment can omit the type condition and carry a directive")(""" + query inlineFragmentNoType($expandedInfo: Boolean) { + user(handle: "zuck") { + id + name + ... @include(if: $expandedInfo) { + firstName + lastName + birthday + } + } + } + """) + + // 2.10.4 String Value + // https://spec.graphql.org/September2025/#sec-String-Value + + parses("a block string spans lines and strips the common indentation")(""" + mutation { + sendEmail(message: ''' + Hello, + World! + + Yours, + GraphQL. + ''') + } + """) + + parses("the same value written as a single line string")(""" + mutation { + sendEmail(message: "Hello,\n World!\n\nYours,\n GraphQL.") + } + """) + + // 2.10.5 Null Value + // https://spec.graphql.org/September2025/#sec-Null-Value + + parses("an explicit null argument differs from an absent argument")(""" + { + field(arg: null) + field + } + """) + + // 2.10.8 Input Object Values + // https://spec.graphql.org/September2025/#sec-Input-Object-Values + + parses("input object fields in one order")(""" + { + nearestThing(location: { lon: 12.43, lat: -53.211 }) + } + """) + + parses("the same input object fields in the reverse order, which is equivalent")(""" + { + nearestThing(location: { lat: -53.211, lon: 12.43 }) + } + """) + + // 2.11 Variables + // https://spec.graphql.org/September2025/#sec-Language.Variables + + parses("a variable definition can carry a description".fail)(""" + query getZuckProfile( + "The size of the profile picture to fetch." + $devicePicSize: Int + ) { + user(id: 4) { + id + name + profilePic(size: $devicePicSize) + } + } + """) + + // The specification states the variable values `{"devicePicSize": 60}` for the example above. + // This test case supplies those values. It drops the description of the variable definition, + // because the parser rejects it, which the test case above records. + yields( + "a request supplies a value for the variable of an operation", + Site, + json"""{"devicePicSize": 60}""")(""" + query getZuckProfile($devicePicSize: Int) { + user(id: 4) { + id + name + profilePic(size: $devicePicSize) + } + } + """)(json""" + { + "data": { + "user": { + "id": 4, + "name": "Mark Zuckerberg", + "profilePic": "https://cdn.site.io/pic-4-60.jpg" + } + } + } + """) + + // 2.13 Directives + // https://spec.graphql.org/September2025/#sec-Language.Directives + + parses("directives on a type definition in one order")(""" + type Person + @addExternalFields(source: "profiles") + @excludeField(name: "photo") { + name: String + } + """) + + parses("the same directives in the reverse order, which can mean something else")(""" + type Person + @excludeField(name: "photo") + @addExternalFields(source: "profiles") { + name: String + } + """) +} diff --git a/modules/core/src/test/scala/conformance/ResponseMappings.scala b/modules/core/src/test/scala/conformance/ResponseMappings.scala new file mode 100644 index 00000000..3f32c4f2 --- /dev/null +++ b/modules/core/src/test/scala/conformance/ResponseMappings.scala @@ -0,0 +1,127 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import cats.effect.IO + +import grackle._ +import grackle.Predicate.{Const, Eql} +import grackle.Query.{Binding, Filter, Unique} +import grackle.QueryCompiler._ +import grackle.Value.EnumValue + +/** + * Mappings for the examples of section 7, Response. + * + * Section 7.1.6, Errors, states that the name of the character with ID 1002 could not be + * fetched. The `name` field of that one character therefore fails here, and every other field + * resolves. + * + * @see + * https://spec.graphql.org/September2025/#sec-Response + */ +object ResponseMappings { + + case class Character(id: String, name: String, friendIds: List[String]) + + val luke: Character = Character("1000", "Luke Skywalker", List("1003")) + val han: Character = Character("1002", "Han Solo", Nil) + val leia: Character = Character("1003", "Leia Organa", Nil) + val r2d2: Character = Character("2001", "R2-D2", List("1000", "1002", "1003")) + + /** + * The characters which the examples of section 7 name. + */ + val characters: List[Character] = List(luke, han, leia, r2d2) + + val heroes: Map[String, Character] = + Map("NEWHOPE" -> r2d2, "EMPIRE" -> luke, "JEDI" -> r2d2) + + /** + * The character whose name the specification cannot fetch. + */ + val unfetchableId: String = han.id + + val unfetchableMessage: String = + s"Name for character with ID $unfetchableId could not be fetched." + + /** + * The mapping whose `name` field is nullable, so an error leaves `null` in place. + */ + object NullableName extends HeroMapping(nullableName = true) + + /** + * The mapping whose `name` field is non-null, so an error bubbles up to the list entry. + */ + object NonNullName extends HeroMapping(nullableName = false) + + /** + * The schema and the data of section 7. + * + * Section 7.1.6 states one response for a nullable `name` and one for a non-null `name`, so + * `nullableName` selects between the two forms. + */ + abstract class HeroMapping(nullableName: Boolean) extends ValueMapping[IO] { + val schema: Schema = + ConformanceSuite.mkSchema(s""" + type Query { hero(episode: Episode!): Character } + enum Episode { NEWHOPE EMPIRE JEDI } + type Character { + id: ID! + name: String${if (nullableName) "" else "!"} + friends: [Character] + } + """) + + val QueryType = schema.ref("Query") + val CharacterType = schema.ref("Character") + + private def heroName(c: Cursor): Result[String] = + c.as[Character].flatMap { ch => + if (ch.id == unfetchableId) Result.failure(unfetchableMessage) + else Result(ch.name) + } + + private val nameField: FieldMapping = + if (nullableName) CursorField[Option[String]]("name", c => heroName(c).map(Some(_))) + else CursorField[String]("name", heroName) + + val typeMappings = + List( + ValueObjectMapping[Unit]( + tpe = QueryType, + fieldMappings = List(ValueField("hero", _ => characters))), + ValueObjectMapping[Character]( + tpe = CharacterType, + fieldMappings = List( + ValueField("id", _.id), + nameField, + ValueField( + "friends", + c => Some(c.friendIds.map(id => characters.find(_.id == id))) + ) + ) + ) + ) + + override val selectElaborator: SelectElaborator = + SelectElaborator { + case (QueryType, "hero", List(Binding("episode", EnumValue(e)))) => + Elab.transformChild(child => + Unique(Filter(Eql(CharacterType / "id", Const(heroes(e).id)), child))) + } + } +} diff --git a/modules/core/src/test/scala/conformance/ResponseSuite.scala b/modules/core/src/test/scala/conformance/ResponseSuite.scala new file mode 100644 index 00000000..f32b6df4 --- /dev/null +++ b/modules/core/src/test/scala/conformance/ResponseSuite.scala @@ -0,0 +1,235 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import io.circe.{ACursor, Json, JsonObject} +import io.circe.literal._ +import io.circe.syntax._ + +import grackle.Problem + +/** + * Conformance test cases for section 7, Response. + * + * Both examples of this section omit the definition of the variable `$episode`, so neither one + * is a valid request as written. Each test case adds the definition and supplies a value. A + * comment marks the addition. + * + * @see + * https://spec.graphql.org/September2025/#sec-Response + */ +final class ResponseSuite extends ConformanceSuite { + + // 7.1.4 Response Position + // https://spec.graphql.org/September2025/#sec-Response-Position + + // The specification names four response paths for this request: the hero's name at + // ["hero", "name"], the list of friends at ["hero", "friends"], the first friend at + // ["hero", "friends", 0] and that friend's name at ["hero", "friends", 0, "name"]. + test("one field execution can produce more than one response position") { + val response = + ResponseMappings + .NullableName + .compileAndRun( + """ + query ($episode: Episode!) { + hero(episode: $episode) { + name + friends { + name + } + } + } + """, + untypedVars = Some(json"""{"episode": "EMPIRE"}""") + ) + + assertIO( + response.map(r => + List( + position(r, "hero", "name"), + position(r, "hero", "friends"), + position(r, "hero", "friends", 0), + position(r, "hero", "friends", 0, "name") + )), + List( + Some(json""""Luke Skywalker""""), + Some(json"""[{ "name": "Leia Organa" }]"""), + Some(json"""{ "name": "Leia Organa" }"""), + Some(json""""Leia Organa"""") + ) + ) + } + + // 7.1.6 Errors + // https://spec.graphql.org/September2025/#sec-Request-Error-Result + + // Grackle discards the whole `data` entry when a field raises an error, and it attaches + // neither `path` nor `locations` to the error. The response is + // `{"errors": [{"message": "..."}], "data": null}`. + /** + * The request of section 7.1.6, which the specification runs against two schemas. + */ + private val heroFriendsDoc = """ + query ($episode: Episode!) { + hero(episode: $episode) { + name + heroFriends: friends { + id + name + } + } + } + """ + + yields( + "an error carries the response path of the position which raised it".fail, + ResponseMappings.NullableName, + json"""{"episode": "NEWHOPE"}""")(heroFriendsDoc)(json""" + { + "errors": [ + { + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [{ "line": 6, "column": 7 }], + "path": ["hero", "heroFriends", 1, "name"] + } + ], + "data": { + "hero": { + "name": "R2-D2", + "heroFriends": [ + { + "id": "1000", + "name": "Luke Skywalker" + }, + { + "id": "1002", + "name": null + }, + { + "id": "1003", + "name": "Leia Organa" + } + ] + } + } + } + """) + + // The same request against a schema whose `name` field is non-null. The null bubbles up to the + // nearest nullable position, which is the entry of the `heroFriends` list. + yields( + "a null from an error bubbles up to the nearest nullable position".fail, + ResponseMappings.NonNullName, + json"""{"episode": "NEWHOPE"}""")(heroFriendsDoc)(json""" + { + "errors": [ + { + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [{ "line": 6, "column": 7 }], + "path": ["hero", "heroFriends", 1, "name"] + } + ], + "data": { + "hero": { + "name": "R2-D2", + "heroFriends": [ + { + "id": "1000", + "name": "Luke Skywalker" + }, + null, + { + "id": "1003", + "name": "Leia Organa" + } + ] + } + } + } + """) + + // Section 7.1.6 states that each location is a map with the keys `line` and `column`. Grackle + // writes the key `col`. This test case isolates that difference from the two above. + test("an error location uses the keys line and column".fail) { + assertEquals( + Problem( + "Name for character with ID 1002 could not be fetched.", + List(6 -> 7), + Nil).asJson, + json""" + { + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [{ "line": 6, "column": 7 }] + } + """ + ) + } + + // The specification states this error with `locations` and `path`. The two test cases above + // cover those two entries, so this test case states the `extensions` entry only. + test("an error can carry an extensions map") { + assertEquals( + Problem(ResponseMappings.unfetchableMessage, Nil, Nil, Some(errorExtensions)).asJson, + json""" + { + "message": "Name for character with ID 1002 could not be fetched.", + "extensions": { + "code": "CAN_NOT_FETCH_BY_ID", + "timestamp": "Fri Feb 9 14:33:09 UTC 2018" + } + } + """ + ) + } + + // The counter-example of this subject writes `code` and `timestamp` at the top level of the + // error. Grackle writes them inside `extensions`, so the error holds no other entry. + test("an error carries no entry of its own beside message, locations, path and extensions") { + assertEquals( + Problem(ResponseMappings.unfetchableMessage, Nil, Nil, Some(errorExtensions)) + .asJson + .hcursor + .keys + .map(_.toList), + Some(List("message", "extensions")) + ) + } + + /** + * The `extensions` map which section 7.1.6 states. + */ + private val errorExtensions: JsonObject = + JsonObject( + "code" -> json""""CAN_NOT_FETCH_BY_ID"""", + "timestamp" -> json""""Fri Feb 9 14:33:09 UTC 2018"""" + ) + + /** + * The value at `path` in the `data` entry of `response`. + * + * Section 7.1.4 defines a response path as a list of path segments. A segment which names a + * field is a string, and a segment which indexes a list is an integer. + */ + private def position(response: Json, path: Any*): Option[Json] = + path + .foldLeft(response.hcursor.downField("data"): ACursor) { + case (cursor, name: String) => cursor.downField(name) + case (cursor, index: Int) => cursor.downN(index) + case (_, segment) => fail(s"'$segment' is not a response path segment") + } + .focus +} diff --git a/modules/core/src/test/scala/conformance/TypeSystemMappings.scala b/modules/core/src/test/scala/conformance/TypeSystemMappings.scala new file mode 100644 index 00000000..f1df4476 --- /dev/null +++ b/modules/core/src/test/scala/conformance/TypeSystemMappings.scala @@ -0,0 +1,54 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import cats.effect.IO + +import grackle._ +import grackle.syntax._ + +/** + * Mappings for the examples of section 3, Type System. + * + * @see + * https://spec.graphql.org/September2025/#sec-Type-System + */ +object TypeSystemMappings { + + /** + * The four fields of section 3.6, Field Ordering. + * + * The specification numbers the values of each stated result by position, so the values here + * follow the order of the first example. + */ + object Ordering extends ValueMapping[IO] { + val schema = schema"type Query { foo: Int bar: Int baz: Int qux: Int }" + + val QueryType = schema.ref("Query") + + val typeMappings = + List( + ValueObjectMapping[Unit]( + tpe = QueryType, + fieldMappings = List( + ValueField("foo", _ => Some(1)), + ValueField("bar", _ => Some(2)), + ValueField("baz", _ => Some(3)), + ValueField("qux", _ => Some(4)) + ) + )) + } +} diff --git a/modules/core/src/test/scala/conformance/TypeSystemSuite.scala b/modules/core/src/test/scala/conformance/TypeSystemSuite.scala new file mode 100644 index 00000000..c500b696 --- /dev/null +++ b/modules/core/src/test/scala/conformance/TypeSystemSuite.scala @@ -0,0 +1,858 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import io.circe.literal._ + +import grackle.syntax._ + +/** + * Conformance test cases for section 3, Type System. + * + * Several examples in this section are a fragment of a schema, or a selection set which needs a + * schema. Each such test case adds the definitions which complete the example. A comment marks + * every addition. + * + * @see + * https://spec.graphql.org/September2025/#sec-Type-System + */ +final class TypeSystemSuite extends ConformanceSuite { + + // 3.2 Type System Descriptions + // https://spec.graphql.org/September2025/#sec-Descriptions + + validSchema("every definition of a schema can carry a description".fail)(""" + ''' + A simple GraphQL schema which is well described. + ''' + schema { + query: Query + } + + ''' + Root type for all your query operations + ''' + type Query { + ''' + Translates a string from a given language into a different language. + ''' + translate( + "The original language that `text` is provided in." + fromLanguage: Language + + "The translated language to be returned." + toLanguage: Language + + "The text to be translated." + text: String + ): String + } + + ''' + The set of languages supported by `translate`. + ''' + enum Language { + "English" + EN + + "French" + FR + + "Chinese" + CH + } + """) + + // 3.3.1 Root Operation Types + // https://spec.graphql.org/September2025/#sec-Root-Operation-Types + + validQuery("a query operation selects a field of the query root type", MyNameSchema)(""" + query { + myName + } + """) + + validSchema("the query root operation type provides the field which that query selects")(""" + type Query { + myName: String + } + """) + + validQuery("a mutation operation selects a field of the mutation root type", SetNameSchema)( + """ + mutation { + setName(name: "Zuck") { + newName + } + } + """) + + validSchema("a schema definition can name a query and a mutation root operation type")(""" + schema { + query: MyQueryRootType + mutation: MyMutationRootType + } + + type MyQueryRootType { + someField: String + } + + type MyMutationRootType { + setSomeField(to: String): String + } + """) + + validSchema("a schema definition can be omitted when the root types use the default names")( + """ + type Query { + someField: String + } + """) + + validSchema("a type named Mutation is not a root type when a schema definition says so")(""" + schema { + query: Query + } + + type Query { + latestVirus: Virus + } + + type Virus { + name: String + mutations: [Mutation] + } + + type Mutation { + name: String + } + """) + + validSchema("a schema definition can carry a description".fail)(""" + ''' + Example schema + ''' + schema { + query: Query + mutation: Mutation + } + + type Query { + someField: String + } + + type Mutation { + someMutation: String + } + """) + + // 3.5 Scalars + // https://spec.graphql.org/September2025/#sec-Scalars + + validSchema("a custom scalar can point at the specification which defines it")(""" + scalar UUID @specifiedBy(url: "https://tools.ietf.org/html/rfc4122") + scalar URL @specifiedBy(url: "https://tools.ietf.org/html/rfc3986") + scalar DateTime + @specifiedBy(url: "https://scalars.graphql.org/andimarek/date-time") + + # Added to complete the example: a query root type. + type Query { id: UUID url: URL at: DateTime } + """) + + // 3.6 Objects + // https://spec.graphql.org/September2025/#sec-Objects + + validSchema("an object type defines a set of fields")(""" + type Person { + name: String + age: Int + picture: Url + } + + # Added to complete the example: the `Url` scalar and a query root type. + scalar Url + type Query { person: Person } + """) + + validQuery("a selection set requests the fields of an object type", PersonSchema)(""" + { + name + age + picture + } + """) + + validQuery("the order of the requested fields is free", PersonSchema)(""" + { + age + name + } + """) + + validSchema("a field of an object type can have that same object type")(""" + type Person { + name: String + age: Int + picture: Url + relationship: Person + } + + # Added to complete the example: the `Url` scalar and a query root type. + scalar Url + type Query { person: Person } + """) + + invalidQuery("a field of an object type needs a selection set", RelationshipSchema)(""" + { + name + relationship + } + """) + + validQuery("a field of an object type with a selection set", RelationshipSchema)(""" + { + name + relationship { + name + } + } + """) + + // 3.6 Objects, Field Ordering + // https://spec.graphql.org/September2025/#sec-Objects.Field-Ordering + + // The specification states an ordered result for each example of this subject. It numbers the + // values of that result by position, so two examples state a different value for one field. + // Each test case below therefore compares the response keys, in order, against the keys of the + // stated result. + + // The stated result is {"foo": 1, "bar": 2, "baz": 3, "qux": 4}. + yieldsFieldOrder( + "a fragment spread before other fields keeps its position", + TypeSystemMappings.Ordering)(""" + { + foo + ...Frag + qux + } + + fragment Frag on Query { + bar + baz + } + """)(List("foo", "bar", "baz", "qux")) + + yieldsFieldOrder( + "a repeated field keeps the position of its first use", + TypeSystemMappings.Ordering)(""" + { + foo + ...Matching + bar + } + + fragment Matching on Query { + bar + qux + foo + } + """)(List("foo", "bar", "qux")) + + // The stated result is {"bar": 1, "foo": 2}. + yieldsFieldOrder( + "a field which a directive excludes does not affect the field order", + TypeSystemMappings.Ordering)(""" + { + foo @skip(if: true) + bar + foo + } + """)(List("bar", "foo")) + + // 3.6.1 Field Arguments + // https://spec.graphql.org/September2025/#sec-Field-Arguments + + validSchema("a field can declare arguments")(""" + type Person { + name: String + picture(size: Int): Url + } + + # Added to complete the example: the `Url` scalar and a query root type. + scalar Url + type Query { person: Person } + """) + + validQuery("a selection set supplies the argument of a field", PictureSchema)(""" + { + name + picture(size: 600) + } + """) + + // 3.6.2 Field Deprecation + // https://spec.graphql.org/September2025/#sec-Field-Deprecation + + validSchema("a field can be deprecated")(""" + type ExampleType { + oldField: String @deprecated + } + + # Added to complete the example: a query root type. + type Query { example: ExampleType } + """) + + // 3.6.3 Object Extensions + // https://spec.graphql.org/September2025/#sec-Object-Extensions + + validSchema("an object extension can add a field")(""" + extend type Story { + isHiddenLocally: Boolean + } + + # Added to complete the example: the `Story` type and a query root type. + type Story { id: ID } + type Query { story: Story } + """) + + validSchema("an object extension can add a directive only")(""" + extend type User @addedDirective + + # Added to complete the example: the directive, the `User` type and a query root type. + directive @addedDirective on OBJECT + type User { id: ID } + type Query { user: User } + """) + + // 3.7 Interfaces + // https://spec.graphql.org/September2025/#sec-Interfaces + + validSchema("an object type can implement more than one interface")(""" + interface NamedEntity { + name: String + } + + interface ValuedEntity { + value: Int + } + + type Person implements NamedEntity { + name: String + age: Int + } + + type Business implements NamedEntity & ValuedEntity { + name: String + value: Int + employeeCount: Int + } + + # Added to complete the example: a query root type. + type Query { person: Person business: Business } + """) + + validSchema("a field can have an interface type")(""" + type Contact { + entity: NamedEntity + phoneNumber: String + address: String + } + + # Added to complete the example: the interface and a query root type. + interface NamedEntity { name: String } + type Person implements NamedEntity { name: String age: Int } + type Query { contact: Contact } + """) + + validQuery("a selection set can request the fields of an interface", ContactSchema)(""" + { + entity { + name + } + phoneNumber + } + """) + + invalidQuery("a selection set cannot request a field of one implementation", ContactSchema)( + """ + { + entity { + name + age + } + phoneNumber + } + """) + + validQuery("an inline fragment reaches the fields of one implementation", ContactSchema)(""" + { + entity { + name + ... on Person { + age + } + } + phoneNumber + } + """) + + validSchema("an interface can implement another interface")(""" + interface Node { + id: ID! + } + + interface Resource implements Node { + id: ID! + url: String + } + + # Added to complete the example: a query root type. + type Query { resource: Resource } + """) + + validSchema("an interface must declare every transitively implemented interface")(""" + interface Node { + id: ID! + } + + interface Resource implements Node { + id: ID! + url: String + } + + interface Image implements Resource & Node { + id: ID! + url: String + thumbnail: String + } + + # Added to complete the example: a query root type. + type Query { image: Image } + """) + + invalidSchema("two interfaces cannot implement each other")(""" + interface Node implements Named & Node { + id: ID! + name: String + } + + interface Named implements Node & Named { + id: ID! + name: String + } + + # Added to complete the example: a query root type. + type Query { node: Node } + """) + + // 3.7.1 Interface Extensions + // https://spec.graphql.org/September2025/#sec-Interface-Extensions + + validSchema("an interface extension adds a field to the interface and its implementations")( + """ + extend interface NamedEntity { + nickname: String + } + + extend type Person { + nickname: String + } + + extend type Business { + nickname: String + } + + # Added to complete the example: the base definitions and a query root type. + interface NamedEntity { name: String } + type Person implements NamedEntity { name: String age: Int } + type Business implements NamedEntity { name: String employeeCount: Int } + type Query { person: Person business: Business } + """) + + validSchema("an interface extension can add a directive only")(""" + extend interface NamedEntity @addedDirective + + # Added to complete the example: the directive, the interface and a query root type. + directive @addedDirective on INTERFACE + interface NamedEntity { name: String } + type Person implements NamedEntity { name: String } + type Query { entity: NamedEntity } + """) + + // 3.8 Unions + // https://spec.graphql.org/September2025/#sec-Unions + + validSchema("a union type lists the object types which it can be")(""" + union SearchResult = Photo | Person + + type Person { + name: String + age: Int + } + + type Photo { + height: Int + width: Int + } + + type SearchQuery { + firstSearchResult: SearchResult + } + + # Added to complete the example: a query root type. + schema { query: SearchQuery } + """) + + invalidQuery("a selection set cannot request a field directly on a union", SearchSchema)(""" + { + firstSearchResult { + name + height + } + } + """) + + validQuery("an inline fragment reaches the fields of one member of a union", SearchSchema)(""" + { + firstSearchResult { + ... on Person { + name + } + ... on Photo { + height + } + } + } + """) + + validSchema("a union can start with a leading vertical bar")(""" + union SearchResult = + | Photo + | Person + + # Added to complete the example: the member types and a query root type. + type Person { name: String } + type Photo { height: Int } + type Query { result: SearchResult } + """) + + // 3.9 Enums + // https://spec.graphql.org/September2025/#sec-Enums + + validSchema("an enum type lists its values")(""" + enum Direction { + NORTH + EAST + SOUTH + WEST + } + + # Added to complete the example: a query root type. + type Query { direction: Direction } + """) + + // 3.10 Input Objects + // https://spec.graphql.org/September2025/#sec-Input-Objects + + validSchema("an input object type defines a set of input fields")(""" + input Point2D { + x: Float + y: Float + } + + # Added to complete the example: a query root type. + type Query { nearest(point: Point2D): String } + """) + + validSchema("an input object can refer to itself through a nullable field")(""" + input Example { + self: Example + value: String + } + + # Added to complete the example: a query root type. + type Query { example(arg: Example): String } + """) + + validSchema("an input object can refer to itself through a list field")(""" + input Example { + self: [Example!]! + value: String + } + + # Added to complete the example: a query root type. + type Query { example(arg: Example): String } + """) + + invalidSchema("an input object cannot refer to itself through a non-null field".fail)(""" + input Example { + value: String + self: Example! + } + + # Added to complete the example: a query root type. + type Query { example(arg: Example): String } + """) + + invalidSchema("two input objects cannot form a cycle of non-null fields".fail)(""" + input First { + second: Second! + value: String + } + + input Second { + first: First! + value: String + } + + # Added to complete the example: a query root type. + type Query { example(arg: First): String } + """) + + validSchema("an input object field can be non-null")(""" + input ExampleInputObject { + a: String + b: Int! + } + + # Added to complete the example: a query root type. + type Query { example(arg: ExampleInputObject): String } + """) + + // 3.10.1 OneOf Input Objects + // https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects + + validSchema("a oneOf input object accepts exactly one of its fields")(""" + input ExampleOneOfInputObject @oneOf { + a: String + b: Int + } + + # Added to complete the example: a query root type. + type Query { example(arg: ExampleOneOfInputObject): String } + """) + + // 3.12 Non-Null + // https://spec.graphql.org/September2025/#sec-Non-Null + + invalidQuery("a non-null argument cannot be omitted", NonNullArgSchema)(""" + { + fieldWithNonNullArg + } + """) + + invalidQuery("a non-null argument cannot take the literal null", NonNullArgSchema)(""" + { + fieldWithNonNullArg(nonNullArg: null) + } + """) + + // The specification marks the next document as an example, and the note below it states that + // the Validation section defines the document as invalid. Rule 5.8.5 applies, and grackle has + // no check for that rule. The test case supplies a value for `$var`, because the rule holds + // for every value. Without a value, the test case would pass because the value is absent. + invalidQuery( + "a nullable variable cannot be supplied to a non-null argument".fail, + NonNullArgSchema, + json"""{"var": "x"}""")(""" + query withNullableVariable($var: String) { + fieldWithNonNullArg(nonNullArg: $var) + } + """) + + // 3.13 Directives + // https://spec.graphql.org/September2025/#sec-Type-System.Directives + + // The specification puts a directive definition and a fragment definition in one document. A + // request accepts executable definitions only, so this test case checks the grammar. + parses("a custom directive definition and a use of that directive")(""" + directive @example on FIELD + + fragment SomeFragment on SomeType { + field @example + } + """) + + validSchema("a directive definition can list its locations with a leading vertical bar")(""" + directive @example on + | FIELD + | FRAGMENT_SPREAD + | INLINE_FRAGMENT + + # Added to complete the example: a query root type. + type Query { field: String } + """) + + validSchema("a directive can apply to a field definition and to an argument definition")(""" + directive @example on FIELD_DEFINITION | ARGUMENT_DEFINITION + + type SomeType { + field(arg: Int @example): String @example + } + + # Added to complete the example: a query root type. + type Query { some: SomeType } + """) + + validSchema("a repeatable directive can apply more than once at one location")(""" + directive @delegateField(name: String!) repeatable on OBJECT | INTERFACE + + type Book @delegateField(name: "pageCount") @delegateField(name: "author") { + id: ID! + } + + extend type Book @delegateField(name: "index") + + # Added to complete the example: a query root type. + type Query { book: Book } + """) + + invalidSchema("a directive cannot refer to itself".fail)(""" + directive @invalidExample(arg: String @invalidExample) on ARGUMENT_DEFINITION + + # Added to complete the example: a query root type. + type Query { field: String } + """) + + // 3.13.1 @skip + // https://spec.graphql.org/September2025/#sec--skip + + validQuery( + "@skip excludes a field when its argument is true", + ExperimentalSchema, + json"""{"someTest": true}""")(""" + query myQuery($someTest: Boolean!) { + experimentalField @skip(if: $someTest) + } + """) + + // 3.13.2 @include + // https://spec.graphql.org/September2025/#sec--include + + validQuery( + "@include keeps a field when its argument is true", + ExperimentalSchema, + json"""{"someTest": true}""")(""" + query myQuery($someTest: Boolean!) { + experimentalField @include(if: $someTest) + } + """) + + // 3.13.3 @deprecated + // https://spec.graphql.org/September2025/#sec--deprecated + + validSchema("@deprecated applies to a field definition and to an argument definition")(""" + type ExampleType { + newField: String + oldField: String @deprecated(reason: "Use `newField`.") + + anotherField( + newArg: String + oldArg: String @deprecated(reason: "Use `newArg`.") + ): String + } + + # Added to complete the example: a query root type. + type Query { example: ExampleType } + """) + + invalidSchema("@deprecated cannot apply to a required argument".fail)(""" + type ExampleType { + invalidField( + newArg: String + oldArg: String! @deprecated(reason: "Use `newArg`.") + ): String + } + + # Added to complete the example: a query root type. + type Query { example: ExampleType } + """) + + // 3.13.4 @specifiedBy + // https://spec.graphql.org/September2025/#sec--specifiedBy + + validSchema("@specifiedBy applies to a custom scalar")(""" + scalar UUID @specifiedBy(url: "https://tools.ietf.org/html/rfc4122") + + # Added to complete the example: a query root type. + type Query { id: UUID } + """) + + // 3.13.5 @oneOf + // https://spec.graphql.org/September2025/#sec--oneOf + + validSchema("@oneOf applies to an input object")(""" + input UserUniqueCondition @oneOf { + id: ID + username: String + organizationAndEmail: OrganizationAndEmailInput + } + + # Added to complete the example: the nested input type and a query root type. + input OrganizationAndEmailInput { organization: String email: String } + type Query { user(by: UserUniqueCondition): String } + """) + + // -- Schemas which complete the examples above ----------------------------------------------- + + lazy val MyNameSchema = schema"type Query { myName: String }" + + lazy val SetNameSchema = schema""" + type Query { placeholder: Boolean } + type Mutation { setName(name: String): SetNameResult } + type SetNameResult { newName: String } + """ + + // The specification writes these selection sets against `Person`, so `Person` is the query + // root operation type here. + lazy val PersonSchema = schema""" + scalar Url + schema { query: Person } + type Person { name: String age: Int picture: Url } + """ + + lazy val RelationshipSchema = schema""" + scalar Url + schema { query: Person } + type Person { name: String age: Int picture: Url relationship: Person } + """ + + lazy val PictureSchema = schema""" + scalar Url + schema { query: Person } + type Person { name: String picture(size: Int): Url } + """ + + lazy val ContactSchema = schema""" + schema { query: Contact } + interface NamedEntity { name: String } + type Person implements NamedEntity { name: String age: Int } + type Contact { entity: NamedEntity phoneNumber: String address: String } + """ + + lazy val SearchSchema = schema""" + schema { query: SearchQuery } + union SearchResult = Photo | Person + type Person { name: String age: Int } + type Photo { height: Int width: Int } + type SearchQuery { firstSearchResult: SearchResult } + """ + + lazy val NonNullArgSchema = + schema"type Query { fieldWithNonNullArg(nonNullArg: String!): String }" + + lazy val ExperimentalSchema = schema"type Query { experimentalField: String }" +} diff --git a/modules/core/src/test/scala/conformance/ValidationArgumentsSuite.scala b/modules/core/src/test/scala/conformance/ValidationArgumentsSuite.scala new file mode 100644 index 00000000..47e4a35f --- /dev/null +++ b/modules/core/src/test/scala/conformance/ValidationArgumentsSuite.scala @@ -0,0 +1,131 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +/** + * Conformance test cases for section 5.4, Arguments. + * + * Each test case adds a driver operation, as [[ValidationFieldsSuite]] describes. + * + * @see + * https://spec.graphql.org/September2025/#sec-Validation.Arguments + */ +final class ValidationArgumentsSuite extends ValidationSuite { + + // 5.4.1 Argument Names + // https://spec.graphql.org/September2025/#sec-Argument-Names + + validQuery("an argument name must be defined on the field or on the directive")(""" + query driver { + a: dog { ...argOnRequiredArg } + b: dog { ...argOnOptional } + } + + fragment argOnRequiredArg on Dog { + doesKnowCommand(dogCommand: SIT) + } + + fragment argOnOptional on Dog { + isHouseTrained(atOtherHomes: true) @include(if: true) + } + """) + + invalidQuery("an argument name which the field does not define is rejected")(""" + query driver { + dog { ...invalidArgName } + } + + fragment invalidArgName on Dog { + doesKnowCommand(command: CLEAN_UP_HOUSE) + } + """) + + invalidQuery("an argument name which the directive does not define is rejected")(""" + query driver { + dog { ...invalidArgName } + } + + fragment invalidArgName on Dog { + isHouseTrained(atOtherHomes: true) @include(unless: false) + } + """) + + validSchema("a type whose fields declare several arguments")( + ValidationSchema.base + ValidationSchema.arguments) + + validQuery("the order of the arguments of a field is free")(""" + query driver { + a: arguments { ...multipleArgs } + b: arguments { ...multipleArgsReverseOrder } + } + + fragment multipleArgs on Arguments { + multipleRequirements(x: 1, y: 2) + } + + fragment multipleArgsReverseOrder on Arguments { + multipleRequirements(y: 2, x: 1) + } + """) + + // 5.4.3 Required Arguments + // https://spec.graphql.org/September2025/#sec-Required-Arguments + + validQuery("a required argument which the selection supplies")(""" + query driver { + a: arguments { ...goodBooleanArg } + b: arguments { ...goodNonNullArg } + } + + fragment goodBooleanArg on Arguments { + booleanArgField(booleanArg: true) + } + + fragment goodNonNullArg on Arguments { + nonNullBooleanArgField(nonNullBooleanArg: true) + } + """) + + validQuery("a nullable argument can be omitted")(""" + query driver { + arguments { ...goodBooleanArgDefault } + } + + fragment goodBooleanArgDefault on Arguments { + booleanArgField + } + """) + + invalidQuery("a required argument must not be omitted")(""" + query driver { + arguments { ...missingRequiredArg } + } + + fragment missingRequiredArg on Arguments { + nonNullBooleanArgField + } + """) + + invalidQuery("a required argument must not take the literal null")(""" + query driver { + arguments { ...missingRequiredArg } + } + + fragment missingRequiredArg on Arguments { + nonNullBooleanArgField(nonNullBooleanArg: null) + } + """) +} diff --git a/modules/core/src/test/scala/conformance/ValidationDirectivesSuite.scala b/modules/core/src/test/scala/conformance/ValidationDirectivesSuite.scala new file mode 100644 index 00000000..d1d23cd6 --- /dev/null +++ b/modules/core/src/test/scala/conformance/ValidationDirectivesSuite.scala @@ -0,0 +1,68 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import grackle.syntax._ + +/** + * Conformance test cases for section 5.7, Directives. + * + * The examples of this section select a field named `field`, which the schema of section 5 does + * not define. Each test case supplies a schema which does define it. + * + * @see + * https://spec.graphql.org/September2025/#sec-Validation.Directives + */ +final class ValidationDirectivesSuite extends ValidationSuite { + + // 5.7.2 Directives Are in Valid Locations + // https://spec.graphql.org/September2025/#sec-Directives-Are-in-Valid-Locations + + invalidQuery("@skip must not appear on an operation definition", LeafFieldSchema)(""" + query @skip(if: $foo) { + field + } + """) + + // 5.7.3 Directives Are Unique per Location + // https://spec.graphql.org/September2025/#sec-Directives-Are-Unique-per-Location + + invalidQuery("one directive must not appear twice at one location", LeafFieldSchema)(""" + query ($foo: Boolean = true, $bar: Boolean = false) { + field @skip(if: $foo) @skip(if: $bar) + } + """) + + validQuery("one directive can appear once at each of two locations", ObjectFieldSchema)(""" + query ($foo: Boolean = true, $bar: Boolean = false) { + field @skip(if: $foo) { + subfieldA + } + field @skip(if: $bar) { + subfieldB + } + } + """) + + // -- Schemas which complete the examples above ----------------------------------------------- + + lazy val LeafFieldSchema = schema"type Query { field: Boolean }" + + lazy val ObjectFieldSchema = schema""" + type Query { field: FieldResult } + type FieldResult { subfieldA: String subfieldB: String } + """ +} diff --git a/modules/core/src/test/scala/conformance/ValidationDocumentsSuite.scala b/modules/core/src/test/scala/conformance/ValidationDocumentsSuite.scala new file mode 100644 index 00000000..30f1099f --- /dev/null +++ b/modules/core/src/test/scala/conformance/ValidationDocumentsSuite.scala @@ -0,0 +1,65 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +/** + * Conformance test cases for the introduction to section 5, and for section 5.1, Documents. + * + * @see + * https://spec.graphql.org/September2025/#sec-Documents + */ +final class ValidationDocumentsSuite extends ValidationSuite { + + // 5 Validation + // https://spec.graphql.org/September2025/#sec-Validation + + validSchema("the example schema which the rules of section 5 run against")( + ValidationSchema.base) + + // 5.1.1 Executable Definitions + // https://spec.graphql.org/September2025/#sec-Executable-Definitions + + // Grackle drops a type system definition from a request instead of rejecting the document, so + // the extension does not apply and the field `color` stays undefined. The document is rejected + // either way. + invalidQuery("a request must not contain a type system definition or extension")(""" + query getDogName { + dog { + name + color + } + } + + extend type Dog { + color: String + } + """) + + // The test case above passes for the reason of the missing field, so this test case isolates + // rule 5.1.1. The selection set holds no field of the extension, which leaves the extension + // itself as the only reason to reject the request. Grackle accepts the request. + invalidQuery("a request which contains a type system extension only".fail)(""" + query getDogName { + dog { + name + } + } + + extend type Dog { + color: String + } + """) +} diff --git a/modules/core/src/test/scala/conformance/ValidationFieldsSuite.scala b/modules/core/src/test/scala/conformance/ValidationFieldsSuite.scala new file mode 100644 index 00000000..41c95c4f --- /dev/null +++ b/modules/core/src/test/scala/conformance/ValidationFieldsSuite.scala @@ -0,0 +1,301 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import io.circe.literal._ + +/** + * Conformance test cases for section 5.3, Fields. + * + * The specification writes most of these examples as fragment definitions only. A request needs + * at least one operation, so each test case adds a driver operation which spreads the + * fragments. The driver gives each fragment its own aliased parent field, so that two fragments + * never merge into one selection set. + * + * A counter-example block which holds more than one fragment becomes one test case per + * fragment. A document is rejected as a whole, so one test case for the whole block would pass + * while one fragment fails. + * + * @see + * https://spec.graphql.org/September2025/#sec-Validation.Fields + */ +final class ValidationFieldsSuite extends ValidationSuite { + + // 5.3.1 Field Selections + // https://spec.graphql.org/September2025/#sec-Field-Selections + + invalidQuery("a field which the type of the selection set does not define")(""" + query driver { + dog { ...fieldNotDefined } + } + + fragment fieldNotDefined on Dog { + meowVolume + } + """) + + invalidQuery("an alias cannot rename an undefined field to a defined one")(""" + query driver { + dog { ...aliasedLyingFieldTargetNotDefined } + } + + fragment aliasedLyingFieldTargetNotDefined on Dog { + barkVolume: kawVolume + } + """) + + validQuery("a selection on an interface can request a field of that interface")(""" + query driver { + pet { ...interfaceFieldSelection } + } + + fragment interfaceFieldSelection on Pet { + name + } + """) + + invalidQuery("a selection on an interface cannot request a field of one implementation")(""" + query driver { + pet { ...definedOnImplementersButNotInterface } + } + + fragment definedOnImplementersButNotInterface on Pet { + nickname + } + """) + + validQuery("a selection on a union can request __typename and use inline fragments")(""" + query driver { + catOrDog { ...inDirectFieldSelectionOnUnion } + } + + fragment inDirectFieldSelectionOnUnion on CatOrDog { + __typename + ... on Pet { + name + } + ... on Dog { + barkVolume + } + } + """) + + invalidQuery("a selection on a union cannot request a field directly")(""" + query driver { + catOrDog { ...directFieldSelectionOnUnion } + } + + fragment directFieldSelectionOnUnion on CatOrDog { + name + barkVolume + } + """) + + // 5.3.2 Field Selection Merging + // https://spec.graphql.org/September2025/#sec-Field-Selection-Merging + + validQuery("two identical fields merge")(""" + query driver { + a: dog { ...mergeIdenticalFields } + b: dog { ...mergeIdenticalAliasesAndFields } + } + + fragment mergeIdenticalFields on Dog { + name + name + } + + fragment mergeIdenticalAliasesAndFields on Dog { + otherName: name + otherName: name + } + """) + + invalidQuery("one response key must not point at two different fields")(""" + query driver { + dog { ...conflictingBecauseAlias } + } + + fragment conflictingBecauseAlias on Dog { + name: nickname + name + } + """) + + validQuery( + "two identical fields with identical arguments merge", + vars = json"""{"dogCommand": "SIT"}""")(""" + query driver($dogCommand: DogCommand!) { + a: dog { ...mergeIdenticalFieldsWithIdenticalArgs } + b: dog { ...mergeIdenticalFieldsWithIdenticalValues } + } + + fragment mergeIdenticalFieldsWithIdenticalArgs on Dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand(dogCommand: SIT) + } + + fragment mergeIdenticalFieldsWithIdenticalValues on Dog { + doesKnowCommand(dogCommand: $dogCommand) + doesKnowCommand(dogCommand: $dogCommand) + } + """) + + // The specification writes the four fragments below as one counter-example block. Each + // fragment is a separate case, so each one has its own test case. + + invalidQuery("two literal arguments with different values conflict")(""" + query driver { + dog { ...conflictingArgsOnValues } + } + + fragment conflictingArgsOnValues on Dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand(dogCommand: HEEL) + } + """) + + invalidQuery( + "a literal argument and a variable argument conflict", + vars = json"""{"dogCommand": "SIT"}""")(""" + query driver($dogCommand: DogCommand!) { + dog { ...conflictingArgsValueAndVar } + } + + fragment conflictingArgsValueAndVar on Dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand(dogCommand: $dogCommand) + } + """) + + invalidQuery( + "two different variable arguments conflict", + vars = json"""{"varOne": "SIT", "varTwo": "HEEL"}""")(""" + query driver($varOne: DogCommand!, $varTwo: DogCommand!) { + dog { ...conflictingArgsWithVars } + } + + fragment conflictingArgsWithVars on Dog { + doesKnowCommand(dogCommand: $varOne) + doesKnowCommand(dogCommand: $varTwo) + } + """) + + invalidQuery("an argument and an absent argument conflict")(""" + query driver { + dog { ...differingArgs } + } + + fragment differingArgs on Dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand + } + """) + + validQuery("two fields of mutually exclusive types can differ")(""" + query driver { + a: pet { ...safeDifferingFields } + b: pet { ...safeDifferingArgs } + } + + fragment safeDifferingFields on Pet { + ... on Dog { + volume: barkVolume + } + ... on Cat { + volume: meowVolume + } + } + + fragment safeDifferingArgs on Pet { + ... on Dog { + doesKnowCommand(dogCommand: SIT) + } + ... on Cat { + doesKnowCommand(catCommand: JUMP) + } + } + """) + + invalidQuery("two fields of mutually exclusive types must return the same type")(""" + query driver { + pet { ...conflictingDifferingResponses } + } + + fragment conflictingDifferingResponses on Pet { + ... on Dog { + someValue: nickname + } + ... on Cat { + someValue: meowVolume + } + } + """) + + // 5.3.3 Leaf Field Selections + // https://spec.graphql.org/September2025/#sec-Leaf-Field-Selections + + validQuery("a field of a scalar type takes no selection set")(""" + query driver { + dog { ...scalarSelection } + } + + fragment scalarSelection on Dog { + barkVolume + } + """) + + invalidQuery("a field of a scalar type must not take a selection set")(""" + query driver { + dog { ...scalarSelectionsNotAllowedOnInt } + } + + fragment scalarSelectionsNotAllowedOnInt on Dog { + barkVolume { + sinceWhen + } + } + """) + + validSchema("the query root type gains a field of object, interface and union type")( + ValidationSchema.base + ValidationSchema.leafFields) + + invalidQuery("a field of object type must take a selection set")(""" + query directQueryOnObjectWithoutSubFields { + human + } + """) + + invalidQuery("a field of interface type must take a selection set")(""" + query directQueryOnInterfaceWithoutSubFields { + pet + } + """) + + invalidQuery("a field of union type must take a selection set")(""" + query directQueryOnUnionWithoutSubFields { + catOrDog + } + """) + + validQuery("a field of object type with a selection set")(""" + query directQueryOnObjectWithSubFields { + human { + name + } + } + """) +} diff --git a/modules/core/src/test/scala/conformance/ValidationFragmentsSuite.scala b/modules/core/src/test/scala/conformance/ValidationFragmentsSuite.scala new file mode 100644 index 00000000..d06995bc --- /dev/null +++ b/modules/core/src/test/scala/conformance/ValidationFragmentsSuite.scala @@ -0,0 +1,392 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +/** + * Conformance test cases for section 5.5, Fragments. + * + * Each test case adds a driver operation, as [[ValidationFieldsSuite]] describes. A + * counter-example block which holds more than one fragment becomes one test case per fragment, + * for the reason which [[ValidationFieldsSuite]] gives. + * + * @see + * https://spec.graphql.org/September2025/#sec-Validation.Fragments + */ +final class ValidationFragmentsSuite extends ValidationSuite { + + // 5.5.1.1 Fragment Name Uniqueness + // https://spec.graphql.org/September2025/#sec-Fragment-Name-Uniqueness + + validQuery("two fragments can have different names")(""" + { + dog { + ...fragmentOne + ...fragmentTwo + } + } + + fragment fragmentOne on Dog { + name + } + + fragment fragmentTwo on Dog { + owner { + name + } + } + """) + + invalidQuery("two fragments must not share a name")(""" + { + dog { + ...fragmentOne + } + } + + fragment fragmentOne on Dog { + name + } + + fragment fragmentOne on Dog { + owner { + name + } + } + """) + + // 5.5.1.2 Fragment Spread Type Existence + // https://spec.graphql.org/September2025/#sec-Fragment-Spread-Type-Existence + + validQuery("a fragment and an inline fragment can name a type of the schema")(""" + query driver { + a: dog { ...correctType } + b: dog { ...inlineFragment } + c: dog { ...inlineFragment2 } + } + + fragment correctType on Dog { + name + } + + fragment inlineFragment on Dog { + ... on Dog { + name + } + } + + fragment inlineFragment2 on Dog { + ... @include(if: true) { + name + } + } + """) + + invalidQuery("a fragment must not name a type which the schema does not define")(""" + query driver { + dog { ...notOnExistingType } + } + + fragment notOnExistingType on NotInSchema { + name + } + """) + + invalidQuery("an inline fragment must not name a type which the schema does not define")(""" + query driver { + dog { ...inlineNotExistingType } + } + + fragment inlineNotExistingType on Dog { + ... on NotInSchema { + name + } + } + """) + + // 5.5.1.3 Fragments on Object, Interface or Union Types + // https://spec.graphql.org/September2025/#sec-Fragments-on-Object-Interface-or-Union-Types + + validQuery("a fragment can be declared on an object, an interface or a union type")(""" + query driver { + a: dog { ...fragOnObject } + b: pet { ...fragOnInterface } + c: catOrDog { ...fragOnUnion } + } + + fragment fragOnObject on Dog { + name + } + + fragment fragOnInterface on Pet { + name + } + + fragment fragOnUnion on CatOrDog { + ... on Dog { + name + } + } + """) + + invalidQuery("a fragment must not be declared on a scalar type")(""" + query driver { + dog { ...fragOnScalar } + } + + fragment fragOnScalar on Int { + something + } + """) + + invalidQuery("an inline fragment must not be declared on a scalar type")(""" + query driver { + dog { ...inlineFragOnScalar } + } + + fragment inlineFragOnScalar on Dog { + ... on Boolean { + somethingElse + } + } + """) + + // 5.5.1.4 Fragments Must Be Used + // https://spec.graphql.org/September2025/#sec-Fragments-Must-Be-Used + + invalidQuery("every fragment of a document must be spread at least once")(""" + fragment nameFragment on Dog { # unused + name + } + + { + dog { + name + } + } + """) + + // 5.5.2.1 Fragment Spread Target Defined + // https://spec.graphql.org/September2025/#sec-Fragment-Spread-Target-Defined + + invalidQuery("a fragment spread must name a fragment of the document")(""" + { + dog { + ...undefinedFragment + } + } + """) + + // 5.5.2.2 Fragment Spreads Must Not Form Cycles + // https://spec.graphql.org/September2025/#sec-Fragment-Spreads-Must-Not-Form-Cycles + + invalidQuery("two fragments must not spread each other")(""" + { + dog { + ...nameFragment + } + } + + fragment nameFragment on Dog { + name + ...barkVolumeFragment + } + + fragment barkVolumeFragment on Dog { + barkVolume + ...nameFragment + } + """) + + invalidQuery("a cycle through a nested field is also a cycle")(""" + { + dog { + ...dogFragment + } + } + + fragment dogFragment on Dog { + name + owner { + ...ownerFragment + } + } + + fragment ownerFragment on Human { + name + pets { + ...dogFragment + } + } + """) + + // 5.5.2.3.1 Object Spreads in Object Scope + // https://spec.graphql.org/September2025/#sec-Object-Spreads-in-Object-Scope + + validQuery("an object fragment can spread into the same object type")(""" + query driver { + dog { ...dogFragment } + } + + fragment dogFragment on Dog { + ... on Dog { + barkVolume + } + } + """) + + invalidQuery("an object fragment must not spread into a different object type")(""" + query driver { + dog { ...catInDogFragmentInvalid } + } + + fragment catInDogFragmentInvalid on Dog { + ... on Cat { + meowVolume + } + } + """) + + // 5.5.2.3.2 Abstract Spreads in Object Scope + // https://spec.graphql.org/September2025/#sec-Abstract-Spreads-in-Object-Scope + + validQuery("an interface fragment can spread into an object type which implements it")(""" + query driver { + dog { ...interfaceWithinObjectFragment } + } + + fragment petNameFragment on Pet { + name + } + + fragment interfaceWithinObjectFragment on Dog { + ...petNameFragment + } + """) + + validQuery("a union fragment can spread into an object type which the union holds")(""" + query driver { + dog { ...unionWithObjectFragment } + } + + fragment catOrDogNameFragment on CatOrDog { + ... on Cat { + meowVolume + } + } + + fragment unionWithObjectFragment on Dog { + ...catOrDogNameFragment + } + """) + + // 5.5.2.3.3 Object Spreads in Abstract Scope + // https://spec.graphql.org/September2025/#sec-Object-Spreads-in-Abstract-Scope + + validQuery("an object fragment can spread into an abstract type which the object belongs to")( + """ + query driver { + a: pet { ...petFragment } + b: catOrDog { ...catOrDogFragment } + } + + fragment petFragment on Pet { + name + ... on Dog { + barkVolume + } + } + + fragment catOrDogFragment on CatOrDog { + ... on Cat { + meowVolume + } + } + """) + + invalidQuery("an object fragment must not spread into an interface which excludes it")(""" + query driver { + sentient { ...sentientFragment } + } + + fragment sentientFragment on Sentient { + ... on Dog { + barkVolume + } + } + """) + + invalidQuery("an object fragment must not spread into a union which excludes it")(""" + query driver { + humanOrAlien { ...humanOrAlienFragment } + } + + fragment humanOrAlienFragment on HumanOrAlien { + ... on Cat { + meowVolume + } + } + """) + + // 5.5.2.3.4 Abstract Spreads in Abstract Scope + // https://spec.graphql.org/September2025/#sec-Abstract-Spreads-in-Abstract-Scope + + validQuery("a union fragment can spread into an interface which they share a type with")(""" + query driver { + pet { ...unionWithInterface } + } + + fragment unionWithInterface on Pet { + ...dogOrHumanFragment + } + + fragment dogOrHumanFragment on DogOrHuman { + ... on Dog { + barkVolume + } + } + """) + + invalidQuery("two abstract types with no type in common must not spread into each other")(""" + query driver { + pet { ...nonIntersectingInterfaces } + } + + fragment nonIntersectingInterfaces on Pet { + ...sentientFragment + } + + fragment sentientFragment on Sentient { + name + } + """) + + // The specification writes the two interface definitions in the same document as the + // fragments. `ValidationSchema` holds them instead, because a request accepts executable + // definitions only. + validQuery("an interface fragment can spread into an interface which implements it")(""" + query driver { + node { ...interfaceWithInterface } + } + + fragment interfaceWithInterface on Node { + ...resourceFragment + } + + fragment resourceFragment on Resource { + url + } + """) +} diff --git a/modules/core/src/test/scala/conformance/ValidationOperationsSuite.scala b/modules/core/src/test/scala/conformance/ValidationOperationsSuite.scala new file mode 100644 index 00000000..0d0789e2 --- /dev/null +++ b/modules/core/src/test/scala/conformance/ValidationOperationsSuite.scala @@ -0,0 +1,198 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import io.circe.literal._ + +import grackle.syntax._ + +/** + * Conformance test cases for section 5.2, Operations. + * + * @see + * https://spec.graphql.org/September2025/#sec-Validation.Operations + */ +final class ValidationOperationsSuite extends ValidationSuite { + + // 5.2.1.1 Operation Type Existence + // https://spec.graphql.org/September2025/#sec-Operation-Type-Existence + + validSchema("a schema which defines a query root operation type only")(""" + type Query { + hello: String + } + """) + + validQuery("a query operation needs a query root operation type", HelloSchema)(""" + query helloQuery { + hello + } + """) + + invalidQuery("a mutation operation needs a mutation root operation type", HelloSchema)(""" + mutation goodbyeMutation { + goodbye + } + """) + + // 5.2.2.1 Operation Name Uniqueness + // https://spec.graphql.org/September2025/#sec-Operation-Name-Uniqueness + + validQuery("two operations can have different names")(""" + query getDogName { + dog { + name + } + } + + query getOwnerName { + dog { + owner { + name + } + } + } + """) + + invalidQuery("two operations must not share a name")(""" + query getName { + dog { + name + } + } + + query getName { + dog { + owner { + name + } + } + } + """) + + invalidQuery("two operations of different types must not share a name")(""" + query dogOperation { + dog { + name + } + } + + mutation dogOperation { + mutateDog { + id + } + } + """) + + // 5.2.3.1 Lone Anonymous Operation + // https://spec.graphql.org/September2025/#sec-Lone-Anonymous-Operation + + validQuery("a document can hold one anonymous operation")(""" + { + dog { + name + } + } + """) + + invalidQuery("an anonymous operation must be the only operation")(""" + { + dog { + name + } + } + + query getName { + dog { + owner { + name + } + } + } + """) + + // 5.2.4.1 Single Root Field + // https://spec.graphql.org/September2025/#sec-Single-Root-Field + + validQuery("a subscription operation can select one root field")(""" + subscription sub { + newMessage { + body + sender + } + } + """) + + validQuery("a fragment can supply the one root field of a subscription")(""" + subscription sub { + ...newMessageFields + } + + fragment newMessageFields on Subscription { + newMessage { + body + sender + } + } + """) + + invalidQuery("a subscription operation must not select two root fields".fail)(""" + subscription sub { + newMessage { + body + sender + } + disallowedSecondRootField + } + """) + + invalidQuery("a fragment must not add a second root field to a subscription".fail)(""" + subscription sub { + ...multipleSubscriptions + } + + fragment multipleSubscriptions on Subscription { + newMessage { + body + sender + } + disallowedSecondRootField + } + """) + + invalidQuery( + "@skip and @include must not appear on the root selection set of a subscription".fail, + vars = json"""{"bool": true}""")(""" + subscription requiredRuntimeValidation($bool: Boolean!) { + newMessage @include(if: $bool) { + body + sender + } + disallowedSecondRootField @skip(if: $bool) + } + """) + + invalidQuery("the one root field of a subscription must not be an introspection field".fail)( + """ + subscription sub { + __typename + } + """) + + // -- Schemas which complete the examples above ----------------------------------------------- + + lazy val HelloSchema = schema"type Query { hello: String }" +} diff --git a/modules/core/src/test/scala/conformance/ValidationSchema.scala b/modules/core/src/test/scala/conformance/ValidationSchema.scala new file mode 100644 index 00000000..ab0b223e --- /dev/null +++ b/modules/core/src/test/scala/conformance/ValidationSchema.scala @@ -0,0 +1,237 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import grackle.Schema + +/** + * The schema which the examples of section 5, Validation, run against. + * + * [[base]] holds the schema of the introduction to section 5. [[sdl]] adds the definitions + * which the later examples introduce, and the definitions which complete those examples. + * + * @see + * https://spec.graphql.org/September2025/#sec-Validation + */ +object ValidationSchema { + + /** + * The example schema of the introduction to section 5. + */ + val base: String = """ + type Query { + dog: Dog + findDog(searchBy: FindDogInput): Dog + } + + type Mutation { + addPet(pet: PetInput!): Pet + addPets(pets: [PetInput!]!): [Pet] + } + + enum DogCommand { + SIT + DOWN + HEEL + } + + type Dog implements Pet { + name: String! + nickname: String + barkVolume: Int + doesKnowCommand(dogCommand: DogCommand!): Boolean! + isHouseTrained(atOtherHomes: Boolean): Boolean! + owner: Human + } + + interface Sentient { + name: String! + } + + interface Pet { + name: String! + } + + type Alien implements Sentient { + name: String! + homePlanet: String + } + + type Human implements Sentient { + name: String! + pets: [Pet!] + } + + enum CatCommand { + JUMP + } + + type Cat implements Pet { + name: String! + nickname: String + doesKnowCommand(catCommand: CatCommand!): Boolean! + meowVolume: Int + } + + union CatOrDog = Cat | Dog + union DogOrHuman = Dog | Human + union HumanOrAlien = Human | Alien + + input FindDogInput { + name: String + owner: String + } + + input CatInput { + name: String! + nickname: String + meowVolume: Int + } + + input DogInput { + name: String! + nickname: String + barkVolume: Int + } + + input PetInput @oneOf { + cat: CatInput + dog: DogInput + } + """ + + /** + * The definitions which rule 5.3.3, Leaf Field Selections, adds to [[base]]. + */ + val leafFields: String = """ + extend type Query { + human: Human + pet: Pet + catOrDog: CatOrDog + } + """ + + /** + * The definitions which rule 5.4.1, Argument Names, adds to [[base]]. + */ + val arguments: String = """ + type Arguments { + multipleRequirements(x: Int!, y: Int!): Int! + booleanArgField(booleanArg: Boolean): Boolean + floatArgField(floatArg: Float): Float + intArgField(intArg: Int): Int + nonNullBooleanArgField(nonNullBooleanArg: Boolean!): Boolean! + booleanListArgField(booleanListArg: [Boolean]!): [Boolean] + optionalNonNullBooleanArgField(optionalBooleanArg: Boolean! = false): Boolean! + } + + extend type Query { + arguments: Arguments + } + """ + + /** + * The definitions which rule 5.8.2, Variables Are Input Types, adds to [[base]]. + */ + val variables: String = """ + extend type Query { + booleanList(booleanListArg: [Boolean!]): Boolean + } + """ + + /** + * The definitions which the examples of section 5 add to [[base]]. + */ + val fromExamples: String = leafFields + arguments + variables + + /** + * The definitions which complete the examples of section 5. + * + * The specification names these types and fields in its examples without defining them. A + * counter-example must fail for the reason which the rule states, not because a name is + * missing, so this suite defines them. + */ + val completions: String = """ + # Rule 5.2.4.1, Single Root Field, needs a subscription root type. + type Subscription { + newMessage: Message + disallowedSecondRootField: Boolean + } + + type Message { + body: String + sender: String + } + + # Rule 5.2.2.1, Operation Name Uniqueness, selects `mutateDog`. + extend type Mutation { + mutateDog: DogMutation + } + + type DogMutation { + id: ID + } + + # Rule 5.8.5, All Variable Usages Are Allowed, selects `nonNullBooleanListField`. + extend type Arguments { + nonNullBooleanListField(nonNullBooleanListArg: [Boolean]!): [Boolean] + } + + # Rule 5.5.2.3.4, Abstract Spreads in Abstract Scope, writes these two interfaces inline. + interface Node { + id: ID! + } + + interface Resource implements Node { + id: ID! + url: String + } + + type Image implements Resource & Node { + id: ID! + url: String + thumbnail: String + } + + # Fields which let a driver operation spread the fragments of the examples. + extend type Query { + cat: Cat + sentient: Sentient + humanOrAlien: HumanOrAlien + dogOrHuman: DogOrHuman + node: Node + } + """ + + /** + * The complete schema. + */ + val sdl: String = base + fromExamples + completions + + /** + * The parsed form of [[sdl]]. + */ + lazy val schema: Schema = ConformanceSuite.mkSchema(sdl) +} + +/** + * Base class for the conformance suites of section 5, Validation. + * + * Every suite of section 5 runs its query test cases against [[ValidationSchema.schema]]. + */ +abstract protected[conformance] class ValidationSuite extends ConformanceSuite { + override lazy val defaultSchema: Schema = ValidationSchema.schema +} diff --git a/modules/core/src/test/scala/conformance/ValidationValuesSuite.scala b/modules/core/src/test/scala/conformance/ValidationValuesSuite.scala new file mode 100644 index 00000000..f4daa43c --- /dev/null +++ b/modules/core/src/test/scala/conformance/ValidationValuesSuite.scala @@ -0,0 +1,163 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import io.circe.literal._ + +import grackle.syntax._ + +/** + * Conformance test cases for section 5.6, Values. + * + * Two examples of this section mix fragment definitions and operations. Each such test case + * adds a driver operation which spreads the fragments, as [[ValidationFieldsSuite]] describes. + * + * @see + * https://spec.graphql.org/September2025/#sec-Values + */ +final class ValidationValuesSuite extends ValidationSuite { + + // 5.6.1 Values of Correct Type + // https://spec.graphql.org/September2025/#sec-Values-of-Correct-Type + + // The specification writes the example and the counter-example of this rule as one block each. + // Each block holds several operations and fragments, and each one of those is a separate case. + // One test case per block would pass while one case fails, so each case has its own test case + // here. + + validQuery("a Boolean literal at a Boolean location")(""" + query driver { + arguments { ...goodBooleanArg } + } + + fragment goodBooleanArg on Arguments { + booleanArgField(booleanArg: true) + } + """) + + // Grackle rejects the Int literal `123` at a `Float` location. Section 3.5.2, Float, requires + // that coercion. + validQuery("an Int literal at a Float location".fail)(""" + query driver { + arguments { ...coercedIntIntoFloatArg } + } + + fragment coercedIntIntoFloatArg on Arguments { + # Note: The input coercion rules for Float allow Int literals. + floatArgField(floatArg: 123) + } + """) + + validQuery("an input object literal as the default value of a variable")(""" + query goodComplexDefaultValue($search: FindDogInput = { name: "Fido" }) { + findDog(searchBy: $search) { + name + } + } + """) + + validQuery("a oneOf input object literal as the default value of a variable")(""" + mutation addPet($pet: PetInput! = { cat: { name: "Brontie" } }) { + addPet(pet: $pet) { + name + } + } + """) + + invalidQuery("a String literal at an Int location")(""" + query driver { + arguments { ...stringIntoInt } + } + + fragment stringIntoInt on Arguments { + intArgField(intArg: "123") + } + """) + + invalidQuery("an Int literal at a String location inside an input object")(""" + query badComplexValue { + findDog(searchBy: { name: 123 }) { + name + } + } + """) + + invalidQuery("a oneOf input object literal with no field")(""" + mutation oneOfWithNoFields { + addPet(pet: {}) { + name + } + } + """) + + // The rule counts the fields which the literal writes, so it rejects this document whatever + // value the request supplies for `$dog`. Grackle counts the fields after it substitutes the + // variable value, so it accepts the document when the request supplies no value. + invalidQuery("a oneOf input object literal with two fields".fail)(""" + mutation oneOfWithTwoFields($dog: DogInput) { + addPet(pet: { cat: { name: "Brontie" }, dog: $dog }) { + name + } + } + """) + + // Rule 5.8.5 forbids a nullable variable at the field of a oneOf input object. Grackle has no + // check for that rule, so it accepts the document once `$dog` has a value. + invalidQuery( + "a nullable variable at the field of a oneOf input object inside a list".fail, + vars = json"""{"dog": {"name": "Fido"}}""")(""" + mutation listOfOneOfWithNullableVariable($dog: DogInput) { + addPets(pets: [{ dog: $dog }]) { + name + } + } + """) + + // 5.6.2 Input Object Field Names + // https://spec.graphql.org/September2025/#sec-Input-Object-Field-Names + + validQuery("an input object field name must be defined on the input object type")(""" + { + findDog(searchBy: { name: "Fido" }) { + name + } + } + """) + + invalidQuery("an input object field name which the input object type does not define")(""" + { + findDog(searchBy: { favoriteCookieFlavor: "Bacon" }) { + name + } + } + """) + + // 5.6.3 Input Object Field Uniqueness + // https://spec.graphql.org/September2025/#sec-Input-Object-Field-Uniqueness + + invalidQuery("an input object must not name one field twice".fail, FieldArgSchema)(""" + { + field(arg: { field: true, field: false }) + } + """) + + // -- Schemas which complete the examples above ----------------------------------------------- + + lazy val FieldArgSchema = schema""" + type Query { field(arg: ExampleInput): Boolean } + input ExampleInput { field: Boolean } + """ +} diff --git a/modules/core/src/test/scala/conformance/ValidationVariablesSuite.scala b/modules/core/src/test/scala/conformance/ValidationVariablesSuite.scala new file mode 100644 index 00000000..8bcf989f --- /dev/null +++ b/modules/core/src/test/scala/conformance/ValidationVariablesSuite.scala @@ -0,0 +1,366 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import io.circe.literal._ + +/** + * Conformance test cases for section 5.8, Variables. + * + * @see + * https://spec.graphql.org/September2025/#sec-Validation.Variables + */ +final class ValidationVariablesSuite extends ValidationSuite { + + // 5.8.1 Variable Uniqueness + // https://spec.graphql.org/September2025/#sec-Variable-Uniqueness + + invalidQuery("one operation must not declare a variable name twice".fail)(""" + query houseTrainedQuery($atOtherHomes: Boolean, $atOtherHomes: Boolean) { + dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + } + """) + + validQuery("two operations can declare the same variable name")(""" + query A($atOtherHomes: Boolean) { + ...HouseTrainedFragment + } + + query B($atOtherHomes: Boolean) { + ...HouseTrainedFragment + } + + fragment HouseTrainedFragment on Query { + dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + } + """) + + // 5.8.2 Variables Are Input Types + // https://spec.graphql.org/September2025/#sec-Variables-Are-Input-Types + + validSchema("the query root type gains a field with a list argument")( + ValidationSchema.base + ValidationSchema.variables) + + validQuery("a variable can have a scalar, an enum or an input object type")(""" + query takesBoolean($atOtherHomes: Boolean) { + dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + } + + query takesComplexInput($search: FindDogInput) { + findDog(searchBy: $search) { + name + } + } + + query TakesListOfBooleanBang($booleans: [Boolean!]) { + booleanList(booleanListArg: $booleans) + } + """) + + // The specification writes the next four operations as one counter-example block, and leaves + // the selection set of each one empty. Grackle rejects each operation because its variable is + // unused, which is rule 5.8.4, not rule 5.8.2. Each operation has its own test case, so that + // one operation cannot hide another. + + invalidQuery("a variable must not have an object type")(""" + query takesCat($cat: Cat) { + # ... + } + """) + + invalidQuery("a variable must not have a non-null object type")(""" + query takesDogBang($dog: Dog!) { + # ... + } + """) + + invalidQuery("a variable must not have a list of interface type")(""" + query takesListOfPet($pets: [Pet]) { + # ... + } + """) + + invalidQuery("a variable must not have a union type")(""" + query takesCatOrDog($catOrDog: CatOrDog) { + # ... + } + """) + + // The four test cases above pass for the reason of rule 5.8.4, so this test case isolates rule + // 5.8.2. The operation uses the variable, which leaves rule 5.8.2 as the only reason to reject + // the document. Grackle has no check for that rule and accepts the document. Rule 5.8.5 also + // forbids this usage, and grackle has no check for that rule either. + invalidQuery("a variable of object type which the operation uses".fail)(""" + query takesCat($cat: Cat) { + findDog(searchBy: $cat) { + name + } + } + """) + + // 5.8.3 All Variable Uses Defined + // https://spec.graphql.org/September2025/#sec-All-Variable-Uses-Defined + + validQuery("a variable use inside the operation which declares it")(""" + query variableIsDefined($atOtherHomes: Boolean) { + dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + } + """) + + invalidQuery("a variable use without a declaration")(""" + query variableIsNotDefined { + dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + } + """) + + validQuery("a variable use inside a fragment which the operation spreads")(""" + query variableIsDefinedUsedInSingleFragment($atOtherHomes: Boolean) { + dog { + ...isHouseTrainedFragment + } + } + + fragment isHouseTrainedFragment on Dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + """) + + invalidQuery("a variable use inside a fragment without a declaration")(""" + query variableIsNotDefinedUsedInSingleFragment { + dog { + ...isHouseTrainedFragment + } + } + + fragment isHouseTrainedFragment on Dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + """) + + invalidQuery("a variable use inside a nested fragment without a declaration")(""" + query variableIsNotDefinedUsedInNestedFragment { + dog { + ...outerHouseTrainedFragment + } + } + + fragment outerHouseTrainedFragment on Dog { + ...isHouseTrainedFragment + } + + fragment isHouseTrainedFragment on Dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + """) + + validQuery( + "every operation which reaches a fragment declares the variables of that fragment")(""" + query houseTrainedQueryOne($atOtherHomes: Boolean) { + dog { + ...isHouseTrainedFragment + } + } + + query houseTrainedQueryTwo($atOtherHomes: Boolean) { + dog { + ...isHouseTrainedFragment + } + } + + fragment isHouseTrainedFragment on Dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + """) + + invalidQuery("one operation which reaches a fragment lacks the declaration")(""" + query houseTrainedQueryOne($atOtherHomes: Boolean) { + dog { + ...isHouseTrainedFragment + } + } + + query houseTrainedQueryTwoNotDefined { + dog { + ...isHouseTrainedFragment + } + } + + fragment isHouseTrainedFragment on Dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + """) + + // 5.8.4 All Variables Used + // https://spec.graphql.org/September2025/#sec-All-Variables-Used + + invalidQuery("a declared variable which the operation never uses")(""" + query variableUnused($atOtherHomes: Boolean) { + dog { + isHouseTrained + } + } + """) + + validQuery("a fragment which the operation spreads can use the variable")(""" + query variableUsedInFragment($atOtherHomes: Boolean) { + dog { + ...isHouseTrainedFragment + } + } + + fragment isHouseTrainedFragment on Dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + """) + + invalidQuery("a fragment which the operation spreads does not use the variable")(""" + query variableNotUsedWithinFragment($atOtherHomes: Boolean) { + dog { + ...isHouseTrainedWithoutVariableFragment + } + } + + fragment isHouseTrainedWithoutVariableFragment on Dog { + isHouseTrained + } + """) + + invalidQuery("one operation of a document declares a variable which it never uses")(""" + query queryWithUsedVar($atOtherHomes: Boolean) { + dog { + ...isHouseTrainedFragment + } + } + + query queryWithExtraVar($atOtherHomes: Boolean, $extra: Int) { + dog { + ...isHouseTrainedFragment + } + } + + fragment isHouseTrainedFragment on Dog { + isHouseTrained(atOtherHomes: $atOtherHomes) + } + """) + + // 5.8.5 All Variable Usages Are Allowed + // https://spec.graphql.org/September2025/#sec-All-Variable-Usages-Are-Allowed + + // Rule 5.8.5 rejects a document for the declared type of a variable, whatever value the + // request supplies for it. Grackle has no check for the rule. It reports a value which does + // not fit the argument, which is a different check, and it runs that check after it + // substitutes the variable value. A counter-example whose declared type can hold a value which + // fits the argument therefore needs such a value here. Without one, the test case would pass + // because the value is absent, not because the rule holds. + + invalidQuery("an Int variable cannot go into a Boolean argument".fail)(""" + query intCannotGoIntoBoolean($intArg: Int) { + arguments { + booleanArgField(booleanArg: $intArg) + } + } + """) + + invalidQuery("a list variable cannot go into a non-list argument".fail)(""" + query booleanListCannotGoIntoBoolean($booleanListArg: [Boolean]) { + arguments { + booleanArgField(booleanArg: $booleanListArg) + } + } + """) + + invalidQuery( + "a nullable variable cannot go into a non-null argument".fail, + vars = json"""{"booleanArg": true}""")(""" + query booleanArgQuery($booleanArg: Boolean) { + arguments { + nonNullBooleanArgField(nonNullBooleanArg: $booleanArg) + } + } + """) + + validQuery( + "a non-null list variable can go into a nullable list argument", + vars = json"""{"nonNullBooleanList": [true]}""")(""" + query nonNullListToList($nonNullBooleanList: [Boolean]!) { + arguments { + booleanListArgField(booleanListArg: $nonNullBooleanList) + } + } + """) + + invalidQuery( + "a nullable list variable cannot go into a non-null list argument".fail, + vars = json"""{"booleanList": [true]}""")(""" + query listToNonNullList($booleanList: [Boolean]) { + arguments { + nonNullBooleanListField(nonNullBooleanListArg: $booleanList) + } + } + """) + + validQuery( + "a non-null variable can go into a oneOf input field", + vars = json"""{"cat": {"name": "Brontie"}}""")(""" + mutation addCat($cat: CatInput!) { + addPet(pet: { cat: $cat }) { + name + } + } + + mutation addCatWithDefault($cat: CatInput! = { name: "Brontie" }) { + addPet(pet: { cat: $cat }) { + name + } + } + """) + + invalidQuery( + "a nullable variable cannot go into a oneOf input field".fail, + vars = json"""{"cat": {"name": "Brontie"}}""")(""" + mutation addNullableCat($cat: CatInput) { + addPet(pet: { cat: $cat }) { + name + } + } + """) + + validQuery("a nullable variable can go into a non-null argument which has a default".fail)(""" + query booleanArgQueryWithDefault($booleanArg: Boolean) { + arguments { + optionalNonNullBooleanArgField(optionalBooleanArg: $booleanArg) + } + } + """) + + validQuery("a nullable variable with a default can go into a non-null argument")(""" + query booleanArgQueryWithDefault($booleanArg: Boolean = true) { + arguments { + nonNullBooleanArgField(nonNullBooleanArg: $booleanArg) + } + } + """) +}