Skip to content

Commit

Permalink
Add union type redirect (#989)
Browse files Browse the repository at this point in the history
* Add union type redirect

* Adding scala3 support

* Fix union value type resolution.

* Update docs
  • Loading branch information
paulpdaniels authored Aug 7, 2021
1 parent d1ecafb commit 2750d65
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 8 deletions.
11 changes: 9 additions & 2 deletions core/src/main/scala-2/caliban/schema/SchemaDerivation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 14 additions & 4 deletions core/src/main/scala-3/caliban/schema/SchemaDerivation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)))),
Expand Down Expand Up @@ -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)
Expand All @@ -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 =
Expand Down Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions core/src/main/scala/caliban/schema/Annotations.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
36 changes: 35 additions & 1 deletion core/src/test/scala/caliban/execution/ExecutionSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}]}"""
)
)
}
)
}
34 changes: 33 additions & 1 deletion core/src/test/scala/caliban/schema/SchemaSpec.scala
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")))
}
)

Expand All @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions vuepress/docs/docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down

0 comments on commit 2750d65

Please sign in to comment.