In an application with a lot of ad hoc GraphQL operations, we will have to define classes to hold every possible result, plus their Json Decoder instances. Moreover, there may also be a lot of input and enum types, plus Encoder instances for input types and both Encoder and Decoder instances for enum types. On top of that, we may event want to define additional typeclass instances (eg: Eq, Show) or other structure-derived members (eg: lenses) for these classes.
Writing all this boilerplate can get tedious pretty fast. And since all the information to write all this boilerplate is already in the schema and the operations, why not use it?
Currently, boilerplate generation is implemented via macro annotations. We provide two annotations:
-
@GraphQLSchemacan be applied to anyobject. It will look for a schema file with the same name as the object and extension.graphqland generates definitions forinputandenumtypes (plusEncoder/Decoderand optionally other typeclass instances + lenses within theobject. Definitions forscalartypes, as well as overrides, can be defined within theobject. -
@GraphQLcan be applied to anyobjectextendingGraphQLOperation[Schema]. ThatSchemacan be any object that was annotated with@GraphQLSchema. Theobjectjust has to provideval documentand the macro will generate types to hold the result (plusDecoderand optionally other typeclass instances + lenses) and to provide operation parameters (plusEncoderand optionally other typeclass instances + lenses).
More details can be found in MACROS. Further examples are available in the tests of the macros subproject.
Unfortunately, macro annotations are not implemented in Scala 3, at least for the moment. We identified 3 possible alternate approaches to accomplish the same result in Scala 3:
In this approach, all GraphQL operations are written in .graphql files. Given a file with a schema, a process can be run manually which :
- Generates a
Schema.scalafile containing definitions forinputandenumtypes (plusEncoder/Decoderand optionally other typeclass instances + lenses). - Goes through each
.graphqlfile and generates a.scalafile containingobject X extends GraphQLOperation[Schema]with types to hold the result (plusDecoderand optionally other typeclass instances + lenses) and to provide operation parameters (plusEncoderand optionally other typeclass instances + lenses).
The process can be a standalone executable or a task in an sbt plugin that leverages its managed sources mechanism.
Pros:
- All code is explicit.
- Works everywhere (Although if it's an sbt plugin, it will only work with that build tool).
Cons:
- We need an extra configuration file to provide metadata. In particular:
- Mappings for
scalartypes. - Mappings for types we might have already defined in our code.
- Alternate
EncoderorDecoderinstances (might be convoluted to declare this).
- Mappings for
- No part of this metadata is type checked by the compiler.
- Logic gets disseminated between
.graphqland.scalafiles. Eg: To check from Scala code the actual GraphQL query that is being performed, the programmer has to manually go looking for the originating.graphqlfile. - Code can get out of sync/tedious to run generator while developing.
This approach is a bit of a mixture of the previous approach with using macros. Code would look pretty much the same as with macros (we would use the same annotations). Then a Scalafix process is run that generates the missing types + typeclass instances within traits in new files, and the current code is modified to extend those traits.
Pros:
- All code is explicit.
- All mappings and overrides are Scala code.
- Works everywhere Scalafix works.
Cons:
- Code can get out of sync/tedious to run Scalafix while developing.
With this approach we can come close to making GraphQL a first-class citizen. Everything would work pretty much the same as with macros, except that code generation is peformed by a plugin (which are available in Scala 3) instead of macro annotations.
I don't know if quasiquotes can be used in a compiler plugin. If that's the case, we can probably reuse most of the code we have now for annotation macros. We would still have to change the flavor of the generated code to Scala 3.
Pros:
- All mappings and overrides are Scala code.
- No manual intervention necessary.
Cons:
- Generated code is not explicit. I don't know if there's a way to reveal what is being generated (other than spitting it out to the console while compiling).
We may hope that annotation macros are impemented in Scala 3 at some time.
Pros:
- Already done :). Although things would change for Scala 3.
- All mappings and overrides are Scala code.
- No manual intervention necessary.
Cons:
- Generated code is not explicit. However, code can be shown during compilation, and even integrated into and IDE (currently the
debug=trueparameter of both macros will show the generated code as a tooltip in VSCode).