Skip to content

Commit

Permalink
Improve laziness of typeclass derivation (#658)
Browse files Browse the repository at this point in the history
* Improve laziness of typeclass derivation

* Fix build for 2.12

* Rename Exported to Derived

* Improve error messages

* Renaming

* Renaming + improve docs
  • Loading branch information
ghostdogpr authored Dec 6, 2020
1 parent f47d69d commit 7d69874
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 5 deletions.
20 changes: 18 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ val akkaVersion = "2.6.10"
val catsEffectVersion = "2.3.0"
val circeVersion = "0.13.0"
val http4sVersion = "0.21.13"
val magnoliaVersion = "0.17.0"
val mercatorVersion = "0.2.1"
val playVersion = "2.8.5"
val playJsonVersion = "2.9.1"
val silencerVersion = "1.7.1"
Expand Down Expand Up @@ -58,6 +60,7 @@ lazy val root = project
.settings(skip in publish := true)
.settings(crossScalaVersions := Nil)
.aggregate(
macros,
core,
finch,
http4s,
Expand All @@ -74,6 +77,17 @@ lazy val root = project
federation
)

lazy val macros = project
.in(file("macros"))
.settings(name := "caliban-macros")
.settings(commonSettings)
.settings(
libraryDependencies ++= Seq(
"com.propensive" %% "magnolia" % magnoliaVersion,
"com.propensive" %% "mercator" % mercatorVersion
)
)

lazy val core = project
.in(file("core"))
.settings(name := "caliban")
Expand All @@ -82,8 +96,8 @@ lazy val core = project
testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")),
libraryDependencies ++= Seq(
"com.lihaoyi" %% "fastparse" % "2.3.0",
"com.propensive" %% "magnolia" % "0.17.0",
"com.propensive" %% "mercator" % "0.2.1",
"com.propensive" %% "magnolia" % magnoliaVersion,
"com.propensive" %% "mercator" % mercatorVersion,
"dev.zio" %% "zio" % zioVersion,
"dev.zio" %% "zio-streams" % zioVersion,
"dev.zio" %% "zio-query" % zqueryVersion,
Expand All @@ -94,6 +108,7 @@ lazy val core = project
compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1")
)
)
.dependsOn(macros)
.settings(
fork in Test := true,
fork in run := true
Expand Down Expand Up @@ -137,6 +152,7 @@ lazy val codegenSbt = project
},
scriptedBufferLog := false,
scriptedDependencies := {
(macros / publishLocal).value
(core / publishLocal).value
(clientJVM / publishLocal).value
(tools / publishLocal).value
Expand Down
19 changes: 17 additions & 2 deletions core/src/main/scala/caliban/schema/Schema.scala
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ trait GenericSchema[R] extends DerivationSchema[R] with TemporalSchema {

}

trait DerivationSchema[R] {
trait DerivationSchema[R] extends LowPriorityDerivedSchema {

/**
* Default naming logic for input types.
Expand Down Expand Up @@ -570,8 +570,23 @@ trait DerivationSchema[R] {
private def getDescription[Typeclass[_], Type](ctx: ReadOnlyParam[Typeclass, Type]): Option[String] =
getDescription(ctx.annotations)

implicit def gen[T]: Typeclass[T] = macro Magnolia.gen[T]
/**
* Generates an instance of `Schema` for the given type T.
* This should be used only if T is a case class or a sealed trait.
*/
implicit def genMacro[T]: Derived[Typeclass[T]] = macro DerivedMagnolia.derivedMagnolia[Typeclass, T]

/**
* Returns an instance of `Schema` for the given type T.
* For a case class or sealed trait, you can call `genMacro[T].schema` instead to get more details if the
* schema can't be derived.
*/
def gen[T](implicit derived: Derived[Schema[R, T]]): Schema[R, T] = derived.schema

}

private[schema] trait LowPriorityDerivedSchema {
implicit def derivedSchema[R, T](implicit derived: Derived[Schema[R, T]]): Schema[R, T] = derived.schema
}

trait TemporalSchema {
Expand Down
18 changes: 18 additions & 0 deletions core/src/test/scala/caliban/schema/SchemaSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ object SchemaSpec extends DefaultRunnableSpec {
contains("GenericOptionDouble") && contains("GenericOptionInt")
)
},
test("nested types with explicit schema in companion object") {
object blockingSchema extends GenericSchema[Blocking]
import blockingSchema._

case class A(s: String)
object A {
implicit val aSchema: Schema[Blocking, A] = blockingSchema.gen[A]
}
case class B(a: List[Option[A]])

A.aSchema.toType_()

val schema: Schema[Blocking, B] = blockingSchema.gen[B]

assert(Types.collectTypes(schema.toType_()).map(_.name.getOrElse("")))(
not(contains("SomeA")) && not(contains("OptionA")) && not(contains("None"))
)
},
test("UUID field should be converted to ID") {
assert(introspect[IDSchema].fields(__DeprecatedArgs()).toList.flatten.headOption.map(_.`type`()))(
isSome(hasField[__Type, String]("id", _.ofType.flatMap(_.name).get, equalTo("ID")))
Expand Down
14 changes: 14 additions & 0 deletions macros/src/main/scala/caliban/schema/Derived.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package caliban.schema

import scala.annotation.implicitNotFound

@implicitNotFound(
"""Cannot find a ${T}.
Caliban derives a Schema automatically for basic Scala types, case classes and sealed traits, but
you need to manually provide an implicit Schema for other types that could be nested inside your type.
If you use a custom type as an argument, you also need to provide an implicit ArgBuilder for that type.
See https://ghostdogpr.github.io/caliban/docs/schema.html for more information.
"""
)
case class Derived[T](schema: T) extends AnyVal
16 changes: 16 additions & 0 deletions macros/src/main/scala/caliban/schema/DerivedMagnolia.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package caliban.schema

object DerivedMagnolia {
import magnolia.Magnolia

import scala.reflect.macros.whitebox

// Wrap the output of Magnolia in a Derived to force it to a lower priority.
// This seems to work, despite magnolia hardcode checks for `macroApplication` symbol
// and relying on getting an diverging implicit expansion error for auto-mode.
// Thankfully at least it doesn't check the output type of its `macroApplication`
def derivedMagnolia[TC[_], A: c.WeakTypeTag](c: whitebox.Context): c.Expr[Derived[TC[A]]] = {
val magnoliaTree = c.Expr[TC[A]](Magnolia.gen[A](c))
c.universe.reify(Derived(magnoliaTree.splice))
}
}
9 changes: 8 additions & 1 deletion vuepress/docs/docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ If you want Caliban to support other standard types, feel free to [file an issue

::: warning Schema derivation issues
Magnolia (the library used to derive the schema at compile-time) sometimes has some trouble generating schemas with a lot of nested types, or types reused in multiple places.
to deal with this, you can declare schemas for your case classes and sealed traits explicitly:
To deal with this, you can declare schemas for your case classes and sealed traits explicitly:

```scala
implicit val roleSchema = Schema.gen[Role]
Expand All @@ -54,6 +54,13 @@ implicit val characterSchema = Schema.gen[Character]

Make sure those implicits are in scope when you call `graphQL(...)`. This will make Magnolia's job easier by pre-generating schemas for those classes and re-using them when needed.
This will also improve compilation times and generate less bytecode.

If the derivation fails and you're not sure why, you can also call Magnolia's macro directly by using `genMacro`.
The compilation will return better error messages in case something is missing:

```scala
implicit val characterSchema = Schema.genMacro[Character].schema
```
:::

## Enums, unions, interfaces
Expand Down

0 comments on commit 7d69874

Please sign in to comment.