Skip to content

Commit 884884b

Browse files
authored
feat: improve top-level Query and Mutation type handling (#72)
* unit test * refactor * pass test * add mutation tests * fix integration test * refactor * reorganize * separate field definition functions * pull out boolean arg * pull out other boolean arg * refactor * refactor * bun version * set up docs workspace * docs
1 parent ff1811a commit 884884b

File tree

10 files changed

+219
-31
lines changed

10 files changed

+219
-31
lines changed

codegen.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { GraphQLKotlinCodegenConfig } from "./src/plugin";
21
import { CodegenConfig } from "@graphql-codegen/cli";
32

43
export default {
@@ -9,17 +8,7 @@ export default {
98
},
109
generates: {
1110
"test/integration/Types.kt": {
12-
plugins: [
13-
{
14-
"dist/plugin.cjs": {
15-
resolverInterfaces: [
16-
{
17-
typeName: "Query",
18-
},
19-
],
20-
} satisfies GraphQLKotlinCodegenConfig,
21-
},
22-
],
11+
plugins: ["dist/plugin.cjs"],
2312
},
2413
},
2514
} satisfies CodegenConfig;

docs/docs/inheritance.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
---
2+
sidebar_position: 6
3+
---
4+
5+
# Inheritance
6+
7+
When dealing with GraphQL schema containing [field arguments](https://graphql.com/learn/arguments/),
8+
generating Kotlin code can be a bit tricky. This is because the generated code cannot in itself be the implementation
9+
in a resolver.
10+
11+
Here's an example:
12+
13+
```graphql
14+
type Query {
15+
resolveMyType: MyType!
16+
}
17+
18+
type MyType {
19+
resolveMe(input: String!): String!
20+
}
21+
```
22+
23+
Generated Kotlin:
24+
25+
```kotlin
26+
package com.types.generated
27+
28+
open class Query {
29+
open fun resolveMyType(): MyType = throw NotImplementedError("Query.resolveMyType must be implemented.")
30+
}
31+
32+
open class MyType {
33+
open fun resolveMe(input: String): String = throw NotImplementedError("MyType.resolveMe must be implemented.")
34+
}
35+
```
36+
37+
Source code:
38+
39+
```kotlin
40+
import com.expediagroup.graphql.server.operations.Query
41+
import com.types.generated.MyType as MyTypeInterface
42+
import com.types.generated.Query as QueryInterface
43+
44+
class MyQuery : Query, QueryInterface() {
45+
override fun resolveMyType(): MyTypeInterface = MyType()
46+
}
47+
48+
class MyType : MyTypeInterface() {
49+
override fun resolveMe(input: String): String = "Hello world!"
50+
}
51+
```
52+
53+
As you can see, the generated code is not part of the implementation. Rather, it becomes an interface to inherit from in your implementation.
54+
This enforces a type contract between the schema and your resolver code.
55+
56+
Note that GraphQL Kotlin will use the implementation classes to generate the schema, not the generated interfaces.
57+
This means that all `@GraphQLDescription` and `@Deprecated` annotations have to be added to implementation classes
58+
in order to be propagated to the resulting schema.
59+
60+
## Top Level Types
61+
62+
When dealing with top-level types, i.e. `Query` and `Mutation`, you can inherit from the corresponding generated class
63+
to enforce the type contract. This is fine as long as all of your resolvers are contained in the same Query or Mutation class.
64+
65+
```graphql
66+
type Query {
67+
foo: String!
68+
bar: String!
69+
}
70+
```
71+
72+
```kotlin
73+
import com.expediagroup.graphql.server.operations.Query
74+
import com.types.generated.Query as QueryInterface
75+
76+
class MyQuery : Query, QueryInterface() {
77+
override fun foo(): String = "Hello"
78+
override fun bar(): String = "World"
79+
}
80+
```
81+
82+
However, you might want to separate the implementation into multiple classes like so:
83+
84+
```kotlin
85+
import com.expediagroup.graphql.server.operations.Query
86+
87+
class FooQuery : Query {
88+
override fun foo(): String = "Hello"
89+
}
90+
91+
class BarQuery : Query {
92+
override fun bar(): String = "World"
93+
}
94+
```
95+
96+
If you try to inherit from the generated `Query` class, you will get an error during schema generation.
97+
98+
```kotlin
99+
import com.expediagroup.graphql.server.operations.Query
100+
import com.types.generated.Query as QueryInterface
101+
102+
class FooQuery : Query, QueryInterface() {
103+
override fun foo(): String = "Hello"
104+
}
105+
106+
class BarQuery : Query, QueryInterface() {
107+
override fun bar(): String = "World"
108+
}
109+
```
110+
111+
This is because the generated `Query` class contains both `foo` and `bar` fields, which creates a conflict when inherited by multiple implementation classes.
112+
113+
Instead, you should inherit from the field-level generated `Query` classes like so:
114+
115+
```kotlin
116+
import com.expediagroup.graphql.server.operations.Query
117+
import com.types.generated.FooQuery as FooQueryInterface
118+
import com.types.generated.BarQuery as BarQueryInterface
119+
120+
class FooQuery : Query, FooQueryInterface() {
121+
override fun foo(): String = "Hello"
122+
}
123+
124+
class BarQuery : Query, BarQueryInterface() {
125+
override fun bar(): String = "World"
126+
}
127+
```
128+
129+
This way, schema generation can complete without conflict, and you can separate your implementation into multiple classes!

docs/docs/recommended-usage.md

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ type Query {
2121
}
2222

2323
type MyType {
24-
field1: String!
25-
field2: String
24+
foo: String!
25+
bar: String
2626
}
2727
```
2828

@@ -38,8 +38,8 @@ open class Query {
3838
}
3939

4040
data class MyType(
41-
val field1: String,
42-
val field2: String? = null
41+
val foo: String,
42+
val bar: String? = null
4343
)
4444
```
4545

@@ -53,16 +53,15 @@ import com.types.generated.Query as QueryInterface
5353
class MyQuery : Query, QueryInterface() {
5454
override fun resolveMyType(input: String): MyType =
5555
MyType(
56-
field1 = myExpensiveCall1(),
57-
field2 = myExpensiveCall2()
56+
foo = myExpensiveCall1(),
57+
bar = myExpensiveCall2()
5858
)
5959
}
60-
6160
```
6261

6362
The resulting source code is extremely unperformant. The `MyType` class is a data class, which means
64-
that the `field1` and `field2` properties are both initialized when the `MyType` object is created, and
65-
`myExpensiveCall1()` and `myExpensiveCall2()` will both be called in sequence! Even if I only query for `field1`, not
63+
that the `foo` and `bar` properties are both initialized when the `MyType` object is created, and
64+
`myExpensiveCall1()` and `myExpensiveCall2()` will both be called in sequence! Even if I only query for `foo`, not
6665
only will `myExpensiveCall2()` still run, but it will also wait until `myExpensiveCall1()` is totally finished.
6766

6867
### Instead, use the `resolverInterfaces` config!
@@ -91,30 +90,28 @@ open class Query {
9190
}
9291

9392
open class MyType {
94-
open fun field1(): String = throw NotImplementedError("MyType.field1 must be implemented.")
95-
open fun field2(): String? = throw NotImplementedError("MyType.field2 must be implemented.")
93+
open fun foo(): String = throw NotImplementedError("MyType.foo must be implemented.")
94+
open fun bar(): String? = throw NotImplementedError("MyType.bar must be implemented.")
9695
}
9796
```
9897

9998
Source code:
10099

101100
```kotlin
102101
import com.types.generated.MyType as MyTypeInterface
103-
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
104102

105103
class MyQuery : Query, QueryInterface() {
106104
override fun resolveMyType(input: String): MyType = MyType()
107105
}
108106

109-
@GraphQLIgnore
110107
class MyType : MyTypeInterface() {
111-
override fun field1(): String = myExpensiveCall1()
112-
override fun field2(): String? = myExpensiveCall2()
108+
override fun foo(): String = myExpensiveCall1()
109+
override fun bar(): String? = myExpensiveCall2()
113110
}
114111
```
115112

116-
This code is much more performant! The `MyType` class is no longer a data class, so the `field1` and `field2` properties
117-
can now be resolved independently of each other. If I query for only `field1`, only `myExpensiveCall1()` will be called, and
118-
if I query for only `field2`, only `myExpensiveCall2()` will be called.
113+
This code is much more performant! The `MyType` class is no longer a data class, so the `foo` and `bar` properties
114+
can now be resolved independently of each other. If I query for only `foo`, only `myExpensiveCall1()` will be called, and
115+
if I query for only `bar`, only `myExpensiveCall2()` will be called.
119116

120117
Check out the [related GraphQL Kotlin docs](https://opensource.expediagroup.com/graphql-kotlin/docs/schema-generator/execution/fetching-data/) for more information on this topic.

src/config/build-config-with-defaults.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function buildConfigWithDefaults(
2525
includeDependentTypes: true,
2626
unionGeneration: "MARKER_INTERFACE",
2727
extraImports: ["com.expediagroup.graphql.generator.annotations.*"],
28+
resolverInterfaces: [{ typeName: "Query" }, { typeName: "Mutation" }],
2829
} as const satisfies GraphQLKotlinCodegenConfig;
2930

3031
return merge(defaultConfig, config) as GraphQLKotlinCodegenConfig &

src/config/find-type-in-resolver-interfaces-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function findTypeInResolverInterfacesConfig(
1818
node: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode,
1919
config: CodegenConfigWithDefaults,
2020
) {
21-
return config.resolverInterfaces?.findLast(
21+
return config.resolverInterfaces.findLast(
2222
(resolverInterface) => resolverInterface.typeName === node.name.value,
2323
);
2424
}

src/definitions/object.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,23 @@ export function buildObjectTypeDefinition(
7373
? node.fields
7474
: fieldsWithArguments;
7575

76+
const isTopLevelType =
77+
node.name.value === "Query" || node.name.value === "Mutation";
78+
if (isTopLevelType) {
79+
const individualQueryClasses = node.fields?.map((fieldNode) => {
80+
const className = `${titleCase(fieldNode.name.value)}${node.name.value}`;
81+
return `${annotations}${outputRestrictionAnnotation}open class ${className}${interfaceInheritance} {
82+
${getClassMembers({ node, fieldNodes: [fieldNode], schema, config })}
83+
}`;
84+
});
85+
const consolidatedQueryClass = `${annotations}${outputRestrictionAnnotation}open class ${name}${interfaceInheritance} {
86+
${getClassMembers({ node, fieldNodes, schema, config })}
87+
}`;
88+
return [consolidatedQueryClass, ...(individualQueryClasses ?? [])].join(
89+
"\n\n",
90+
);
91+
}
92+
7693
const shouldGenerateFunctions = shouldGenerateFunctionsInClass(
7794
node,
7895
typeInResolverInterfacesConfig,
@@ -139,3 +156,7 @@ export function shouldGenerateFunctionsInClass(
139156
node.fields?.some((fieldNode) => fieldNode.arguments?.length),
140157
);
141158
}
159+
160+
function titleCase(str: string) {
161+
return str.charAt(0).toUpperCase() + str.slice(1);
162+
}

test/integration/expected.graphql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ directive @specifiedBy(
3030
) on SCALAR
3131

3232
type Query {
33+
getStuff: String!
34+
getStuffWithInput(input: String!): String!
3335
testQuery1: SomeType!
3436
testQuery2: SomeHybridType!
3537
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { GraphQLKotlinCodegenConfig } from "../../../src/plugin";
2+
3+
export default {
4+
resolverInterfaces: [{ typeName: "Mutation", classMethods: "SUSPEND" }],
5+
} satisfies GraphQLKotlinCodegenConfig;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.kotlin.generated
2+
3+
import com.expediagroup.graphql.generator.annotations.*
4+
5+
@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])
6+
open class Query {
7+
open fun getStuff(dataFetchingEnvironment: graphql.schema.DataFetchingEnvironment): String = throw NotImplementedError("Query.getStuff must be implemented.")
8+
open fun getStuffWithInput(input: String, dataFetchingEnvironment: graphql.schema.DataFetchingEnvironment): String = throw NotImplementedError("Query.getStuffWithInput must be implemented.")
9+
}
10+
11+
@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])
12+
open class GetStuffQuery {
13+
open fun getStuff(dataFetchingEnvironment: graphql.schema.DataFetchingEnvironment): String = throw NotImplementedError("Query.getStuff must be implemented.")
14+
}
15+
16+
@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])
17+
open class GetStuffWithInputQuery {
18+
open fun getStuffWithInput(input: String, dataFetchingEnvironment: graphql.schema.DataFetchingEnvironment): String = throw NotImplementedError("Query.getStuffWithInput must be implemented.")
19+
}
20+
21+
@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])
22+
open class Mutation {
23+
open suspend fun mutateStuff(dataFetchingEnvironment: graphql.schema.DataFetchingEnvironment): String = throw NotImplementedError("Mutation.mutateStuff must be implemented.")
24+
open suspend fun mutateStuffWithInput(input: String, dataFetchingEnvironment: graphql.schema.DataFetchingEnvironment): String = throw NotImplementedError("Mutation.mutateStuffWithInput must be implemented.")
25+
}
26+
27+
@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])
28+
open class MutateStuffMutation {
29+
open suspend fun mutateStuff(dataFetchingEnvironment: graphql.schema.DataFetchingEnvironment): String = throw NotImplementedError("Mutation.mutateStuff must be implemented.")
30+
}
31+
32+
@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])
33+
open class MutateStuffWithInputMutation {
34+
open suspend fun mutateStuffWithInput(input: String, dataFetchingEnvironment: graphql.schema.DataFetchingEnvironment): String = throw NotImplementedError("Mutation.mutateStuffWithInput must be implemented.")
35+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
type Query {
2+
getStuff: String!
3+
getStuffWithInput(input: String!): String!
4+
}
5+
6+
type Mutation {
7+
mutateStuff: String!
8+
mutateStuffWithInput(input: String!): String!
9+
}

0 commit comments

Comments
 (0)