Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add union type redirect #989

Merged
merged 4 commits into from
Aug 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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