Skip to content

Commit

Permalink
Schema derivation
Browse files Browse the repository at this point in the history
  • Loading branch information
ghostdogpr committed May 2, 2021
1 parent 402f66d commit 935b00c
Show file tree
Hide file tree
Showing 5 changed files with 284 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,5 @@ trait ArgBuilderDerivation {
}
}

inline given argBuilder[A]: ArgBuilder[A] = derived
inline given gen[A]: ArgBuilder[A] = derived
}
241 changes: 241 additions & 0 deletions core/src/main/scala-3/caliban/schema/SchemaDerivation.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
package caliban.schema

import caliban.Rendering
import caliban.Value.EnumValue
import caliban.introspection.adt._
import caliban.parsing.adt.Directive
import caliban.schema.Annotations._
import caliban.schema.Step.ObjectStep
import caliban.schema.Types._
import caliban.schema.macros.{Macros, TypeInfo}

import scala.deriving.Mirror
import scala.compiletime._

trait SchemaDerivation[R] {

/**
Expand All @@ -9,4 +21,233 @@ trait SchemaDerivation[R] {
* By default, we add the "Input" suffix after the type name.
*/
def customizeInputTypeName(name: String): String = s"${name}Input"

inline def recurse[Label, A <: Tuple](index: Int = 0): List[(String, List[Any], TypeInfo, Schema[R, Any], Int)] =
inline erasedValue[(Label, A)] match {
case (_: (name *: names), _: (t *: ts)) =>
val label = constValue[name].toString
val annotations = Macros.annotations[t]
val info = Macros.typeInfo[t]
val builder = summonInline[Schema[R, t]].asInstanceOf[Schema[R, Any]]
(label, annotations, info, builder, index) :: recurse[names, ts](index + 1)
case (_: EmptyTuple, _) => Nil
}

inline def derived[A]: Schema[R, A] =
inline summonInline[Mirror.Of[A]] match {
case m: Mirror.SumOf[A] =>
lazy val subTypes = recurse[m.MirroredElemLabels, m.MirroredElemTypes]()
lazy val info = Macros.typeInfo[A]
lazy val annotations = Macros.annotations[A]
new Schema[R, A] {
def toType(isInput: Boolean, isSubscription: Boolean): __Type = {
val subtypes =
subTypes
.map { case (_, subTypeAnnotations, _, schema, _) => schema.toType_() -> subTypeAnnotations }
.sortBy { case (tpe, _) =>
tpe.name.getOrElse("")
}
val isEnum = subtypes.forall {
case (t, _)
if t.fields(__DeprecatedArgs(Some(true))).forall(_.isEmpty)
&& t.inputFields.forall(_.isEmpty) =>
true
case _ => false
}
if (isEnum && subtypes.nonEmpty)
makeEnum(
Some(getName(annotations, info)),
getDescription(annotations),
subtypes.collect { case (__Type(_, Some(name), description, _, _, _, _, _, _, _, _), annotations) =>
__EnumValue(
name,
description,
annotations.collectFirst { case GQLDeprecated(_) => () }.isDefined,
annotations.collectFirst { case GQLDeprecated(reason) => reason }
)
},
Some(info.full)
)
else {
annotations.collectFirst { case GQLInterface() =>
()
}.fold(
makeUnion(
Some(getName(annotations, info)),
getDescription(annotations),
subtypes.map { case (t, _) => fixEmptyUnionObject(t) },
Some(info.full)
)
) { _ =>
val impl = subtypes.map(_._1.copy(interfaces = () => Some(List(toType(isInput, isSubscription)))))
val commonFields = impl
.flatMap(_.fields(__DeprecatedArgs(Some(true))))
.flatten
.groupBy(_.name)
.collect {
case (name, list)
if impl.forall(_.fields(__DeprecatedArgs(Some(true))).getOrElse(Nil).exists(_.name == name)) &&
list.map(t => Types.name(t.`type`())).distinct.length == 1 =>
list.headOption
}
.flatten

makeInterface(Some(getName(annotations, info)), getDescription(annotations), commonFields.toList, impl, Some(info.full))
}
}
}

def resolve(value: A): Step[R] = {
val (_, _, _, schema, _) = subTypes(m.ordinal(value))
schema.resolve(value)
}
}
case m: Mirror.ProductOf[A] =>
lazy val fields = recurse[m.MirroredElemLabels, m.MirroredElemTypes]()
lazy val isValueClass = Macros.isValueClass[A]
lazy val isObject = Macros.isObject[A]
lazy val info = Macros.typeInfo[A]
lazy val annotations = Macros.annotations[A]
new Schema[R, A] {
def toType(isInput: Boolean, isSubscription: Boolean): __Type =
if (isValueClass && fields.nonEmpty) fields.head._4.toType_(isInput, isSubscription)
else if (isInput)
makeInputObject(
Some(annotations.collectFirst { case GQLInputName(suffix) => suffix }
.getOrElse(customizeInputTypeName(getName(annotations, info)))),
getDescription(annotations),
fields
.map { case (label, fieldAnnotations, _, schema, _) =>
__InputValue(
getName(fieldAnnotations, label),
getDescription(fieldAnnotations),
() =>
if (schema.optional) schema.toType_(isInput, isSubscription)
else makeNonNull(schema.toType_(isInput, isSubscription)),
None,
Some(fieldAnnotations.collect { case GQLDirective(dir) => dir }).filter(_.nonEmpty)
)
},
Some(info.full)
)
else
makeObject(
Some(getName(annotations, info)),
getDescription(annotations),
fields
.map { case (label, fieldAnnotations, _, schema, _) =>
__Field(
getName(fieldAnnotations, label),
getDescription(fieldAnnotations),
schema.arguments,
() =>
if (schema.optional) schema.toType_(isInput, isSubscription)
else makeNonNull(schema.toType_(isInput, isSubscription)),
fieldAnnotations.collectFirst { case GQLDeprecated(_) => () }.isDefined,
fieldAnnotations.collectFirst { case GQLDeprecated(reason) => reason },
Option(fieldAnnotations.collect { case GQLDirective(dir) => dir }).filter(_.nonEmpty)
)
},
getDirectives(annotations),
Some(info.full)
)

def resolve(value: A): Step[R] =
if (isObject) PureStep(EnumValue(getName(annotations, info)))
else if (isValueClass && fields.nonEmpty) {
val (_, _, _, schema, index) = fields.head
schema.resolve(value.asInstanceOf[Product].productElement(index))
} else {
val fieldsBuilder = Map.newBuilder[String, Step[R]]
fields.foreach { case (label, fieldAnnotations, _, schema, index) =>
fieldsBuilder += getName(fieldAnnotations, label) -> schema.resolve(value.asInstanceOf[Product].productElement(index))
}
ObjectStep(getName(annotations, info), fieldsBuilder.result())
}
}
}

// see https://github.com/graphql/graphql-spec/issues/568
private def fixEmptyUnionObject(t: __Type): __Type =
t.fields(__DeprecatedArgs(Some(true))) match {
case Some(Nil) =>
t.copy(
fields = (_: __DeprecatedArgs) =>
Some(
List(
__Field(
"_",
Some(
"Fake field because GraphQL does not support empty objects. Do not query, use __typename instead."
),
Nil,
() => makeScalar("Boolean")
)
)
)
)
case _ => t
}

private def getName(annotations: Seq[Any], info: TypeInfo): String =
annotations.collectFirst { case GQLName(name) => name }.getOrElse {
info.typeParams match {
case Nil => info.short
case args => info.short + args.map(getName(Nil, _)).mkString
}
}

private def getName(annotations: Seq[Any], label: String): String =
annotations.collectFirst { case GQLName(name) => name }.getOrElse(label)

private def getDescription(annotations: Seq[Any]): Option[String] =
annotations.collectFirst { case GQLDescription(desc) => desc }

private def getDirectives(annotations: Seq[Any]): List[Directive] =
annotations.collect { case GQLDirective(dir) => dir }.toList

inline given gen[A]: Schema[R, A] = derived
}

