diff --git a/build.sbt b/build.sbt index 62c48a16..b607c859 100644 --- a/build.sbt +++ b/build.sbt @@ -30,7 +30,7 @@ ThisBuild / scalaVersion := Scala2 ThisBuild / crossScalaVersions := Seq(Scala2, Scala3) ThisBuild / tlJdkRelease := Some(11) -ThisBuild / tlBaseVersion := "0.15" +ThisBuild / tlBaseVersion := "0.16" ThisBuild / organization := "org.typelevel" ThisBuild / organizationName := "Association of Universities for Research in Astronomy, Inc. (AURA)" ThisBuild / startYear := Some(2019) diff --git a/modules/core/src/main/scala/ast.scala b/modules/core/src/main/scala/ast.scala index 1b207451..2ff70e3b 100644 --- a/modules/core/src/main/scala/ast.scala +++ b/modules/core/src/main/scala/ast.scala @@ -23,6 +23,11 @@ object Ast { sealed trait ExecutableDefinition extends Definition sealed trait TypeSystemDefinition extends Definition + sealed trait TypeSystemExtension extends Definition + + sealed trait TypeExtension extends TypeSystemExtension { + def baseType: Type.Named + } sealed abstract class OperationType(val name: String) object OperationType { @@ -115,6 +120,11 @@ object Ast { directives: List[Directive] ) extends TypeSystemDefinition + case class SchemaExtension( + rootOperationTypes: List[RootOperationTypeDefinition], + directives: List[Directive] + ) extends TypeSystemExtension + case class RootOperationTypeDefinition( operationType: OperationType, tpe: Type.Named, @@ -200,6 +210,43 @@ object Ast { locations: List[DirectiveLocation] ) extends TypeSystemDefinition + case class ScalarTypeExtension( + baseType: Type.Named, + directives: List[Directive] + ) extends TypeExtension + + case class ObjectTypeExtension( + baseType: Type.Named, + fields: List[FieldDefinition], + interfaces: List[Type.Named], + directives: List[Directive] + ) extends TypeExtension + + case class InterfaceTypeExtension( + baseType: Type.Named, + fields: List[FieldDefinition], + interfaces: List[Type.Named], + directives: List[Directive] + ) extends TypeExtension + + case class UnionTypeExtension( + baseType: Type.Named, + directives: List[Directive], + members: List[Type.Named] + ) extends TypeExtension + + case class EnumTypeExtension( + baseType: Type.Named, + directives: List[Directive], + values: List[EnumValueDefinition] + ) extends TypeExtension + + case class InputObjectTypeExtension( + baseType: Type.Named, + directives: List[Directive], + fields: List[InputValueDefinition], + ) extends TypeExtension + sealed trait DirectiveLocation object DirectiveLocation { case object QUERY extends DirectiveLocation diff --git a/modules/core/src/main/scala/parser.scala b/modules/core/src/main/scala/parser.scala index 38c9d0e5..b6789a94 100644 --- a/modules/core/src/main/scala/parser.scala +++ b/modules/core/src/main/scala/parser.scala @@ -32,7 +32,7 @@ object GraphQLParser { (whitespace.void | comment).rep0 *> Definition.rep0 <* Parser.end lazy val Definition: Parser[Ast.Definition] = - ExecutableDefinition | TypeSystemDefinition // | TypeSystemExtension + ExecutableDefinition | TypeSystemDefinition | TypeSystemExtension lazy val TypeSystemDefinition: Parser[Ast.TypeSystemDefinition] = { val SchemaDefinition: Parser[Ast.SchemaDefinition] = @@ -92,6 +92,56 @@ object GraphQLParser { } } + lazy val TypeSystemExtension: Parser[Ast.TypeSystemExtension] = { + + val SchemaExtension: Parser[Ast.SchemaExtension] = + ((keyword("schema") *> Directives.?) ~ braces(RootOperationTypeDefinition.rep0).?).map { + case (dirs, rootdefs) => Ast.SchemaExtension(rootdefs.getOrElse(Nil), dirs.getOrElse(Nil)) + } + + val TypeExtension: Parser[Ast.TypeExtension] = { + + val ScalarTypeExtension: Parser[Ast.ScalarTypeExtension] = + ((keyword("scalar") *> NamedType) ~ Directives.?).map { + case (((name), dirs)) => Ast.ScalarTypeExtension(name, dirs.getOrElse(Nil)) + } + + val ObjectTypeExtension: Parser[Ast.ObjectTypeExtension] = + ((keyword("type") *> NamedType) ~ ImplementsInterfaces.? ~ Directives.? ~ FieldsDefinition.?).map { + case (((name, ifs), dirs), fields) => Ast.ObjectTypeExtension(name, fields.getOrElse(Nil), ifs.getOrElse(Nil), dirs.getOrElse(Nil)) + } + + val InterfaceTypeExtension: Parser[Ast.InterfaceTypeExtension] = + ((keyword("interface") *> NamedType) ~ ImplementsInterfaces.? ~ Directives.? ~ FieldsDefinition.?).map { + case (((name, ifs), dirs), fields) => Ast.InterfaceTypeExtension(name, fields.getOrElse(Nil), ifs.getOrElse(Nil), dirs.getOrElse(Nil)) + } + + val UnionTypeExtension: Parser[Ast.UnionTypeExtension] = + ((keyword("union") *> NamedType) ~ Directives.? ~ UnionMemberTypes.?).map { + case (((name), dirs), members) => Ast.UnionTypeExtension(name, dirs.getOrElse(Nil), members.getOrElse(Nil)) + } + + val EnumTypeExtension: Parser[Ast.EnumTypeExtension] = + ((keyword("enum") *> NamedType) ~ Directives.? ~ EnumValuesDefinition.?).map { + case (((name), dirs), values) => Ast.EnumTypeExtension(name, dirs.getOrElse(Nil), values.getOrElse(Nil)) + } + + val InputObjectTypeExtension: Parser[Ast.InputObjectTypeExtension] = + ((keyword("input") *> NamedType) ~ Directives.? ~ InputFieldsDefinition.?).map { + case (((name), dirs), fields) => Ast.InputObjectTypeExtension(name, dirs.getOrElse(Nil), fields.getOrElse(Nil)) + } + + ScalarTypeExtension| + ObjectTypeExtension| + InterfaceTypeExtension| + UnionTypeExtension| + EnumTypeExtension| + InputObjectTypeExtension + } + + keyword("extend") *> (SchemaExtension | TypeExtension) + } + lazy val RootOperationTypeDefinition: Parser[Ast.RootOperationTypeDefinition] = (OperationType ~ keyword(":") ~ NamedType ~ Directives).map { case (((optpe, _), tpe), dirs) => Ast.RootOperationTypeDefinition(optpe, tpe, dirs) diff --git a/modules/core/src/main/scala/schema.scala b/modules/core/src/main/scala/schema.scala index 58a947fe..a1328d54 100644 --- a/modules/core/src/main/scala/schema.scala +++ b/modules/core/src/main/scala/schema.scala @@ -35,12 +35,23 @@ trait Schema { def pos: SourcePos - /** The types defined by this `Schema`. */ - def types: List[NamedType] + /** The types defined by this `Schema` prior to any extensions. */ + def baseTypes: List[NamedType] + + /** The types defined by this `Schema` with any extensions applied. */ + lazy val types: List[NamedType] = + if (typeExtensions.isEmpty) baseTypes + else baseTypes.map(extendType(typeExtensions)) /** The directives defined by this `Schema`. */ def directives: List[DirectiveDef] + /** The schema extensions defined by this `Schema` */ + def schemaExtensions: List[SchemaExtension] + + /** The type extensions defined by this `Schema` */ + def typeExtensions: List[TypeExtension] + /** A reference by name to a type defined by this `Schema`. * * `TypeRef`s refer to types defined in this schema by name and hence @@ -103,13 +114,17 @@ trait Schema { case _ => None } + def baseSchemaType: NamedType = definition("Schema").getOrElse(defaultSchemaType) + /** * The schema type. * * Either the explicitly defined type named `"Schema"` or the default * schema type if not defined. */ - def schemaType: NamedType = definition("Schema").getOrElse(defaultSchemaType) + lazy val schemaType: NamedType = + if (schemaExtensions.isEmpty) baseSchemaType + else extendSchemaType(schemaExtensions, baseSchemaType) /** The type of queries defined by this `Schema`*/ def queryType: NamedType = schemaType.field("query").flatMap(_.nonNull.asNamed).get @@ -133,6 +148,82 @@ trait Schema { } override def toString = SchemaRenderer.renderSchema(this) + + private def extendType(extns: List[TypeExtension])(baseType: NamedType): NamedType = { + baseType match { + case ScalarType(name, description, directives) => + val exts = extns.collect { case se@ScalarExtension(`name`, _) => se } + if (exts.isEmpty) baseType + else { + val newDirectives = exts.flatMap(_.directives) + ScalarType(name, description, directives ++ newDirectives) + } + + case InterfaceType(name, description, fields, interfaces, directives) => + val exts = extns.collect { case ie@InterfaceExtension(`name`, _, _, _) => ie } + if (exts.isEmpty) baseType + else { + val newFields = exts.flatMap(_.fields) + val newInterfaces = exts.flatMap(_.interfaces) + val newDirectives = exts.flatMap(_.directives) + InterfaceType(name, description, fields ++ newFields, interfaces ++ newInterfaces, directives ++ newDirectives) + } + + case ObjectType(name, description, fields, interfaces, directives) => + val exts = extns.collect { case oe@ObjectExtension(`name`, _, _, _) => oe } + if (exts.isEmpty) baseType + else { + val newFields = exts.flatMap(_.fields) + val newInterfaces = exts.flatMap(_.interfaces) + val newDirectives = exts.flatMap(_.directives) + ObjectType(name, description, fields ++ newFields, interfaces ++ newInterfaces, directives ++ newDirectives) + } + + case UnionType(name, description, members, directives) => + val exts = extns.collect { case ue@UnionExtension(`name`, _, _) => ue } + if (exts.isEmpty) baseType + else { + val newMembers = exts.flatMap(_.members) + val newDirectives = exts.flatMap(_.directives) + UnionType(name, description, members ++ newMembers, directives ++ newDirectives) + } + + case EnumType(name, description, enumValues, directives) => + val exts = extns.collect { case ee@EnumExtension(`name`, _, _) => ee } + if (exts.isEmpty) baseType + else { + val newValues = exts.flatMap(_.enumValues) + val newDirectives = exts.flatMap(_.directives) + EnumType(name, description, enumValues ++ newValues, directives ++ newDirectives) + } + + case InputObjectType(name, description, inputFields, directives) => + val exts = extns.collect { case ioe@InputObjectExtension(`name`, _, _) => ioe } + if (exts.isEmpty) baseType + else { + val newFields = exts.flatMap(_.inputFields) + val newDirectives = exts.flatMap(_.directives) + InputObjectType(name, description, inputFields ++ newFields, directives ++ newDirectives) + } + + case tr: TypeRef => + // This case should never be hit, however, it is the correct behaviour to return + // the ref as is. If the underlying type is present it will be extended, if not + // there will be an error reported elsewhere. + tr + } + } + + private def extendSchemaType(extns: List[SchemaExtension], schemaType: NamedType): NamedType = { + schemaType match { + case ObjectType(name, description, fields, interfaces, directives) => + val newFields = extns.flatMap(_.rootOperations) + val newDirectives = extns.flatMap(_.directives) + ObjectType(name, description, fields ++ newFields, interfaces, directives ++ newDirectives) + + case _ => schemaType + } + } } object Schema { @@ -140,6 +231,11 @@ object Schema { SchemaParser.parseText(schemaText) } +case class SchemaExtension( + rootOperations: List[Field], + directives: List[Directive] +) + /** * A GraphQL type definition. */ @@ -461,6 +557,13 @@ sealed trait NamedType extends Type { override def toString: String = name } +/** + * A GraphQL type extension + */ +sealed trait TypeExtension { + def baseType: String +} + /** * A by name reference to a type defined in `schema`. */ @@ -585,6 +688,16 @@ sealed trait TypeWithFields extends NamedType { override def fieldInfo(name: String): Option[Field] = fields.find(_.name == name) } +/** + * Scalar extensions allow additional directives to be applied to a pre-existing Scalar type + * + * @see https://spec.graphql.org/draft/#sec-Scalar-Extensions + */ +case class ScalarExtension( + baseType: String, + directives: List[Directive] +) extends TypeExtension + /** * Interfaces are an abstract type where there are common fields declared. Any type that * implements an interface must define all the fields with names and types exactly matching. @@ -597,10 +710,22 @@ case class InterfaceType( fields: List[Field], interfaces: List[NamedType], directives: List[Directive] -) extends Type with TypeWithFields { +) extends TypeWithFields { override def isInterface: Boolean = true } +/** + * Interface extensions allow additional fields to be added to a pre-existing interface type + * + * @see https://spec.graphql.org/draft/#sec-Interface-Extensions + **/ +case class InterfaceExtension( + baseType: String, + fields: List[Field], + interfaces: List[NamedType], + directives: List[Directive] +) extends TypeExtension + /** * Object types represent concrete instantiations of sets of fields. * @@ -612,7 +737,19 @@ case class ObjectType( fields: List[Field], interfaces: List[NamedType], directives: List[Directive] -) extends Type with TypeWithFields +) extends TypeWithFields + +/** + * Object extensions allow additional fields to be added to a pre-existing object type + * + * @see https://spec.graphql.org/draft/#sec-Object-Extensions + **/ +case class ObjectExtension( + baseType: String, + fields: List[Field], + interfaces: List[NamedType], + directives: List[Directive] +) extends TypeExtension /** * Unions are an abstract type where no common fields are declared. The possible types of a union @@ -631,6 +768,17 @@ case class UnionType( override def toString: String = members.mkString("|") } +/** + * Union extensions allow additional members to be added to a pre-existing union type + * + * @see https://spec.graphql.org/draft/#sec-Union-Extensions + **/ +case class UnionExtension( + baseType: String, + members: List[NamedType], + directives: List[Directive] +) extends TypeExtension + /** * Enums are special scalars that can only have a defined set of values. * @@ -648,6 +796,17 @@ case class EnumType( def valueDefinition(name: String): Option[EnumValueDefinition] = enumValues.find(_.name == name) } +/** + * Enum extensions allow additional values to be added to a pre-existing enum type + * + * @see https://spec.graphql.org/draft/#sec-Enum-Extensions + **/ +case class EnumExtension( + baseType: String, + enumValues: List[EnumValueDefinition], + directives: List[Directive] +) extends TypeExtension + /** * The `EnumValue` type represents one of possible values of an enum. * @@ -683,6 +842,17 @@ case class InputObjectType( def inputFieldInfo(name: String): Option[InputValue] = inputFields.find(_.name == name) } +/** + * Input Object extensions allow additional fields to be added to a pre-existing Input Object type + * + * @see https://spec.graphql.org/draft/#sec-Input-Object-Extensions + **/ +case class InputObjectExtension( + baseType: String, + inputFields: List[InputValue], + directives: List[Directive] +) extends TypeExtension + /** * Lists represent sequences of values in GraphQL. A List type is a type modifier: it wraps * another type instance in the ofType field, which defines the type of each item in the list. @@ -1155,7 +1325,7 @@ object Directive { */ object SchemaParser { - import Ast.{Directive => _, EnumValueDefinition => _, Type => _, Value => _, _} + import Ast.{Directive => _, EnumValueDefinition => _, SchemaExtension => _, Type => _, TypeExtension => _, Value => _, _} /** * Parse a query String to a query algebra term. @@ -1170,43 +1340,43 @@ object SchemaParser { def parseDocument(doc: Document)(implicit sourcePos: SourcePos): Result[Schema] = { object schema extends Schema { - var types: List[NamedType] = Nil - var schemaType1: Option[NamedType] = null + var baseTypes: List[NamedType] = Nil + var baseSchemaType1: Option[NamedType] = null var pos: SourcePos = sourcePos - override def schemaType: NamedType = schemaType1.getOrElse(super.schemaType) + override def baseSchemaType: NamedType = baseSchemaType1.getOrElse(super.baseSchemaType) var directives: List[DirectiveDef] = Nil + var schemaExtensions: List[SchemaExtension] = Nil + var typeExtensions: List[TypeExtension] = Nil - def complete(types0: List[NamedType], schemaType0: Option[NamedType], directives0: List[DirectiveDef]): Unit = { - types = types0 - schemaType1 = schemaType0 + def complete(types0: List[NamedType], baseSchemaType0: Option[NamedType], directives0: List[DirectiveDef], schemaExtensions0: List[SchemaExtension], typeExtensions0: List[TypeExtension]): Unit = { + baseTypes = types0 + baseSchemaType1 = baseSchemaType0 directives = directives0 ++ DirectiveDef.builtIns + schemaExtensions = schemaExtensions0 + typeExtensions = typeExtensions0 } } + val schemaExtnDefns: List[Ast.SchemaExtension] = doc.collect { case tpe: Ast.SchemaExtension => tpe } val typeDefns: List[TypeDefinition] = doc.collect { case tpe: TypeDefinition => tpe } val dirDefns: List[DirectiveDefinition] = doc.collect { case dir: DirectiveDefinition => dir } + val extnDefns: List[Ast.TypeExtension] = doc.collect { case tpe: Ast.TypeExtension => tpe } + for { - types <- mkTypeDefs(schema, typeDefns) - directives <- mkDirectiveDefs(schema, dirDefns) - schemaType <- mkSchemaType(schema, doc) - _ = schema.complete(types, schemaType, directives) - _ <- Result.fromProblems(SchemaValidator.validateSchema(schema, typeDefns)) + baseTypes <- mkTypeDefs(schema, typeDefns) + schemaExtns <- mkSchemaExtensions(schema, schemaExtnDefns) + typeExtns <- mkExtensions(schema, extnDefns) + directives <- mkDirectiveDefs(schema, dirDefns) + schemaType <- mkSchemaType(schema, doc) + _ = schema.complete(baseTypes, schemaType, directives, schemaExtns, typeExtns) + _ <- Result.fromProblems(SchemaValidator.validateSchema(schema, typeDefns, extnDefns)) } yield schema } // explicit Schema type, if any def mkSchemaType(schema: Schema, doc: Document): Result[Option[NamedType]] = { - def mkRootOperationType(rootTpe: RootOperationTypeDefinition): Result[Field] = { - val RootOperationTypeDefinition(optype, tpe, dirs0) = rootTpe - for { - dirs <- dirs0.traverse(mkDirective) - tpe <- mkType(schema)(tpe) - _ <- Result.failure(s"Root operation types must be named types, found '$tpe'").whenA(!tpe.nonNull.isNamed) - } yield Field(optype.name, None, Nil, tpe, dirs) - } - def build(dirs: List[Directive], ops: List[Field]): NamedType = { val query = ops.find(_.name == "query").getOrElse(Field("query", None, Nil, defaultQueryType, Nil)) ObjectType( @@ -1225,7 +1395,7 @@ object SchemaParser { case Nil => None.success case SchemaDefinition(rootOpTpes, dirs0) :: Nil => for { - ops <- rootOpTpes.traverse(mkRootOperationType) + ops <- rootOpTpes.traverse(mkRootOperation(schema)) dirs <- dirs0.traverse(mkDirective) } yield Some(build(dirs, ops)) @@ -1233,6 +1403,64 @@ object SchemaParser { } } + def mkSchemaExtensions(schema: Schema, extnDefns: List[Ast.SchemaExtension]): Result[List[SchemaExtension]] = + extnDefns.traverse(mkSchemaExtension(schema)) + + def mkSchemaExtension(schema: Schema)(se: Ast.SchemaExtension): Result[SchemaExtension] = { + val Ast.SchemaExtension(rootOpTpes, dirs0) = se + for { + ops <- rootOpTpes.traverse(mkRootOperation(schema)) + dirs <- dirs0.traverse(mkDirective) + } yield SchemaExtension(ops, dirs) + } + + def mkRootOperation(schema: Schema)(rootTpe: RootOperationTypeDefinition): Result[Field] = { + val RootOperationTypeDefinition(optype, tpe, dirs0) = rootTpe + for { + dirs <- dirs0.traverse(mkDirective) + tpe <- mkType(schema)(tpe) + _ <- Result.failure(s"Root operation types must be named types, found '$tpe'").whenA(!tpe.nonNull.isNamed) + } yield Field(optype.name, None, Nil, tpe, dirs) + } + + def mkExtensions(schema: Schema, extnDefns: List[Ast.TypeExtension]): Result[List[TypeExtension]] = + extnDefns.traverse(mkExtension(schema)) + + def mkExtension(schema: Schema)(ed: Ast.TypeExtension): Result[TypeExtension] = + ed match { + case ScalarTypeExtension(Ast.Type.Named(Name(name)), dirs0) => + for { + dirs <- dirs0.traverse(mkDirective) + } yield ScalarExtension(name, dirs) + case InterfaceTypeExtension(Ast.Type.Named(Name(name)), fields0, ifs0, dirs0) => + for { + fields <- fields0.traverse(mkField(schema)) + ifs = ifs0.map { case Ast.Type.Named(Name(nme)) => schema.ref(nme) } + dirs <- dirs0.traverse(mkDirective) + } yield InterfaceExtension(name, fields, ifs, dirs) + case ObjectTypeExtension(Ast.Type.Named(Name(name)), fields0, ifs0, dirs0) => + for { + fields <- fields0.traverse(mkField(schema)) + ifs = ifs0.map { case Ast.Type.Named(Name(nme)) => schema.ref(nme) } + dirs <- dirs0.traverse(mkDirective) + } yield ObjectExtension(name, fields, ifs, dirs) + case UnionTypeExtension(Ast.Type.Named(Name(name)), dirs0, members0) => + for { + dirs <- dirs0.traverse(mkDirective) + members = members0.map { case Ast.Type.Named(Name(nme)) => schema.ref(nme) } + } yield UnionExtension(name, members, dirs) + case EnumTypeExtension(Ast.Type.Named(Name(name)), dirs0, values0) => + for { + values <- values0.traverse(mkEnumValue) + dirs <- dirs0.traverse(mkDirective) + } yield EnumExtension(name, values, dirs) + case InputObjectTypeExtension(Ast.Type.Named(Name(name)), dirs0, fields0) => + for { + fields <- fields0.traverse(mkInputValue(schema)) + dirs <- dirs0.traverse(mkDirective) + } yield InputObjectExtension(name, fields, dirs) + } + def mkTypeDefs(schema: Schema, defns: List[TypeDefinition]): Result[List[NamedType]] = defns.traverse(mkTypeDef(schema)) @@ -1364,11 +1592,14 @@ object SchemaParser { object SchemaValidator { import SchemaRenderer.renderType - def validateSchema(schema: Schema, defns: List[TypeDefinition]): List[Problem] = + def validateSchema(schema: Schema, defns: List[TypeDefinition], typeExtnDefns: List[Ast.TypeExtension]): List[Problem] = validateReferences(schema, defns) ++ validateUniqueDefns(schema) ++ + validateUniqueFields(schema) ++ + validateUnionMembers(schema) ++ validateUniqueEnumValues(schema) ++ validateImplementations(schema) ++ + validateTypeExtensions(defns, typeExtnDefns) ++ Directive.validateDirectivesForSchema(schema) def validateReferences(schema: Schema, defns: List[TypeDefinition]): List[Problem] = { @@ -1382,10 +1613,10 @@ object SchemaValidator { def referencedTypes(defns: List[TypeDefinition]): List[String] = { defns.flatMap { - case ObjectTypeDefinition(_, _, fields, interfaces, _) => - (fields.flatMap(_.args.map(_.tpe)) ++ fields.map(_.tpe) ++ interfaces).map(underlyingName) - case InterfaceTypeDefinition(_, _, fields, interfaces, _) => - (fields.flatMap(_.args.map(_.tpe)) ++ fields.map(_.tpe) ++ interfaces).map(underlyingName) + case ObjectTypeDefinition(_, _, fields, _, _) => + (fields.flatMap(_.args.map(_.tpe)) ++ fields.map(_.tpe)).map(underlyingName) + case InterfaceTypeDefinition(_, _, fields, _, _) => + (fields.flatMap(_.args.map(_.tpe)) ++ fields.map(_.tpe)).map(underlyingName) case u: UnionTypeDefinition => u.members.map(underlyingName) case _ => Nil @@ -1410,14 +1641,74 @@ object SchemaValidator { } } + def validateUniqueFields(schema: Schema): List[Problem] = { + val withFields = schema.types.collect { + case wf: TypeWithFields => wf + } + + val inputObjs = schema.types.collect { + case io: InputObjectType => io + } + + withFields.flatMap { tpe => + val dupes = tpe.fields.groupBy(_.name).collect { + case (nme, fields) if fields.length > 1 => nme + }.toSet + + tpe.fields.map(_.name).distinct.collect { + case nme if dupes.contains(nme) => Problem(s"Duplicate definition of field '$nme' for type '${tpe.name}'") + } + } ++ + inputObjs.flatMap { tpe => + val dupes = tpe.inputFields.groupBy(_.name).collect { + case (nme, fields) if fields.length > 1 => nme + }.toSet + + tpe.inputFields.map(_.name).distinct.collect { + case nme if dupes.contains(nme) => Problem(s"Duplicate definition of field '$nme' for type '${tpe.name}'") + } + } + } + + def validateUnionMembers(schema: Schema): List[Problem] = { + val unions = schema.types.collect { + case u: UnionType => u + } + + unions.flatMap { tpe => + val dupes = tpe.members.groupBy(_.name).collect { + case (nme, vs) if vs.length > 1 => nme + }.toSet + + tpe.members.map(_.name).distinct.collect { + case nme if dupes.contains(nme) => Problem(s"Duplicate inclusion of union member '$nme' for type '${tpe.name}'") + } ++ + tpe.members.flatMap { member => + schema.definition(member.name) match { + case None => List(Problem(s"Undefined type '${member.name}' included in union '${tpe.name}'")) + case Some(mtpe) => + mtpe match { + case (_: ObjectType) | (_: InterfaceType) => Nil + case _ => List(Problem(s"Non-object type '${member.name}' included in union '${tpe.name}'")) + } + } + } + } + } + def validateUniqueEnumValues(schema: Schema): List[Problem] = { val enums = schema.types.collect { case e: EnumType => e } - enums.flatMap { e => - val duplicateValues = e.enumValues.groupBy(_.name).collect { case (nme, vs) if vs.length > 1 => nme }.toList - duplicateValues.map(dupe => Problem(s"Duplicate definition of enum value '$dupe' for Enum type '${e.name}'")) + enums.flatMap { tpe => + val dupes = tpe.enumValues.groupBy(_.name).collect { + case (nme, vs) if vs.length > 1 => nme + }.toSet + + tpe.enumValues.map(_.name).distinct.collect { + case nme if dupes.contains(nme) => Problem(s"Duplicate definition of enum value '$nme' for type '${tpe.name}'") + } } } @@ -1449,6 +1740,8 @@ object SchemaValidator { rp ++ ap }.getOrElse(List(Problem(s"Field '${ifField.name}' from interface '${iface.name}' is not defined by implementing type '$name'"))) } + case undefined: TypeRef => + List(Problem(s"Undefined type '${undefined.name}' declared as implemented by type '$name'")) case other => List(Problem(s"Non-interface type '${other.name}' declared as implemented by type '$name'")) }) @@ -1457,29 +1750,80 @@ object SchemaValidator { val impls = schema.types.collect { case impl: TypeWithFields => impl } impls.flatMap(validateImplementor) } + + def validateTypeExtensions(defns: List[TypeDefinition], extnDefns: List[Ast.TypeExtension]): List[Problem] = { + extnDefns.mapFilter { extension => + val notFound = Some(Problem(s"Unable apply extension to non-existent ${extension.baseType.name}")) + defns.find(_.name == extension.baseType.astName).fold[Option[Problem]](notFound) { baseType => + def wrongTypeExtended(typ: String) = Problem(s"Attempted to apply $typ extension to ${baseType.name.value} but it is not a $typ").some + + extension match { + case _: Ast.ScalarTypeExtension => + baseType match { + case _: Ast.ScalarTypeDefinition => None + case _ => wrongTypeExtended("Scalar") + } + case _: Ast.InterfaceTypeExtension => + baseType match { + case _: Ast.InterfaceTypeDefinition => None + case _ => wrongTypeExtended("Interface") + } + case _: Ast.ObjectTypeExtension => + baseType match { + case _: Ast.ObjectTypeDefinition => None + case _ => wrongTypeExtended("Object") + } + case _: Ast.UnionTypeExtension => + baseType match { + case _: Ast.UnionTypeDefinition => None + case _ => wrongTypeExtended("Union") + } + case _: Ast.EnumTypeExtension => + baseType match { + case _: Ast.EnumTypeDefinition => None + case _ => wrongTypeExtended("Enum") + } + case _: Ast.InputObjectTypeExtension => + baseType match { + case _: Ast.InputObjectTypeDefinition => None + case _ => wrongTypeExtended("Input Object") + } + } + } + } + } } object SchemaRenderer { def renderSchema(schema: Schema): String = { - def mkRootDef(fieldName: String)(tpe: NamedType): String = - s"$fieldName: ${tpe.name}" - - val fields = - mkRootDef("query")(schema.queryType) :: - List( - schema.mutationType.map(mkRootDef("mutation")), - schema.subscriptionType.map(mkRootDef("subscription")) - ).flatten - val schemaDefn = { - val dirs0 = schema.schemaType.directives - if (fields.sizeCompare(1) == 0 && schema.queryType =:= schema.ref("Query") && dirs0.isEmpty) "" + val dirs0 = schema.baseSchemaType.directives + if ( + schema.queryType.name == "Query" && + schema.mutationType.map(_.name == "Mutation").getOrElse(true) && + schema.subscriptionType.map(_.name == "Subscription").getOrElse(true) && + dirs0.isEmpty + ) "" else { + val fields = + schema.baseSchemaType match { + case twf: TypeWithFields => twf.fields.map(renderField) + case _ => Nil + } + val dirs = renderDirectives(dirs0) fields.mkString(s"schema$dirs {\n ", "\n ", "\n}\n") } } + val schemaExtnDefns = + if(schema.schemaExtensions.isEmpty) "" + else "\n"+schema.schemaExtensions.map(renderSchemaExtension).mkString("\n") + + val typeExtnDefns = + if(schema.typeExtensions.isEmpty) "" + else "\n"+schema.typeExtensions.map(renderTypeExtension).mkString("\n") + val dirDefns = { val nonBuiltInDefns = schema.directives.filter { @@ -1492,7 +1836,9 @@ object SchemaRenderer { } schemaDefn ++ - schema.types.map(renderTypeDefn).mkString("\n") ++ + schema.baseTypes.map(renderTypeDefn).mkString("\n") ++ + schemaExtnDefns ++ + typeExtnDefns ++ dirDefns } @@ -1511,16 +1857,91 @@ object SchemaRenderer { s"@$name$args" } - def renderTypeDefn(tpe: NamedType): String = { - def renderField(f: Field): String = { - val Field(nme, _, args, tpe, dirs0) = f - val dirs = renderDirectives(dirs0) - if (args.isEmpty) - s"$nme: ${renderType(tpe)}$dirs" + def renderField(f: Field): String = { + val Field(nme, _, args, tpe, dirs0) = f + val dirs = renderDirectives(dirs0) + if (args.isEmpty) + s"$nme: ${renderType(tpe)}$dirs" + else + s"$nme(${args.map(renderInputValue).mkString(", ")}): ${renderType(tpe)}$dirs" + } + + def renderSchemaExtension(extension: SchemaExtension): String = { + val SchemaExtension(ops0, dirs0) = extension + val dirs = renderDirectives(dirs0) + val ops = + if (ops0.isEmpty) "" else - s"$nme(${args.map(renderInputValue).mkString(", ")}): ${renderType(tpe)}$dirs" + s"""| { + | ${ops0.map(renderField).mkString("\n ")} + |}""".stripMargin + + s"extend schema$dirs$ops" + } + + def renderTypeExtension(extension: TypeExtension): String = { + extension match { + case ScalarExtension(nme, dirs0) => + val dirs = renderDirectives(dirs0) + s"extend scalar $nme$dirs" + + case ObjectExtension(nme, fields0, ifs0, dirs0) => + val ifs = if (ifs0.isEmpty) "" else " implements " + ifs0.map(_.name).mkString("&") + val dirs = renderDirectives(dirs0) + val fields = + if(fields0.isEmpty) "" + else + s"""| { + | ${fields0.map(renderField).mkString("\n ")} + |}""".stripMargin + + s"extend type $nme$ifs$dirs$fields" + + case InterfaceExtension(nme, fields0, ifs0, dirs0) => + val ifs = if (ifs0.isEmpty) "" else " implements " + ifs0.map(_.name).mkString("&") + val dirs = renderDirectives(dirs0) + val fields = + if(fields0.isEmpty) "" + else + s"""| { + | ${fields0.map(renderField).mkString("\n ")} + |}""".stripMargin + + s"extend interface $nme$ifs$dirs$fields" + + case UnionExtension(nme, members0, dirs0) => + val dirs = renderDirectives(dirs0) + val members = + if(members0.isEmpty) "" + else s" = ${members0.map(_.name).mkString(" | ")}" + + s"extend union $nme$dirs$members" + + case EnumExtension(nme, values0, dirs0) => + val dirs = renderDirectives(dirs0) + val values = + if(values0.isEmpty) "" + else + s"""| { + | ${values0.map(renderEnumValueDefinition).mkString("\n ")} + |}""".stripMargin + + s"extend enum $nme$dirs$values" + + case InputObjectExtension(nme, fields0, dirs0) => + val dirs = renderDirectives(dirs0) + val fields = + if(fields0.isEmpty) "" + else + s"""| { + | ${fields0.map(renderInputValue).mkString("\n ")} + |}""".stripMargin + + s"extend input $nme$dirs$fields" } + } + def renderTypeDefn(tpe: NamedType): String = { tpe match { case tr: TypeRef => renderTypeDefn(tr.dealias) diff --git a/modules/core/src/test/scala/extensions/ExtensionsSuite.scala b/modules/core/src/test/scala/extensions/ExtensionsSuite.scala new file mode 100644 index 00000000..4a919851 --- /dev/null +++ b/modules/core/src/test/scala/extensions/ExtensionsSuite.scala @@ -0,0 +1,706 @@ +// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2023 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 extensions + +import cats.data.NonEmptyChain +import munit.CatsEffectSuite + +import grackle._ +import grackle.syntax._ + +final class ExtensionsSuite extends CatsEffectSuite { + test("valid schema extension") { + val schema = + schema""" + schema { + query: Foo + } + + extend schema @Sch { + mutation: Bar + } + + type Foo { + foo: String + } + + type Bar { + bar: String + } + + directive @Sch on SCHEMA + """ + + assertEquals(schema.mutationType.map(_.name), Some("Bar")) + assertEquals(schema.schemaType.directives.map(_.name), List("Sch")) + } + + test("valid schema extension (no operations)") { + val schema = + schema""" + schema { + query: Foo + } + + extend schema @Sch + + type Foo { + foo: String + } + + directive @Sch on SCHEMA + """ + + assertEquals(schema.schemaType.directives.map(_.name), List("Sch")) + } + + test("valid scalar extension") { + val schema = + schema""" + schema { + query: Foo + } + + scalar Foo + + extend scalar Foo @Sca + + directive @Sca on SCALAR + """ + + schema.definition("Foo") match { + case Some(scalar: ScalarType) => + assertEquals(scalar.directives.map(_.name), List("Sca")) + case _ => fail("Foo type not found") + } + } + + test("valid object extension") { + val schema = + schema""" + type Query { + foo: Human + } + + type Human { + name: String + } + + interface Animal { + species: String + } + + extend type Human implements Animal @Obj { + id: ID! + species: String + } + + directive @Obj on OBJECT + """ + + schema.definition("Human") match { + case Some(obj: ObjectType) => + assertEquals(obj.fields.map(_.name), List("name", "id", "species")) + assertEquals(obj.interfaces.map(_.name), List("Animal")) + assertEquals(obj.directives.map(_.name), List("Obj")) + case _ => fail("Human type not found") + } + } + + test("valid object extension (no fields)") { + val schema = + schema""" + type Query { + foo: Human + } + + type Human { + name: String + } + + interface Animal { + name: String + } + + extend type Human implements Animal @Obj + + directive @Obj on OBJECT + """ + + schema.definition("Human") match { + case Some(obj: ObjectType) => + assertEquals(obj.interfaces.map(_.name), List("Animal")) + assertEquals(obj.directives.map(_.name), List("Obj")) + case _ => fail("Human type not found") + } + } + + test("valid interface extension") { + val schema = + schema""" + type Query { + foo: Human + } + + type Human implements Animal { + name: String + species: String + } + + interface Animal { + species: String + } + + interface Organism { + extinct: Boolean + } + + extend interface Animal implements Organism @Intrf { + id: ID! + extinct: Boolean + } + + extend type Human { + id: ID! + extinct: Boolean + } + + directive @Intrf on INTERFACE + """ + + schema.definition("Animal") match { + case Some(intrf: InterfaceType) => + assertEquals(intrf.fields.map(_.name), List("species", "id", "extinct")) + assertEquals(intrf.interfaces.map(_.name), List("Organism")) + assertEquals(intrf.directives.map(_.name), List("Intrf")) + case _ => fail("Animal type not found") + } + + schema.definition("Human") match { + case Some(obj: ObjectType) => + assertEquals(obj.fields.map(_.name), List("name", "species", "id", "extinct")) + assertEquals(obj.interfaces.map(_.name), List("Animal")) + case _ => fail("Human type not found") + } + } + + test("valid interface extension (no fields)") { + val schema = + schema""" + type Query { + foo: Human + } + + type Human implements Animal { + name: String + species: String + } + + interface Animal { + species: String + } + + interface Organism { + species: String + } + + extend interface Animal implements Organism @Intrf + + directive @Intrf on INTERFACE + """ + + schema.definition("Animal") match { + case Some(intrf: InterfaceType) => + assertEquals(intrf.interfaces.map(_.name), List("Organism")) + assertEquals(intrf.directives.map(_.name), List("Intrf")) + case _ => fail("Animal type not found") + } + } + + test("valid union extension") { + val schema = + schema""" + type Query { + foo: Human + } + + type Human { + name: String + } + + type Dog { + name: String + } + + type Cat { + name: String + } + + union Animal = Human | Dog + + extend union Animal @Uni = Cat + + directive @Uni on UNION + """ + + schema.definition("Animal") match { + case Some(u: UnionType) => + assertEquals(u.members.map(_.name), List("Human", "Dog", "Cat")) + assertEquals(u.directives.map(_.name), List("Uni")) + case _ => fail("Animal type not found") + } + } + + test("valid union extension (no members)") { + val schema = + schema""" + type Query { + foo: Human + } + + type Human { + name: String + } + + type Dog { + name: String + } + + union Animal = Human | Dog + + extend union Animal @Uni + + directive @Uni on UNION + """ + + schema.definition("Animal") match { + case Some(u: UnionType) => + assertEquals(u.directives.map(_.name), List("Uni")) + case _ => fail("Animal type not found") + } + } + + test("valid enum extension") { + val schema = + schema""" + type Query { + foo: Animal + } + + enum Animal { Human, Dog } + + extend enum Animal @Enu { Cat } + + directive @Enu on ENUM + """ + + schema.definition("Animal") match { + case Some(e: EnumType) => + assertEquals(e.enumValues.map(_.name), List("Human", "Dog", "Cat")) + assertEquals(e.directives.map(_.name), List("Enu")) + case _ => fail("Animal type not found") + } + } + + test("valid enum extension (no values)") { + val schema = + schema""" + type Query { + foo: Animal + } + + enum Animal { Human, Dog } + + extend enum Animal @Enu + + directive @Enu on ENUM + """ + + schema.definition("Animal") match { + case Some(e: EnumType) => + assertEquals(e.directives.map(_.name), List("Enu")) + case _ => fail("Animal type not found") + } + } + + test("valid input object extension") { + val schema = + schema""" + type Query { + foo(arg: Animal): Int + } + + input Animal { + name: String + } + + extend input Animal @Inp { + species: String + } + + directive @Inp on INPUT_OBJECT + """ + + schema.definition("Animal") match { + case Some(inp: InputObjectType) => + assertEquals(inp.inputFields.map(_.name), List("name", "species")) + assertEquals(inp.directives.map(_.name), List("Inp")) + case _ => fail("Animal type not found") + } + } + + test("valid input object extension (no fields)") { + val schema = + schema""" + type Query { + foo(arg: Animal): Int + } + + input Animal { + name: String + } + + extend input Animal @Inp + + directive @Inp on INPUT_OBJECT + """ + + schema.definition("Animal") match { + case Some(inp: InputObjectType) => + assertEquals(inp.directives.map(_.name), List("Inp")) + case _ => fail("Animal type not found") + } + } + + test("invalid extension on incorrect type") { + val schema = Schema( + """ + type Query { + foo: Scalar + } + + scalar Scalar + type Object { + id: String! + } + interface Interface { + id: String! + } + union Union = Object | Interface + enum Enum { A, B, C } + input Input { id: String! } + + extend type Scalar @Sca + extend interface Object @Obj + extend union Interface @Intrf + extend enum Union @Uni + extend input Enum @Enu + extend scalar Input @Inp + + directive @Sca on SCALAR + directive @Obj on OBJECT + directive @Intrf on INTERFACE + directive @Uni on UNION + directive @Enu on ENUM + directive @Inp on INPUT_OBJECT + """ + ) + + val expected = + NonEmptyChain( + "Attempted to apply Object extension to Scalar but it is not a Object", + "Attempted to apply Interface extension to Object but it is not a Interface", + "Attempted to apply Union extension to Interface but it is not a Union", + "Attempted to apply Enum extension to Union but it is not a Enum", + "Attempted to apply Input Object extension to Enum but it is not a Input Object", + "Attempted to apply Scalar extension to Input but it is not a Scalar" + ) + + schema match { + case Result.Failure(a) => + assertEquals(a.map(_.message), expected) + case unexpected => fail(s"This was unexpected: $unexpected") + } + } + + test("invalid extension of non-existent type") { + val schema = Schema( + """ + type Query { + foo: Int + } + + extend scalar Scalar + extend interface Interface + extend type Object + extend union Union + extend enum Enum + extend input Input + """ + ) + + val expected = + NonEmptyChain( + "Unable apply extension to non-existent Scalar", + "Unable apply extension to non-existent Interface", + "Unable apply extension to non-existent Object", + "Unable apply extension to non-existent Union", + "Unable apply extension to non-existent Enum", + "Unable apply extension to non-existent Input" + ) + + schema match { + case Result.Failure(a) => + assertEquals(a.map(_.message), expected) + case unexpected => fail(s"This was unexpected: $unexpected") + } + } + + test("invalid schema extension") { + val schema = Schema( + """ + schema { + query: Foo + } + + extend schema @Sca + + type Foo { + foo: String + } + + directive @Sca on SCALAR + """ + ) + + val expected = + NonEmptyChain( + "Directive 'Sca' is not allowed on SCHEMA" + ) + + schema match { + case Result.Failure(a) => + assertEquals(a.map(_.message), expected) + case unexpected => fail(s"This was unexpected: $unexpected") + } + } + + test("invalid scalar extension") { + val schema = Schema( + """ + schema { + query: Foo + } + + scalar Foo + + extend scalar Foo @Obj + + directive @Obj on OBJECT + """ + ) + + val expected = + NonEmptyChain( + "Directive 'Obj' is not allowed on SCALAR" + ) + + schema match { + case Result.Failure(a) => + assertEquals(a.map(_.message), expected) + case unexpected => fail(s"This was unexpected: $unexpected") + } + } + + test("invalid object extension") { + val schema = Schema( + """ + type Query { + foo: Human + } + + type Human { + name: String + } + + interface Animal { + species: String + } + + extend type Human implements Animal & Organism @Sca { + name: String + } + + directive @Sca on SCALAR + """ + ) + + val expected = + NonEmptyChain( + "Duplicate definition of field 'name' for type 'Human'", + "Field 'species' from interface 'Animal' is not defined by implementing type 'Human'", + "Undefined type 'Organism' declared as implemented by type 'Human'", + "Directive 'Sca' is not allowed on OBJECT" + ) + + schema match { + case Result.Failure(a) => + assertEquals(a.map(_.message), expected) + case unexpected => fail(s"This was unexpected: $unexpected") + } + } + + test("invalid interface extension") { + val schema = Schema( + """ + type Query { + foo: Human + } + + type Human implements Animal { + name: String + species: String + } + + interface Animal { + species: String + } + + interface Organism { + extinct: Boolean + } + + extend interface Animal implements Organism & Mineral @Sca { + species: String + } + + directive @Sca on SCALAR + """ + ) + + val expected = + NonEmptyChain( + "Duplicate definition of field 'species' for type 'Animal'", + "Field 'extinct' from interface 'Organism' is not defined by implementing type 'Animal'", + "Undefined type 'Mineral' declared as implemented by type 'Animal'", + "Directive 'Sca' is not allowed on INTERFACE" + ) + + schema match { + case Result.Failure(a) => + assertEquals(a.map(_.message), expected) + case unexpected => fail(s"This was unexpected: $unexpected") + } + } + + test("invalid union extension") { + val schema = Schema( + """ + type Query { + foo: Human + } + + type Human { + name: String + } + + type Dog { + name: String + } + + union Animal = Human | Dog + + extend union Animal @Sca = Dog | Cat | Int + + directive @Sca on SCALAR + """ + ) + + val expected = + NonEmptyChain( + "Duplicate inclusion of union member 'Dog' for type 'Animal'", + "Undefined type 'Cat' included in union 'Animal'", + "Non-object type 'Int' included in union 'Animal'", + "Directive 'Sca' is not allowed on UNION" + ) + + schema match { + case Result.Failure(a) => + assertEquals(a.map(_.message), expected) + case unexpected => fail(s"This was unexpected: $unexpected") + } + } + + test("invalid enum extension") { + val schema = Schema( + """ + type Query { + foo: Animal + } + + enum Animal { Human, Dog } + + extend enum Animal @Sca { Dog } + + directive @Sca on SCALAR + """ + ) + + val expected = + NonEmptyChain( + "Duplicate definition of enum value 'Dog' for type 'Animal'", + "Directive 'Sca' is not allowed on ENUM" + ) + + schema match { + case Result.Failure(a) => + assertEquals(a.map(_.message), expected) + case unexpected => fail(s"This was unexpected: $unexpected") + } + } + + test("invalid input object extension") { + val schema = Schema( + """ + type Query { + foo(arg: Animal): Int + } + + input Animal { + name: String + } + + extend input Animal @Sca { + name: String + } + + directive @Sca on SCALAR + """ + ) + + val expected = + NonEmptyChain( + "Duplicate definition of field 'name' for type 'Animal'", + "Directive 'Sca' is not allowed on INPUT_OBJECT" + ) + + schema match { + case Result.Failure(a) => + assertEquals(a.map(_.message), expected) + case unexpected => fail(s"This was unexpected: $unexpected") + } + } +} diff --git a/modules/core/src/test/scala/parser/ParserSuite.scala b/modules/core/src/test/scala/parser/ParserSuite.scala index 9f204835..039f9a40 100644 --- a/modules/core/src/test/scala/parser/ParserSuite.scala +++ b/modules/core/src/test/scala/parser/ParserSuite.scala @@ -579,4 +579,35 @@ final class ParserSuite extends CatsEffectSuite { assertParse("\"\"\" \n\n first\n \tλ\n 123\n\n\n \t\n\n\"\"\"", StringValue(" first\n \tλ\n123")) } + test("parse object type extension") { + val schema = """ + extend type Foo { + bar: Int + } + """ + + val expected = + List( + ObjectTypeExtension(Named(Name("Foo")), List(FieldDefinition(Name("bar"),None,Nil,Named(Name("Int")),Nil)), Nil, Nil) + ) + + val res = GraphQLParser.Document.parseAll(schema).toOption + assert(res == Some(expected)) + } + + test("parse schema extension") { + val schema = """ + extend schema { + query: Query + } + """ + + val expected = + List( + SchemaExtension(List(RootOperationTypeDefinition(OperationType.Query, Named(Name("Query")), Nil)), Nil) + ) + + val res = GraphQLParser.Document.parseAll(schema).toOption + assert(res == Some(expected)) + } } diff --git a/modules/core/src/test/scala/schema/SchemaSuite.scala b/modules/core/src/test/scala/schema/SchemaSuite.scala index 41b25096..fcfd9709 100644 --- a/modules/core/src/test/scala/schema/SchemaSuite.scala +++ b/modules/core/src/test/scala/schema/SchemaSuite.scala @@ -129,7 +129,7 @@ final class SchemaSuite extends CatsEffectSuite { ) schema match { - case Result.Failure(ps) => assertEquals(ps.map(_.message), NonEmptyChain("Duplicate definition of enum value 'NORTH' for Enum type 'Direction'")) + case Result.Failure(ps) => assertEquals(ps.map(_.message), NonEmptyChain("Duplicate definition of enum value 'NORTH' for type 'Direction'")) case unexpected => fail(s"This was unexpected: $unexpected") } } @@ -148,10 +148,8 @@ final class SchemaSuite extends CatsEffectSuite { assertEquals( ps.map(_.message), NonEmptyChain( - "Reference to undefined type 'Character'", - "Reference to undefined type 'Contactable'", - "Non-interface type 'Character' declared as implemented by type 'Human'", - "Non-interface type 'Contactable' declared as implemented by type 'Human'" + "Undefined type 'Character' declared as implemented by type 'Human'", + "Undefined type 'Contactable' declared as implemented by type 'Human'" ) ) case unexpected => fail(s"This was unexpected: $unexpected") diff --git a/modules/core/src/test/scala/sdl/SDLSuite.scala b/modules/core/src/test/scala/sdl/SDLSuite.scala index 9aa50c2d..eb888071 100644 --- a/modules/core/src/test/scala/sdl/SDLSuite.scala +++ b/modules/core/src/test/scala/sdl/SDLSuite.scala @@ -312,4 +312,111 @@ final class SDLSuite extends CatsEffectSuite { assertEquals(ser, schema.success) } + + test("round-trip extensions") { + val schema = + """|schema { + | query: MyQuery + |} + |type MyQuery { + | foo(s: Scalar, i: Input, e: Enum): Union + |} + |type Mutation { + | bar: Int + |} + |scalar Scalar + |interface Intrf { + | bar: String + |} + |type Obj implements Intrf { + | bar: String + |} + |union Union = Intrf | Obj + |enum Enum { + | A + | B + |} + |input Input { + | baz: Float + |} + |type Quux { + | quux: String + |} + |extend schema @Sch { + | mutation: Mutation + |} + |extend scalar Scalar @Sca + |extend interface Intrf @Intrf { + | baz: Boolean + |} + |extend type Obj @Obj { + | baz: Boolean + | quux: String + |} + |extend union Union @Uni = Quux + |extend enum Enum @Enu { + | C + |} + |extend input Input @Inp { + | foo: Int + |} + |directive @Sch on SCHEMA + |directive @Sca on SCALAR + |directive @Obj on OBJECT + |directive @Intrf on INTERFACE + |directive @Uni on UNION + |directive @Enu on ENUM + |directive @Inp on INPUT_OBJECT + |""".stripMargin + + val res = SchemaParser.parseText(schema) + val ser = res.map(_.toString) + + assertEquals(ser, schema.success) + } + + test("round-trip extensions (no fields or members") { + val schema = + """|schema { + | query: MyQuery + |} + |type MyQuery { + | foo(s: Scalar, i: Input, e: Enum): Union + |} + |scalar Scalar + |interface Intrf { + | bar: String + |} + |type Obj implements Intrf { + | bar: String + |} + |union Union = Intrf | Obj + |enum Enum { + | A + | B + |} + |input Input { + | baz: Float + |} + |extend schema @Sch + |extend scalar Scalar @Sca + |extend interface Intrf @Intrf + |extend type Obj @Obj + |extend union Union @Uni + |extend enum Enum @Enu + |extend input Input @Inp + |directive @Sch on SCHEMA + |directive @Sca on SCALAR + |directive @Obj on OBJECT + |directive @Intrf on INTERFACE + |directive @Uni on UNION + |directive @Enu on ENUM + |directive @Inp on INPUT_OBJECT + |""".stripMargin + + val res = SchemaParser.parseText(schema) + val ser = res.map(_.toString) + + assertEquals(ser, schema.success) + } }