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

[Scala 3] Add annotation to derive all case class methods as graphql fields #2306

Merged
merged 3 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
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 / 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 GQLMethodsAsFields() 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 methodsAsFieldsSym = TypeRepr.of[GQLMethodsAsFields].typeSymbol

Expr(targetSym.declaredMethods.exists(_.getAnnotation(annSym).isDefined))
Expr(
TypeRepr.of[T].typeSymbol.hasAnnotation(methodsAsFieldsSym)
|| 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 methodsAsFieldsSym = TypeRepr.of[GQLMethodsAsFields].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(methodsAsFieldsSym)
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") {
@GQLMethodsAsFields
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