Skip to content

Commit 6203fa7

Browse files
authored
FIX #44 Generate code and decoders for enum type (#45)
* FIX #44 Generate code and decoders for `enum` type This adds the general capability for generating interfaces and types in the ApolloSource generator * Add fragments to generated document * Initial tests for fragment code generation * Additional test for nested fragments * Remove interfaces from concrete queries * scalafmt * Remove types from concrete queries and add test for types generation * Fix circe generation test * Adding json generation for enum types * Scalafmt * Wrap types in object types and import in generated code * Add nested fragment to test project * Add documentation * Add scripted test for duplicated fragment names
1 parent 10d2e5d commit 6203fa7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+598
-40
lines changed

README.md

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ You can configure the output in various ways
246246
* `graphqlCodegenImports: Seq[String]` - A list of additional that are included in every generated file
247247

248248

249-
#### JSON support
249+
### JSON support
250250

251251
The common serialization format for graphql results and input variables is JSON.
252252
sbt-graphql supports JSON decoder/encoder code generation.
@@ -264,7 +264,7 @@ In your `build.sbt` you can configure the JSON library with
264264
graphqlCodegenJson := JsonCodec.Circe
265265
```
266266

267-
#### Scalar types
267+
### Scalar types
268268

269269
The code generation doesn't know about your additional scalar types.
270270
sbt-graphql provides a setting `graphqlCodegenImports` to add an import to every
@@ -283,7 +283,7 @@ which is represented as `java.time.ZoneDateTime`. Add this as an import
283283
graphqlCodegenImports += "java.time.ZoneDateTime"
284284
```
285285

286-
#### Codegen style Apollo
286+
### Codegen style Apollo
287287

288288
As the name suggests the output is similar to the one in apollo codegen.
289289

