From 9d83c7287eea336b4e83c125d260a0af12401ac3 Mon Sep 17 00:00:00 2001 From: kyri-petrou <67301607+kyri-petrou@users.noreply.github.com> Date: Wed, 26 Jun 2024 07:57:10 +1000 Subject: [PATCH] [Scala 3] Add annotation to derive all case class methods as graphql fields (#2306) --- .../schema/AnnotationsVersionSpecific.scala | 11 +++- .../caliban/schema/macros/Macros.scala | 58 +++++++++++-------- .../caliban/schema/Scala3DerivesSpec.scala | 24 +++++++- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala b/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala index 7a38c2021..b9f39cd24 100644 --- a/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala +++ b/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala @@ -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 + } diff --git a/core/src/main/scala-3/caliban/schema/macros/Macros.scala b/core/src/main/scala-3/caliban/schema/macros/Macros.scala index 6b1b25b7d..e55c5c644 100644 --- a/core/src/main/scala-3/caliban/schema/macros/Macros.scala +++ b/core/src/main/scala-3/caliban/schema/macros/Macros.scala @@ -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] = { @@ -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 @@ -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) @@ -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) } + ) } + } } } diff --git a/core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala b/core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala index cfe3da054..8d31b4d09 100644 --- a/core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala +++ b/core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala @@ -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 } @@ -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) } )