From 91448c5734e8b9237aed979f4f4bb8dfae09bac0 Mon Sep 17 00:00:00 2001 From: Kyri Petrou Date: Tue, 25 Jun 2024 18:49:04 +1000 Subject: [PATCH 1/3] Add `@GQLMethodsAsFields` annotation for Scala 3 to derive all fields as methods --- .../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..12a9886a1 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 / 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 the more info on deriving fields from methods + */ + case class GQLMethodsAsFields() 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..4a027e52a 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 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 @@ -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) @@ -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) } + ) } + } } } diff --git a/core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala b/core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala index cfe3da054..f65382461 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") { + @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) } ) From 8f6f2933905f65bd1cf9be22850c164f9b162654 Mon Sep 17 00:00:00 2001 From: Kyri Petrou Date: Tue, 25 Jun 2024 18:50:22 +1000 Subject: [PATCH 2/3] Cleanup scaladoc --- .../scala-3/caliban/schema/AnnotationsVersionSpecific.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala b/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala index 12a9886a1..2481921d2 100644 --- a/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala +++ b/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala @@ -17,7 +17,7 @@ trait AnnotationsVersionSpecific { * * If you wish to exclude a public method from being derived as a field, you can annotate it with [[GQLExclude]]. * - * @see [[GQLField]] for the more info on deriving fields from methods + * @see [[GQLField]] for a more fine-grained control over which methods are derived as fields */ case class GQLMethodsAsFields() extends StaticAnnotation From 3f795553863b4a5d7ad5c6ee5f2e9b2076b72080 Mon Sep 17 00:00:00 2001 From: Kyri Petrou Date: Tue, 25 Jun 2024 19:09:49 +1000 Subject: [PATCH 3/3] Rename annotation --- .../caliban/schema/AnnotationsVersionSpecific.scala | 4 ++-- .../main/scala-3/caliban/schema/macros/Macros.scala | 12 ++++++------ .../scala-3/caliban/schema/Scala3DerivesSpec.scala | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala b/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala index 2481921d2..b9f39cd24 100644 --- a/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala +++ b/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala @@ -13,12 +13,12 @@ trait AnnotationsVersionSpecific { 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. + * 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 GQLMethodsAsFields() extends StaticAnnotation + 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 4a027e52a..e55c5c644 100644 --- a/core/src/main/scala-3/caliban/schema/macros/Macros.scala +++ b/core/src/main/scala-3/caliban/schema/macros/Macros.scala @@ -53,11 +53,11 @@ object Macros { private def hasFieldsFromMethodsImpl[T: Type](using q: Quotes): Expr[Boolean] = { import q.reflect.* - val gqlFieldSym = TypeRepr.of[GQLField].typeSymbol - val methodsAsFieldsSym = TypeRepr.of[GQLMethodsAsFields].typeSymbol + val gqlFieldSym = TypeRepr.of[GQLField].typeSymbol + val gqlFieldsFromMethodsSym = TypeRepr.of[GQLFieldsFromMethods].typeSymbol Expr( - TypeRepr.of[T].typeSymbol.hasAnnotation(methodsAsFieldsSym) + TypeRepr.of[T].typeSymbol.hasAnnotation(gqlFieldsFromMethodsSym) || TypeTree.of[T].symbol.declaredMethods.exists(_.hasAnnotation(gqlFieldSym)) ) } @@ -72,8 +72,8 @@ object Macros { val gqlFieldAnnType = TypeRepr.of[GQLField] val gqlFieldAnnSym = gqlFieldAnnType.typeSymbol - val gqlExcludedAnnSym = TypeRepr.of[GQLExcluded].typeSymbol - val methodsAsFieldsSym = TypeRepr.of[GQLMethodsAsFields].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) @@ -112,7 +112,7 @@ object Macros { // 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) + if targetType.typeSymbol.hasAnnotation(gqlFieldsFromMethodsAnnSym) then allMethods.filterNot(_.hasAnnotation(gqlExcludedAnnSym)) else allMethods.filter(_.hasAnnotation(gqlFieldAnnSym)) diff --git a/core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala b/core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala index f65382461..8d31b4d09 100644 --- a/core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala +++ b/core/src/test/scala-3/caliban/schema/Scala3DerivesSpec.scala @@ -284,7 +284,7 @@ object Scala3DerivesSpec extends ZIOSpecDefault { assertTrue(rendered == expected) }, test("annotation on case class directly") { - @GQLMethodsAsFields + @GQLFieldsFromMethods case class Foo(value: String) derives Schema.SemiAuto { def fooValue: Option[String] = None def barValue: Int = 42