Skip to content

Commit 5f352a6

Browse files
committed
feat: generate open classes for types with field arguments (#54)
BREAKING CHANGE: Generate open class rather than interface for types with field arguments BREAKING CHANGE: Remove extraResolverArguments config, which is redundant following #52 BREAKING CHANGE: Rename resolverTypes config to resolverClasses and change its schema
1 parent 9e0a5ff commit 5f352a6

File tree

26 files changed

+554
-399
lines changed

26 files changed

+554
-399
lines changed

docs/docs/recommended-usage.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
---
2+
sidebar_position: 4
3+
---
4+
5+
# Recommended Usage
6+
7+
In general, the `resolverClasses` config should be used to generate more performant code. This is especially important
8+
when dealing with expensive operations, such as database queries or network requests. When at least one field has
9+
arguments in a type, we generate an open class with function signatures to be inherited in source code.
10+
However, when fields have no arguments, we generate data classes by default.
11+
12+
## Example
13+
14+
The following example demonstrates the problem with using generated data classes to implement your resolvers with GraphQL Kotlin.
15+
16+
Say you want to implement the schema below:
17+
18+
```graphql
19+
type Query {
20+
resolveMyType(input: String!): MyType
21+
}
22+
23+
type MyType {
24+
field1: String!
25+
field2: String
26+
}
27+
```
28+
29+
### Here is the default behavior.
30+
31+
Generated Kotlin:
32+
33+
```kotlin
34+
package com.types.generated
35+
36+
open class Query {
37+
open fun resolveMyType(input: String): MyType = throw NotImplementedError("Query.resolveMyType must be implemented.")
38+
}
39+
40+
data class MyType(
41+
val field1: String,
42+
val field2: String? = null
43+
)
44+
```
45+
46+
Source code:
47+
48+
```kotlin
49+
import com.expediagroup.graphql.server.operations.Query
50+
import com.expediagroup.sharedGraphql.generated.Query as QueryInterface
51+
import com.types.generated.MyType
52+
53+
class MyQuery : Query, QueryInterface() {
54+
override suspend fun resolveMyType(input: String): MyType =
55+
MyType(
56+
field1 = myExpensiveCall1(),
57+
field2 = myExpensiveCall2()
58+
)
59+
}
60+
61+
```
62+
63+
The resulting source code is at risk of being 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
66+
only will `myExpensiveCall2()` still run, but it will also wait until `myExpensiveCall1()` is totally finished.
67+
68+
### Instead, use the `resolverClasses` config!
69+
70+
Codegen config:
71+
72+
```ts
73+
import { GraphQLKotlinCodegenConfig } from "@expediagroup/graphql-kotlin-codegen";
74+
75+
export default {
76+
resolverClasses: [
77+
{
78+
typeName: "MyType",
79+
},
80+
],
81+
} satisfies GraphQLKotlinCodegenConfig;
82+
```
83+
84+
Generated Kotlin:
85+
86+
```kotlin
87+
package com.types.generated
88+
89+
open class Query {
90+
open fun resolveMyType(input: String): MyType = throw NotImplementedError("Query.resolveMyType must be implemented.")
91+
}
92+
93+
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.")
96+
}
97+
```
98+
99+
Source code:
100+
101+
```kotlin
102+
import com.types.generated.MyType as MyTypeInterface
103+
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
104+
105+
class MyQuery : Query, QueryInterface() {
106+
override suspend fun resolveMyType(input: String): MyType = MyType()
107+
}
108+
109+
@GraphQLIgnore
110+
class MyType : MyTypeInterface() {
111+
override fun field1(): String = myExpensiveCall1()
112+
override fun field2(): String? = myExpensiveCall2()
113+
}
114+
```
115+
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.

src/config.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -103,31 +103,31 @@ export const configSchema = object({
103103
),
104104
),
105105
/**
106-
* Denotes types that should be generated as interfaces with suspense functions. Resolver classes can inherit from these to enforce a type contract.
107-
* @description Two interfaces will be generated: one with suspend functions, and one with `java.util.concurrent.CompletableFuture` functions.
108-
* @example ["MyResolverType1", "MyResolverType2"]
106+
* Denotes types that should be generated as classes. Resolver classes can inherit from these to enforce a type contract.
107+
* @description Type names can be optionally passed with the classMethods config to generate suspend functions or
108+
* `java.util.concurrent.CompletableFuture` functions.
109+
* @example
110+
* [
111+
* {
112+
* typeName: "MyResolverType",
113+
* },
114+
* {
115+
* typeName: "MySuspendResolverType",
116+
* classMethods: "SUSPEND",
117+
* },
118+
* {
119+
* typeName: "MyCompletableFutureResolverType",
120+
* classMethods: "COMPLETABLE_FUTURE",
121+
* }
122+
* ]
109123
*/
110-
resolverTypes: optional(array(string())),
111-
/**
112-
* Denotes extra arguments that should be added to functions on resolver classes.
113-
* @example [{ typeNames: ["MyType", "MyType2"], argumentName: "myArgument", argumentValue: "myValue" }]
114-
* @deprecated This will be removed in a future release now that DataFetchingEnvironment is added to functions by default.
115-
*/
116-
extraResolverArguments: optional(
124+
resolverClasses: optional(
117125
array(
118126
object({
119-
/**
120-
* The types whose fields to add the argument to. The argument will be added to all fields on each type. If omitted, the argument will be added to all fields on all types.
121-
*/
122-
typeNames: optional(array(string())),
123-
/**
124-
* The name of the argument to add.
125-
*/
126-
argumentName: string(),
127-
/**
128-
* The type of the argument to add.
129-
*/
130-
argumentType: string(),
127+
typeName: string(),
128+
classMethods: optional(
129+
union([literal("SUSPEND"), literal("COMPLETABLE_FUTURE")]),
130+
),
131131
}),
132132
),
133133
),

src/definitions/interface.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ limitations under the License.
1313

1414
import { GraphQLSchema, InterfaceTypeDefinitionNode } from "graphql";
1515
import { buildAnnotations } from "../helpers/build-annotations";
16-
import { indent } from "@graphql-codegen/visitor-plugin-common";
1716
import { buildTypeMetadata } from "../helpers/build-type-metadata";
1817
import { shouldIncludeTypeDefinition } from "../helpers/should-include-type-definition";
1918
import { buildFieldDefinition } from "../helpers/build-field-definition";
2019
import { CodegenConfigWithDefaults } from "../helpers/build-config-with-defaults";
20+
import { getDependentInterfaceNames } from "../helpers/dependent-type-utils";
2121

2222
export function buildInterfaceDefinition(
2323
node: InterfaceTypeDefinitionNode,
@@ -30,33 +30,27 @@ export function buildInterfaceDefinition(
3030

3131
const classMembers = node.fields
3232
?.map((fieldNode) => {
33-
const typeToUse = buildTypeMetadata(fieldNode.type, schema, config);
34-
35-
const annotations = buildAnnotations({
36-
config,
37-
definitionNode: fieldNode,
38-
});
39-
const fieldDefinition = buildFieldDefinition(
40-
fieldNode,
33+
const typeMetadata = buildTypeMetadata(fieldNode.type, schema, config);
34+
return buildFieldDefinition(
4135
node,
36+
fieldNode,
4237
schema,
4338
config,
39+
typeMetadata,
40+
Boolean(fieldNode.arguments?.length),
4441
);
45-
const fieldText = indent(
46-
`${fieldDefinition}: ${typeToUse.typeName}${
47-
typeToUse.isNullable ? "?" : ""
48-
}`,
49-
2,
50-
);
51-
return `${annotations}${fieldText}`;
5242
})
5343
.join("\n");
5444

5545
const annotations = buildAnnotations({
5646
config,
5747
definitionNode: node,
5848
});
59-
return `${annotations}interface ${node.name.value} {
49+
50+
const interfacesToInherit = getDependentInterfaceNames(node);
51+
const interfaceInheritance = `${interfacesToInherit.length ? ` : ${interfacesToInherit.join(", ")}` : ""}`;
52+
53+
return `${annotations}interface ${node.name.value}${interfaceInheritance} {
6054
${classMembers}
6155
}`;
6256
}

src/definitions/object.ts

Lines changed: 56 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,22 @@ limitations under the License.
1212
*/
1313

1414
import {
15+
FieldDefinitionNode,
1516
GraphQLSchema,
1617
isInputObjectType,
17-
isInterfaceType,
1818
ObjectTypeDefinitionNode,
1919
} from "graphql";
2020
import { buildAnnotations } from "../helpers/build-annotations";
21-
import { indent } from "@graphql-codegen/visitor-plugin-common";
2221
import { buildTypeMetadata } from "../helpers/build-type-metadata";
2322
import { shouldIncludeTypeDefinition } from "../helpers/should-include-type-definition";
2423
import {
2524
getDependentInterfaceNames,
2625
getDependentUnionsForType,
2726
} from "../helpers/dependent-type-utils";
28-
import { isResolverType } from "../helpers/is-resolver-type";
2927
import { buildFieldDefinition } from "../helpers/build-field-definition";
30-
import { isExternalField } from "../helpers/is-external-field";
3128
import { CodegenConfigWithDefaults } from "../helpers/build-config-with-defaults";
3229
import { inputTypeHasMatchingOutputType } from "../helpers/input-type-has-matching-output-type";
30+
import { findTypeInResolverClassesConfig } from "../helpers/findTypeInResolverClassesConfig";
3331

3432
export function buildObjectTypeDefinition(
3533
node: ObjectTypeDefinitionNode,
@@ -53,16 +51,6 @@ export function buildObjectTypeDefinition(
5351
: dependentInterfaces;
5452
const interfaceInheritance = `${interfacesToInherit.length ? ` : ${interfacesToInherit.join(", ")}` : ""}`;
5553

56-
if (isResolverType(node, config)) {
57-
return `${annotations}@GraphQLIgnore\ninterface ${name}${interfaceInheritance} {
58-
${getDataClassMembers({ node, schema, config })}
59-
}
60-
61-
${annotations}@GraphQLIgnore\ninterface ${name}CompletableFuture {
62-
${getDataClassMembers({ node, schema, config, completableFuture: true })}
63-
}`;
64-
}
65-
6654
const potentialMatchingInputType = schema.getType(`${name}Input`);
6755
const typeWillBeConsolidated =
6856
isInputObjectType(potentialMatchingInputType) &&
@@ -71,57 +59,79 @@ ${getDataClassMembers({ node, schema, config, completableFuture: true })}
7159
const outputRestrictionAnnotation = typeWillBeConsolidated
7260
? ""
7361
: "@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])\n";
62+
63+
const typeInResolverClassesConfig = findTypeInResolverClassesConfig(
64+
node,
65+
config,
66+
);
67+
const shouldGenerateFunctions = Boolean(
68+
typeInResolverClassesConfig ||
69+
node.fields?.some((fieldNode) => fieldNode.arguments?.length),
70+
);
71+
if (shouldGenerateFunctions) {
72+
const fieldsWithNoArguments = node.fields?.filter(
73+
(fieldNode) => !fieldNode.arguments?.length,
74+
);
75+
const constructor =
76+
!typeInResolverClassesConfig && fieldsWithNoArguments?.length
77+
? `(\n${fieldsWithNoArguments
78+
.map((fieldNode) => {
79+
const typeMetadata = buildTypeMetadata(
80+
fieldNode.type,
81+
schema,
82+
config,
83+
);
84+
return buildFieldDefinition(
85+
node,
86+
fieldNode,
87+
schema,
88+
config,
89+
typeMetadata,
90+
);
91+
})
92+
.join(",\n")}\n)`
93+
: "";
94+
95+
const fieldsWithArguments = node.fields?.filter(
96+
(fieldNode) => fieldNode.arguments?.length,
97+
);
98+
const fieldNodes = typeInResolverClassesConfig
99+
? node.fields
100+
: fieldsWithArguments;
101+
return `${annotations}${outputRestrictionAnnotation}open class ${name}${constructor}${interfaceInheritance} {
102+
${getDataClassMembers({ node, fieldNodes, schema, config, shouldGenerateFunctions })}
103+
}`;
104+
}
105+
74106
return `${annotations}${outputRestrictionAnnotation}data class ${name}(
75107
${getDataClassMembers({ node, schema, config })}
76108
)${interfaceInheritance}`;
77109
}
78110

79111
function getDataClassMembers({
80112
node,
113+
fieldNodes,
81114
schema,
82115
config,
83-
completableFuture,
116+
shouldGenerateFunctions,
84117
}: {
85118
node: ObjectTypeDefinitionNode;
119+
fieldNodes?: readonly FieldDefinitionNode[];
86120
schema: GraphQLSchema;
87121
config: CodegenConfigWithDefaults;
88-
completableFuture?: boolean;
122+
shouldGenerateFunctions?: boolean;
89123
}) {
90-
const resolverType = isResolverType(node, config);
91-
92-
return node.fields
124+
return (fieldNodes ?? node.fields)
93125
?.map((fieldNode) => {
94126
const typeMetadata = buildTypeMetadata(fieldNode.type, schema, config);
95-
const shouldOverrideField =
96-
!completableFuture &&
97-
node.interfaces?.some((interfaceNode) => {
98-
const typeNode = schema.getType(interfaceNode.name.value);
99-
return (
100-
isInterfaceType(typeNode) &&
101-
typeNode.astNode?.fields?.some(
102-
(field) => field.name.value === fieldNode.name.value,
103-
)
104-
);
105-
});
106-
const fieldDefinition = buildFieldDefinition(
107-
fieldNode,
127+
return buildFieldDefinition(
108128
node,
129+
fieldNode,
109130
schema,
110131
config,
111-
completableFuture,
112-
);
113-
const completableFutureDefinition = `java.util.concurrent.CompletableFuture<${typeMetadata.typeName}${typeMetadata.isNullable ? "?" : ""}>`;
114-
const defaultDefinition = `${typeMetadata.typeName}${isExternalField(fieldNode) ? (typeMetadata.isNullable ? "?" : "") : typeMetadata.defaultValue}`;
115-
const field = indent(
116-
`${shouldOverrideField ? "override " : ""}${fieldDefinition}: ${completableFuture ? completableFutureDefinition : defaultDefinition}`,
117-
2,
118-
);
119-
const annotations = buildAnnotations({
120-
config,
121-
definitionNode: fieldNode,
122132
typeMetadata,
123-
});
124-
return `${annotations}${field}`;
133+
shouldGenerateFunctions,
134+
);
125135
})
126-
.join(`${resolverType ? "" : ","}\n`);
136+
.join(`${shouldGenerateFunctions ? "" : ","}\n`);
127137
}

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

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,6 @@ export function buildConfigWithDefaults(
1515
"com.expediagroup.graphql.generator.annotations.*",
1616
...(config.extraImports ?? []),
1717
],
18-
extraResolverArguments: [
19-
{
20-
argumentName: "dataFetchingEnvironment",
21-
argumentType: "graphql.schema.DataFetchingEnvironment",
22-
},
23-
...(config.extraResolverArguments ?? []),
24-
],
2518
} as const satisfies GraphQLKotlinCodegenConfig;
2619
}
2720

0 commit comments

Comments
 (0)