object Test {

sealed trait SomeTrait
object SomeTrait {
case class B(a: Int) extends SomeTrait
case class C(a: Int) extends SomeTrait
}

sealed trait SomeOtherTrait extends SomeTrait
object SomeOtherTrait {
case class D(a: Int) extends SomeOtherTrait
}

summon[Schema[Any, SomeTrait]]

// case class A(f: Int, g: String)
// case class B(f: Int, g: Option[B])
//
// implicit lazy val bSchema: Schema[Any, B] = Schema.gen
//
// sealed trait Enum
// object Enum {
// case object E1 extends Enum
// case object E2 extends Enum
// }
//
// sealed trait Union
// object Union {
// case class U1(a: Int) extends Union
// case class U2(b: String) extends Union
// }
//
// println(Rendering.renderTypes(summon[Schema[Any, Int]].toType_() :: Nil))
// println(Rendering.renderTypes(summon[Schema[Any, A]].toType_() :: Nil))
// println(Rendering.renderTypes(summon[Schema[Any, B]].toType_() :: Nil))
// println(Rendering.renderTypes(summon[Schema[Any, Enum]].toType_() :: Nil))
// println(Rendering.renderTypes(summon[Schema[Any, Union]].toType_() :: Nil))
// println(summon[Schema[Any, Union]].resolve(Union.U1(2)))
// println(summon[Schema[Any, Enum]].resolve(Enum.E2))

@main def hello = println("hello")
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ trait SubscriptionSchemaDerivation {
new SubscriptionSchema[A] {}
}

inline given subscriptionSchema[A]: SubscriptionSchema[A] = derived
inline given gen[A]: SubscriptionSchema[A] = derived
}
40 changes: 36 additions & 4 deletions core/src/main/scala-3/caliban/schema/macros/Macros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ package caliban.schema.macros
import scala.quoted.*

