Skip to content

Commit

Permalink
[Scala 3] Add annotation to derive all case class methods as graphql …
Browse files Browse the repository at this point in the history
…fields (#2306)
  • Loading branch information
kyri-petrou authored Jun 25, 2024
1 parent 544cdf8 commit 9d83c72
Show file tree
Hide file tree
Showing 3 changed files with 68 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,17 @@ trait AnnotationsVersionSpecific {
* Annotation that can be used on a case class method to mark it as a GraphQL field.
* The method must be public, a `def` (does not work on `val`s / `lazy val`s) and must not take any arguments.
*
* NOTE: This annotation is not safe for use with ahead-of-time compilation (e.g., generating a GraalVM native-image executable)
* '''NOTE''' This annotation is not safe for use with ahead-of-time compilation (e.g., generating a GraalVM native-image executable)
*/
case class GQLField() extends StaticAnnotation

/**
* Annotation that can be used on a case class / case object to have all the public methods on it derived as fields.
*
* If you wish to exclude a public method from being derived as a field, you can annotate it with [[GQLExclude]].
*
* @see [[GQLField]] for a more fine-grained control over which methods are derived as fields
*/
case class GQLFieldsFromMethods() extends StaticAnnotation

}
58 changes: 35 additions & 23 deletions core/src/main/scala-3/caliban/schema/macros/Macros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ object Macros {
val annSymbol = TypeRepr.of[GQLExcluded].typeSymbol
Expr(TypeRepr.of[Parent].typeSymbol.primaryConstructor.paramSymss.flatten.exists { v =>
fieldName.map(_ == v.name).getOrElse(false)
&& v.getAnnotation(annSymbol).isDefined
&& v.hasAnnotation(annSymbol)
})
}

private def hasAnnotationImpl[T: Type, Ann: Type](using qctx: Quotes): Expr[Boolean] = {
import qctx.reflect.*
val annSymbol = TypeRepr.of[Ann].typeSymbol
Expr(TypeRepr.of[T].typeSymbol.getAnnotation(annSymbol).isDefined)
Expr(TypeRepr.of[T].typeSymbol.hasAnnotation(annSymbol))
}

private def implicitExistsImpl[T: Type](using q: Quotes): Expr[Boolean] = {
Expand All @@ -53,11 +53,13 @@ object Macros {

private def hasFieldsFromMethodsImpl[T: Type](using q: Quotes): Expr[Boolean] = {
import q.reflect.*
val targetSym = TypeTree.of[T].symbol
val annType = TypeRepr.of[GQLField]
val annSym = annType.typeSymbol
val gqlFieldSym = TypeRepr.of[GQLField].typeSymbol
val gqlFieldsFromMethodsSym = TypeRepr.of[GQLFieldsFromMethods].typeSymbol

Expr(targetSym.declaredMethods.exists(_.getAnnotation(annSym).isDefined))
Expr(
TypeRepr.of[T].typeSymbol.hasAnnotation(gqlFieldsFromMethodsSym)
|| TypeTree.of[T].symbol.declaredMethods.exists(_.hasAnnotation(gqlFieldSym))
)
}

private def fieldsFromMethodsImpl[R: Type, T: Type](using
Expand All @@ -66,8 +68,12 @@ object Macros {
import q.reflect.*
val targetSym = TypeTree.of[T].symbol
val targetType = TypeRepr.of[T]
val annType = TypeRepr.of[GQLField]
val annSym = annType.typeSymbol

val gqlFieldAnnType = TypeRepr.of[GQLField]
val gqlFieldAnnSym = gqlFieldAnnType.typeSymbol

val gqlExcludedAnnSym = TypeRepr.of[GQLExcluded].typeSymbol
val gqlFieldsFromMethodsAnnSym = TypeRepr.of[GQLFieldsFromMethods].typeSymbol

def summonSchema(methodSym: Symbol): Expr[Schema[R, ?]] = {
val fieldType = targetType.memberType(methodSym)
Expand All @@ -91,29 +97,35 @@ object Macros {
// Unfortunately we can't reuse Magnolias filtering so we copy the implementation
def filterAnnotation(ann: Term): Boolean = {
val tpe = ann.tpe
val sym = tpe.typeSymbol

tpe != annType && // No need to include the GQLField annotation
(tpe.typeSymbol.maybeOwner.isNoSymbol ||
(tpe.typeSymbol.owner.fullName != "scala.annotation.internal" &&
tpe.typeSymbol.owner.fullName != "jdk.internal"))
tpe != gqlFieldAnnType && // No need to include the GQLField annotation
(sym.maybeOwner.isNoSymbol ||
(sym.owner.fullName != "scala.annotation.internal" &&
sym.owner.fullName != "jdk.internal"))
}

def extractAnnotations(methodSym: Symbol): List[Expr[Any]] =
methodSym.annotations.filter(filterAnnotation).map(_.asExpr.asInstanceOf[Expr[Any]])

Expr.ofList {
targetSym.declaredMethods
.filter(_.getAnnotation(annSym).isDefined)
.map { method =>
checkMethodNoArgs(method)
'{
(
${ Expr(method.name) },
${ Expr.ofList(extractAnnotations(method)) },
${ summonSchema(method) }
)
}
// NOTE: `Synthetic` methods are ones generated by the compiler, such as `copy`, `equals`, `hashCode`, etc.
val allMethods = targetSym.declaredMethods.filterNot(_.flags.is(Flags.Synthetic))
val filtered =
if targetType.typeSymbol.hasAnnotation(gqlFieldsFromMethodsAnnSym)
then allMethods.filterNot(_.hasAnnotation(gqlExcludedAnnSym))
else allMethods.filter(_.hasAnnotation(gqlFieldAnnSym))

filtered.map { method =>
checkMethodNoArgs(method)
'{
(
${ Expr(method.name) },
${ Expr.ofList(extractAnnotations(method)) },
${ summonSchema(method) }
)
}
}
}
}

Expand Down
24 changes: 23 additions & 1 deletion core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package caliban.schema

import caliban.*
import caliban.parsing.adt.Directive
import caliban.schema.Annotations.{ GQLDescription, GQLDirective, GQLField, GQLInterface, GQLName }
import caliban.schema.Annotations.*
import zio.test.{ assertTrue, ZIOSpecDefault }
import zio.{ RIO, Task, ZIO }

Expand Down Expand Up @@ -281,6 +281,28 @@ object Scala3DerivesSpec extends ZIOSpecDefault {
| barValue: Int!
|}""".stripMargin

assertTrue(rendered == expected)
},
test("annotation on case class directly") {
@GQLFieldsFromMethods
case class Foo(value: String) derives Schema.SemiAuto {
def fooValue: Option[String] = None
def barValue: Int = 42
@GQLExcluded def bazValue: Int = 42
}
val rendered = graphQL(RootResolver(Foo("foo"))).render

val expected =
"""schema {
| query: Foo
|}
|
|type Foo {
| value: String!
| fooValue: String
| barValue: Int!
|}""".stripMargin

assertTrue(rendered == expected)
}
)
Expand Down

0 comments on commit 9d83c72

Please sign in to comment.