diff --git a/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala b/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala index 1b0dce9d9..6fcd78bac 100644 --- a/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala +++ b/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala @@ -22,9 +22,16 @@ trait SchemaDerivation[R] extends LowPriorityDerivedSchema { type Typeclass[T] = Schema[R, T] + def isValueType[T](ctx: ReadOnlyCaseClass[Typeclass, T]): Boolean = + ctx.annotations.exists { + case GQLValueType() => true + case _ => false + } + def combine[T](ctx: ReadOnlyCaseClass[Typeclass, T]): Typeclass[T] = new Typeclass[T] { override def toType(isInput: Boolean, isSubscription: Boolean): __Type = - if (ctx.isValueClass && ctx.parameters.nonEmpty) ctx.parameters.head.typeclass.toType_(isInput, isSubscription) + if ((ctx.isValueClass || isValueType(ctx)) && ctx.parameters.nonEmpty) + ctx.parameters.head.typeclass.toType_(isInput, isSubscription) else if (isInput) makeInputObject( Some(ctx.annotations.collectFirst { case GQLInputName(suffix) => suffix } @@ -70,7 +77,7 @@ trait SchemaDerivation[R] extends LowPriorityDerivedSchema { override def resolve(value: T): Step[R] = if (ctx.isObject) PureStep(EnumValue(getName(ctx))) - else if (ctx.isValueClass && ctx.parameters.nonEmpty) { + else if ((ctx.isValueClass || isValueType(ctx)) && ctx.parameters.nonEmpty) { val head = ctx.parameters.head head.typeclass.resolve(head.dereference(value)) } else { diff --git a/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala b/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala index cf787bf5c..faec25eee 100644 --- a/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala +++ b/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala @@ -105,13 +105,14 @@ trait SchemaDerivation[R] { } } case m: Mirror.ProductOf[A] => + lazy val annotations = Macros.annotations[A] lazy val fields = recurse[m.MirroredElemLabels, m.MirroredElemTypes]() lazy val info = Macros.typeInfo[A] - lazy val annotations = Macros.annotations[A] lazy val paramAnnotations = Macros.paramAnnotations[A].toMap new Schema[R, A] { def toType(isInput: Boolean, isSubscription: Boolean): __Type = - if (isInput) + if (isValueType(annotations) && fields.nonEmpty) fields.head._3.toType_(isInput, isSubscription) + else if (isInput) makeInputObject( Some(annotations.collectFirst { case GQLInputName(suffix) => suffix } .getOrElse(customizeInputTypeName(getName(annotations, info)))), @@ -156,7 +157,10 @@ trait SchemaDerivation[R] { def resolve(value: A): Step[R] = if (fields.isEmpty) PureStep(EnumValue(getName(annotations, info))) - else { + else if (isValueType(annotations) && fields.nonEmpty) { + val head = fields.head + head._3.resolve(value.asInstanceOf[Product].productElement(head._4)) + } else { val fieldsBuilder = Map.newBuilder[String, Step[R]] fields.foreach { case (label, _, schema, index) => val fieldAnnotations = paramAnnotations.getOrElse(label, Nil) @@ -165,7 +169,7 @@ trait SchemaDerivation[R] { ObjectStep(getName(annotations, info), fieldsBuilder.result()) } } - } + } // see https://github.com/graphql/graphql-spec/issues/568 private def fixEmptyUnionObject(t: __Type): __Type = @@ -197,6 +201,12 @@ trait SchemaDerivation[R] { } } + private def isValueType(annotations: Seq[Any]): Boolean = + annotations.exists { + case GQLValueType() => true + case _ => false + } + private def getName(annotations: Seq[Any], label: String): String = annotations.collectFirst { case GQLName(name) => name }.getOrElse(label) diff --git a/core/src/main/scala/caliban/schema/Annotations.scala b/core/src/main/scala/caliban/schema/Annotations.scala index 17e8108df..c0154b6ea 100644 --- a/core/src/main/scala/caliban/schema/Annotations.scala +++ b/core/src/main/scala/caliban/schema/Annotations.scala @@ -41,4 +41,9 @@ object Annotations { * Annotation to make a sealed trait a union instead of an enum */ case class GQLUnion() extends StaticAnnotation + + /** + * Annotation to make a union or interface redirect to a value type + */ + case class GQLValueType() extends StaticAnnotation } diff --git a/core/src/test/scala/caliban/execution/ExecutionSpec.scala b/core/src/test/scala/caliban/execution/ExecutionSpec.scala index 176216e12..abff8113e 100644 --- a/core/src/test/scala/caliban/execution/ExecutionSpec.scala +++ b/core/src/test/scala/caliban/execution/ExecutionSpec.scala @@ -9,7 +9,7 @@ import caliban.TestUtils._ import caliban.Value.{ BooleanValue, IntValue, StringValue } import caliban.introspection.adt.__Type import caliban.parsing.adt.LocationInfo -import caliban.schema.Annotations.{ GQLInterface, GQLName } +import caliban.schema.Annotations.{ GQLInterface, GQLName, GQLValueType } import caliban.schema.{ ArgBuilder, Schema, Step, Types } import zio.{ IO, Task, UIO, ZIO } import zio.stream.ZStream @@ -918,6 +918,40 @@ object ExecutionSpec extends DefaultRunnableSpec { """{"foos":[{"id":123,"bar":{"id":234}}]}""" ) ) + }, + testM("union redirect") { + sealed trait Foo + + case class Bar(int: Int, common: Boolean) extends Foo + + case class Baz(value: String, common: Boolean) + + @GQLValueType + case class Redirect(baz: Baz) extends Foo + + case class Queries(foos: List[Foo]) + + val queries = Queries( + List( + Bar(42, common = true), + Redirect(Baz("hello", common = false)) + ) + ) + + val api: GraphQL[Any] = GraphQL.graphQL(RootResolver(queries)) + val interpreter = api.interpreter + val query = gqldoc("""{ + foos { + ... on Bar { common int } + ... on Baz { common value } + } + }""") + + assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))( + equalTo( + """{"foos":[{"common":true,"int":42},{"common":false,"value":"hello"}]}""" + ) + ) } ) } diff --git a/core/src/test/scala/caliban/schema/SchemaSpec.scala b/core/src/test/scala/caliban/schema/SchemaSpec.scala index 1405dc9b0..6ecba5fa6 100644 --- a/core/src/test/scala/caliban/schema/SchemaSpec.scala +++ b/core/src/test/scala/caliban/schema/SchemaSpec.scala @@ -1,8 +1,10 @@ package caliban.schema +import caliban.Rendering + import java.util.UUID import caliban.introspection.adt.{ __DeprecatedArgs, __Type, __TypeKind } -import caliban.schema.Annotations.{ GQLInterface, GQLUnion } +import caliban.schema.Annotations.{ GQLInterface, GQLUnion, GQLValueType } import zio.blocking.Blocking import zio.console.Console import zio.query.ZQuery @@ -126,6 +128,24 @@ object SchemaSpec extends DefaultRunnableSpec { implicit val somethingSchema: Schema[Any, Something] = Schema.gen[Something].rename("SomethingElse") assert(Types.innerType(introspectSubscription[Something]).name)(isSome(equalTo("SomethingElse"))) + }, + test("union redirect") { + case class Queries(union: RedirectingUnion) + + implicit val queriesSchema: Schema[Any, Queries] = Schema.gen[Queries] + + val types = Types.collectTypes(introspect[Queries]) + val subTypes = types.find(_.name.contains("RedirectingUnion")).flatMap(_.possibleTypes) + val fieldNames = + subTypes.toList.flatMap(_.flatMap(_.fields(__DeprecatedArgs()).map(_.map(_.name)))).toSet.flatten + assert(subTypes.map(_.flatMap(_.name)))( + isSome( + hasSameElements( + List("A", "B") + ) + ) + ) && + assert(fieldNames)(hasSameElements(List("common"))) } ) @@ -148,6 +168,18 @@ object SchemaSpec extends DefaultRunnableSpec { case object B extends EnumLikeUnion } + @GQLUnion + sealed trait RedirectingUnion + + object RedirectingUnion { + case class B(common: Int) + + case class A(common: Int) extends RedirectingUnion + + @GQLValueType + case class Redirect(value: B) extends RedirectingUnion + } + @GQLInterface sealed trait EnumLikeInterface object EnumLikeInterface { diff --git a/vuepress/docs/docs/schema.md b/vuepress/docs/docs/schema.md index 559deeed4..3cfeb3002 100644 --- a/vuepress/docs/docs/schema.md +++ b/vuepress/docs/docs/schema.md @@ -122,6 +122,40 @@ type Mechanic { } ``` +If your type needs to be shared between multiple unions you can use the `@GQLValueType` annotation to have caliban +proxy to another type beyond the sealed trait. + +```scala +case class Pilot(callSign: String) + +sealed trait Role +object Role { + case class Captain(shipName: String) extends Role + case class Engineer(specialty: String) extends Role + + @GQLValueType + case class Proxy(pilot: Pilot) +} +``` + +This will produce the following GraphQL Types: + +```graphql +union Role = Captain | Engineer | Pilot + +type Captain { + shipName: String! +} + +type Engineer { + specialty: String! +} + +type Pilot { + callSign: String! +} +``` + If you prefer an `Interface` instead of a `Union` type, add the `@GQLInterface` annotation to your sealed trait. An interface will be created with all the fields that are common to the case classes extending the sealed trait, as long as they return the same type. @@ -181,6 +215,7 @@ Caliban supports a few annotations to enrich data types: - `@GQLDeprecated("reason")` allows deprecating a field or an enum value. - `@GQLInterface` to force a sealed trait generating an interface instead of a union. - `@GQLDirective(directive: Directive)` to add a directive to a field or type. +- `@GQLValueType` forces a type to behave as a value type for derivation. Meaning that caliban will ignore the outer type and take the first case class parameter as the real type. ## Java 8 Time types