@@ -321,7 +321,7 @@ import graphql.codegen.GraphQLQuery
321321
import sangria.macros._
322322
object HeroNameQuery {
323323
object HeroNameQuery extends GraphQLQuery {
324-
val Document = graphql"""query HeroNameQuery {
324+
val document: sangria.ast.Document = graphql"""query HeroNameQuery {
325325
hero {
326326
name
327327
}
@@ -333,7 +333,79 @@ object HeroNameQuery {
333333
}
334334
```
335335

336-
#### Codegen Style Sangria
336+
#### Interfaces, types and aliases
337+
338+
The `ApolloSourceGenerator` generates an additional file `Interfaces.scala` with the following shape:
339+
340+
```scala
341+
object types {
342+
// contains all defined types like enums and aliases
343+
}
344+
// all used fragments and interfaces are generated as traits here
345+
```
346+
347+
##### Use case
348+
349+
> Share common business logic around a fragment that shouldn't be a directive
350+
351+
You can now do this by defining a `fragment` and include it in every query that
352+
requires to apply this logic. `sbt-graphql` will generate the common `trait?`,
353+
all generated case classes will extend this fragment `trait`.
354+
355+
##### Limitations
356+
357+
You need to **copy the fragments into every `graphql` query** that should use it.
358+
If you have a lot of queries that reuse the fragment and you want to apply changes,
359+
this is cumbersome.
360+
361+
You **cannot nest fragments**. The code generation isn't capable of naming the nested data structure. This means that you need create fragments for every nesting.
362+
363+
**Invalid**
364+
```graphql
365+
query HeroNestedFragmentQuery {
366+
hero {
367+
...CharacterInfo
368+
}
369+
human(id: "Lea") {
370+
...CharacterInfo
371+
}
372+
}
373+
374+
# This will generate code that may compile, but is not usable
375+
fragment CharacterInfo on Character {
376+
name
377+
friends {
378+
name
379+
}
380+
}
381+
```
382+
383+
**correct**
384+
385+
```graphql
386+
query HeroNestedFragmentQuery {
387+
hero {
388+
...CharacterInfo
389+
}
390+
human(id: "Lea") {
391+
...CharacterInfo
392+
}
393+
}
394+
395+
# create a fragment for the nested query
396+
fragment CharacterFriends on Character {
397+
name
398+
}
399+
400+
fragment CharacterInfo on Character {
401+
name
402+
friends {
403+
...CharacterFriends
404+
}
405+
}
406+
```
407+
408+
### Codegen Style Sangria
337409

338410
This style generates one object with a specified `moduleName` and puts everything in there.
339411

src/main/scala/rocks/muki/graphql/codegen/ApolloSourceGenerator.scala

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,41 @@ case class ApolloSourceGenerator(fileName: String,
2929
jsonCodeGen: JsonCodeGen)
3030
extends Generator[List[Stat]] {
3131

32-
override def apply(document: TypedDocument.Api): Result[List[Stat]] = {
32+
/**
33+
* Generates only the interfaces (fragments) that appear in the given
34+
* document.
35+
*
36+
* This method works great with DocumentLoader.merge, merging all
37+
* fragments together and generating a single interface definition object.
38+
*
39+
* @param document schema + query
40+
* @return interfaces
41+
*/
42+
def generateInterfaces(document: TypedDocument.Api): Result[List[Stat]] = {
43+
Right(document.interfaces.map(generateInterface(_, isSealed = false)))
44+
}
3345

34-
// TODO refactor Generator trait into something more flexible
46+
/**
47+
* Generates only the types that appear in the given
48+
* document.
49+
*
50+
* This method works great with DocumentLoader.merge, merging all
51+
* fragments together and generating a single type definition object.
52+
*
53+
*
54+
* @param document schema + query
55+
* @return types
56+
*/
57+
def generateTypes(document: TypedDocument.Api): Result[List[Stat]] = {
58+
val typeStats = document.types.flatMap(generateType)
59+
Right(
60+
jsonCodeGen.imports ++ List(q"""object types {
61+
..$typeStats
62+
}""")
63+
)
64+
}
65+
66+
override def apply(document: TypedDocument.Api): Result[List[Stat]] = {
3567

3668
val operations = document.operations.map { operation =>
3769
val typeName = Term.Name(
@@ -50,9 +82,21 @@ case class ApolloSourceGenerator(fileName: String,
5082
// replacing single $ with $$ for escaping
5183
val escapedDocumentString =
5284
operation.original.renderPretty.replaceAll("\\$", "\\$\\$")
53-
val document = Term.Interpolate(Term.Name("graphql"),
54-
Lit.String(escapedDocumentString) :: Nil,
55-
Nil)
85+
86+
// add the fragments to the query as well
87+
val escapedFragmentString = Option(document.original.fragments)
88+
.filter(_.nonEmpty)
89+
.map { fragments =>
90+
fragments.values
91+
.map(_.renderPretty.replaceAll("\\$", "\\$\\$"))
92+
.mkString("\n\n", "\n", "")
93+
}
94+
.getOrElse("")
95+
96+
val documentString = escapedDocumentString + escapedFragmentString
97+
val graphqlDocument = Term.Interpolate(Term.Name("graphql"),
98+
Lit.String(documentString) :: Nil,
99+
Nil)
56100

57101
val dataJsonDecoder =
58102
Option(jsonCodeGen.generateFieldDecoder(Type.Name("Data")))
@@ -64,15 +108,13 @@ case class ApolloSourceGenerator(fileName: String,
64108

65109
q"""
66110
object $typeName extends ..$additionalInits {
67-
val document: sangria.ast.Document = $document
111+
val document: sangria.ast.Document = $graphqlDocument
68112
case class Variables(..$inputParams)
69113
case class Data(..$dataParams)
70114
..$dataJsonDecoder
71115
..$data
72116
}"""
73117
}
74-
val interfaces =
75-
document.interfaces.map(generateInterface(_, isSealed = false))
76118
val types = document.types.flatMap(generateType)
77119
val objectName = fileName.replaceAll("\\.graphql$|\\.gql$", "")
78120

@@ -81,11 +123,10 @@ case class ApolloSourceGenerator(fileName: String,
81123
jsonCodeGen.imports ++
82124
List(
83125
q"import sangria.macros._",
126+
q"import types._",
84127
q"""
85128
object ${Term.Name(objectName)} {
86129
..$operations
87-
..$interfaces
88-
..$types
89130
}
90131
"""
91132
))
@@ -315,9 +356,12 @@ case class ApolloSourceGenerator(fileName: String,
315356

316357
val enumName = Type.Name(name)
317358
val objectName = Term.Name(name)
359+
val jsonDecoder = jsonCodeGen.generateEnumFieldDecoder(enumName, values)
360+
val enumStats: List[Stat] = enumValues ++ jsonDecoder
361+
318362
List[Stat](
319363
q"sealed trait $enumName",
320-
q"object $objectName { ..$enumValues }"
364+
q"object $objectName { ..$enumStats }"
321365
)
322366

323367
case TypedDocument.TypeAlias(from, to) =>

src/main/scala/rocks/muki/graphql/codegen/CodeGenStyles.scala

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ package rocks.muki.graphql.codegen
22

33
import java.io.File
44

5-
import sangria.schema.Schema
65
import sbt._
76

87
import scala.meta._
9-
import scala.util.Success
8+
import sangria.ast
109

1110
/**
1211
* == CodeGen Styles ==
@@ -77,12 +76,37 @@ object CodeGenStyles {
7776
}
7877
}
7978

79+
val interfaceFile = for {
80+
// use all queries to determine the interfaces & types we need
81+
allQueries <- DocumentLoader.merged(schema, inputFiles.toList)
82+
typedDocument <- TypedDocumentParser(schema, allQueries)
83+
.parse()
84+
codeGenerator = ApolloSourceGenerator("Interfaces.scala",
85+
additionalImports,
86+
additionalInits,
87+
context.jsonCodeGen)
88+
interfaces <- codeGenerator.generateInterfaces(typedDocument)
89+
types <- codeGenerator.generateTypes(typedDocument)
90+
} yield {
91+
val stats = q"""package $packageName {
92+
..$interfaces
93+
..$types
94+
}
95+
"""
96+
val outputFile = context.targetDirectory / "Interfaces.scala"
97+
SourceCodeWriter.write(outputFile, stats)
98+
context.log.info(s"Generated source $outputFile")
99+
outputFile
100+
}
101+
102+
val allFiles = files :+ interfaceFile
103+
80104
// split errors and success
81-
val success = files.collect {
105+
val success = allFiles.collect {
82106
case Right(file) => file
83107
}
84108

85-
val errors = files.collect {
109+
val errors = allFiles.collect {
86110
case Left(error) => error
87111
}
88112

src/main/scala/rocks/muki/graphql/codegen/JsonCodeGen.scala

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ trait JsonCodeGen {
3131
unionNames: List[String],
3232
typeDiscriminatorField: String): List[Stat]
3333

34+
/**
35+
*
36+
* @param enumTrait the enum trait
37+
* @param enumValues all enum field names
38+
* @return a json decoder instance for enum types
39+
*/
40+
def generateEnumFieldDecoder(enumTrait: Type.Name,
41+
enumValues: List[String]): List[Stat]
42+
3443
}
3544

3645
object JsonCodeGens {
@@ -42,6 +51,9 @@ object JsonCodeGens {
4251
unionTrait: Type.Name,
4352
unionNames: List[String],
4453
typeDiscriminatorField: String): List[Stat] = Nil
54+
55+
def generateEnumFieldDecoder(enumTrait: Type.Name,
56+
enumValues: List[String]): List[Stat] = Nil
4557
}
4658

4759
object Circe extends JsonCodeGen {
@@ -73,7 +85,23 @@ object JsonCodeGens {
7385
value <- typeDiscriminator match { ..case $patterns }
7486
} yield value
7587
""")
88+
}
7689

90+
override def generateEnumFieldDecoder(
91+
enumTrait: Type.Name,
92+
enumValues: List[String]): List[Stat] = {
93+
val patterns = enumValues.map { name =>
94+
val nameLiteral = Lit.String(name)
95+
val enumTerm = Term.Name(name)
96+
p"case $nameLiteral => Right($enumTerm)"
97+
} ++ List(
98+
p"""case other => Left("invalid enum value: " + other)"""
99+
)
100+
101+
List(q"""
102+
implicit val jsonDecoder: Decoder[$enumTrait] = Decoder.decodeString.emap {
103+
..case $patterns
104+
} """)
77105
}
78106
}
79107
}

src/main/scala/rocks/muki/graphql/codegen/TypedDocument.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,6 @@ object TypedDocument {
9898
*/
9999
case class Api(operations: List[Operation],
100100
interfaces: List[Interface],
101-
types: List[Type])
101+
types: List[Type],
102+
original: ast.Document)
102103
}

src/main/scala/rocks/muki/graphql/codegen/TypedDocumentParser.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ case class TypedDocumentParser(schema: Schema[_, _], document: ast.Document) {
3838
document.operations.values.map(generateOperation).toList,
3939
document.fragments.values.toList.map(generateFragment),
4040
// Include only types that have been used in the document
41-
schema.typeList.filter(types).collect(generateType).toList
41+
schema.typeList.filter(types).collect(generateType).toList,
42+
document
4243
))
4344

4445
/**

src/sbt-test/codegen/apollo-circe/build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@ libraryDependencies ++= Seq(
1616

1717
TaskKey[Unit]("check") := {
1818
val generatedFiles = (graphqlCodegen in Compile).value
19-
assert(generatedFiles.length == 5, s"Expected 5 files to be generated, but got\n${generatedFiles.mkString("\n")}")
19+
assert(generatedFiles.length == 6, s"Expected 6 files to be generated, but got\n${generatedFiles.mkString("\n")}")
2020
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name := "test"
2+
enablePlugins(GraphQLCodegenPlugin)
3+
scalaVersion := "2.12.4"
4+
5+
libraryDependencies ++= Seq(
6+
"org.sangria-graphql" %% "sangria" % "1.3.0"
7+
)
8+
9+
graphqlCodegenStyle := Apollo
10+
11+
TaskKey[Unit]("check") := {
12+
val generatedFiles = (graphqlCodegen in Compile).value
13+
val interfacesFile = generatedFiles.find(_.getName == "Interfaces.scala")
14+
15+
assert(interfacesFile.isDefined, s"Could not find generated scala class. Available files\n ${generatedFiles.mkString("\n ")}")
16+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
addSbtPlugin("rocks.muki" % "sbt-graphql" % sys.props("project.version"))
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
query HeroNestedFragmentQuery {
2+
hero {
3+
...CharacterInfo
4+
}
5+
human(id: "Lea") {
6+
homePlanet
7+
...CharacterInfo
8+
}
9+
}
10+
11+
fragment CharacterFriends on Character {
12+
name
13+
}
14+
15+
fragment CharacterInfo on Character {
16+
name
17+
friends {
18+
...CharacterFriends
19+
}
20+
}

0 commit comments

Comments
 (0)