private[caliban] object Macros {
// this code was inspired from WIP in magnolia
// https://github.com/propensive/magnolia/blob/b937cf2c7dabebb8236e7e948f37a354777fa9b7/src/core/macro.scala

inline def annotations[T]: List[Any] = ${annotationsImpl[T]}
inline def paramAnnotations[T]: List[(String, List[Any])] = ${paramAnnotationsImpl[T]}
inline def isValueClass[T]: Boolean = ${isValueClassImpl[T]}
inline def typeInfo[T]: TypeInfo = ${typeInfoImpl[T]}
inline def isObject[T]: Boolean = ${isObjectImpl[T]}

def annotationsImpl[T: Type](using qctx: Quotes): Expr[List[Any]] = {
import qctx.reflect.*

val tpe = TypeRepr.of[T]

Expr.ofList {
tpe.typeSymbol.annotations.filter { a =>
a.tpe.typeSymbol.maybeOwner.isNoSymbol || a.tpe.typeSymbol.owner.fullName != "scala.annotation.internal"
Expand All @@ -20,9 +24,7 @@ private[caliban] object Macros {

def paramAnnotationsImpl[T: Type](using qctx: Quotes): Expr[List[(String, List[Any])]] = {
import qctx.reflect.*

val tpe = TypeRepr.of[T]

Expr.ofList {
tpe.typeSymbol.primaryConstructor.paramSymss.flatten.map { field =>
Expr(field.name) -> field.annotations.filter { a =>
Expand All @@ -32,4 +34,34 @@ private[caliban] object Macros {
}.filter(_._2.nonEmpty).map { (name, anns) => Expr.ofTuple(name, Expr.ofList(anns)) }
}
}

def isValueClassImpl[T: Type](using qctx: Quotes): Expr[Boolean] = {
import qctx.reflect.*
Expr(TypeRepr.of[T].baseClasses.contains(Symbol.classSymbol("scala.AnyVal")))
}

def typeInfoImpl[T: Type](using qctx: Quotes): Expr[TypeInfo] = {
import qctx.reflect._

def normalizedName(s: Symbol): String = if s.flags.is(Flags.Module) then s.name.stripSuffix("$") else s.name
def name(tpe: TypeRepr) : Expr[String] = Expr(normalizedName(tpe.typeSymbol))

def owner(tpe: TypeRepr): Expr[String] =
if tpe.typeSymbol.maybeOwner.isNoSymbol then Expr("<no owner>")
else if (tpe.typeSymbol.owner == defn.EmptyPackageClass) Expr("")
else Expr(tpe.typeSymbol.owner.name)

def typeInfo(tpe: TypeRepr): Expr[TypeInfo] = tpe match
case AppliedType(tpe, args) =>
'{TypeInfo(${owner(tpe)}, ${name(tpe)}, ${Expr.ofList(args.map(typeInfo))})}
case _ =>
'{TypeInfo(${owner(tpe)}, ${name(tpe)}, Nil)}

typeInfo(TypeRepr.of[T])
}

def isObjectImpl[T](using qctx: Quotes, tpe: Type[T]): Expr[Boolean] = {
import qctx.reflect.*
Expr(TypeRepr.of[T].typeSymbol.flags.is(Flags.Module))
}
}
5 changes: 5 additions & 0 deletions core/src/main/scala-3/caliban/schema/macros/TypeInfo.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package caliban.schema.macros

case class TypeInfo(owner: String, short: String, typeParams: Iterable[TypeInfo]) {
def full: String = s"$owner.$short"
}

0 comments on commit 935b00c

Please sign in to comment.