Skip to content

Commit

Permalink
feat: generate open classes for types with field arguments (#54)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
danadajian committed Apr 30, 2024
1 parent 9e0a5ff commit 5f352a6
Show file tree
Hide file tree
Showing 26 changed files with 554 additions and 399 deletions.
118 changes: 118 additions & 0 deletions docs/docs/recommended-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
sidebar_position: 4
---

# Recommended Usage

In general, the `resolverClasses` config should be used to generate more performant code. This is especially important
when dealing with expensive operations, such as database queries or network requests. When at least one field has
arguments in a type, we generate an open class with function signatures to be inherited in source code.
However, when fields have no arguments, we generate data classes by default.

## Example

The following example demonstrates the problem with using generated data classes to implement your resolvers with GraphQL Kotlin.

Say you want to implement the schema below:

```graphql
type Query {
resolveMyType(input: String!): MyType
}

type MyType {
field1: String!
field2: String
}
```

### Here is the default behavior.

Generated Kotlin:

```kotlin
package com.types.generated

open class Query {
open fun resolveMyType(input: String): MyType = throw NotImplementedError("Query.resolveMyType must be implemented.")
}

data class MyType(
val field1: String,
val field2: String? = null
)
```

Source code:

```kotlin
import com.expediagroup.graphql.server.operations.Query
import com.expediagroup.sharedGraphql.generated.Query as QueryInterface
import com.types.generated.MyType

class MyQuery : Query, QueryInterface() {
override suspend fun resolveMyType(input: String): MyType =
MyType(
field1 = myExpensiveCall1(),
field2 = myExpensiveCall2()
)
}

```

The resulting source code is at risk of being extremely unperformant. The `MyType` class is a data class, which means
that the `field1` and `field2` properties are both initialized when the `MyType` object is created, and
`myExpensiveCall1()` and `myExpensiveCall2()` will both be called in sequence! Even if I only query for `field1`, not
only will `myExpensiveCall2()` still run, but it will also wait until `myExpensiveCall1()` is totally finished.

### Instead, use the `resolverClasses` config!

Codegen config:

```ts
import { GraphQLKotlinCodegenConfig } from "@expediagroup/graphql-kotlin-codegen";

export default {
resolverClasses: [
{
typeName: "MyType",
},
],
} satisfies GraphQLKotlinCodegenConfig;
```

Generated Kotlin:

```kotlin
package com.types.generated

open class Query {
open fun resolveMyType(input: String): MyType = throw NotImplementedError("Query.resolveMyType must be implemented.")
}

open class MyType {
open fun field1(): String = throw NotImplementedError("MyType.field1 must be implemented.")
open fun field2(): String? = throw NotImplementedError("MyType.field2 must be implemented.")
}
```

Source code:

```kotlin
import com.types.generated.MyType as MyTypeInterface
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore

class MyQuery : Query, QueryInterface() {
override suspend fun resolveMyType(input: String): MyType = MyType()
}

@GraphQLIgnore
class MyType : MyTypeInterface() {
override fun field1(): String = myExpensiveCall1()
override fun field2(): String? = myExpensiveCall2()
}
```

This code is much more performant. The `MyType` class is no longer a data class, so the `field1` and `field2` properties
can now be resolved independently of each other. If I query for only `field1`, only `myExpensiveCall1()` will be called, and
if I query for only `field2`, only `myExpensiveCall2()` will be called.
44 changes: 22 additions & 22 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,31 +103,31 @@ export const configSchema = object({
),
),
/**
* Denotes types that should be generated as interfaces with suspense functions. Resolver classes can inherit from these to enforce a type contract.
* @description Two interfaces will be generated: one with suspend functions, and one with `java.util.concurrent.CompletableFuture` functions.
* @example ["MyResolverType1", "MyResolverType2"]
* Denotes types that should be generated as classes. Resolver classes can inherit from these to enforce a type contract.
* @description Type names can be optionally passed with the classMethods config to generate suspend functions or
* `java.util.concurrent.CompletableFuture` functions.
* @example
* [
* {
* typeName: "MyResolverType",
* },
* {
* typeName: "MySuspendResolverType",
* classMethods: "SUSPEND",
* },
* {
* typeName: "MyCompletableFutureResolverType",
* classMethods: "COMPLETABLE_FUTURE",
* }
* ]
*/
resolverTypes: optional(array(string())),
/**
* Denotes extra arguments that should be added to functions on resolver classes.
* @example [{ typeNames: ["MyType", "MyType2"], argumentName: "myArgument", argumentValue: "myValue" }]
* @deprecated This will be removed in a future release now that DataFetchingEnvironment is added to functions by default.
*/
extraResolverArguments: optional(
resolverClasses: optional(
array(
object({
/**
* 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.
*/
typeNames: optional(array(string())),
/**
* The name of the argument to add.
*/
argumentName: string(),
/**
* The type of the argument to add.
*/
argumentType: string(),
typeName: string(),
classMethods: optional(
union([literal("SUSPEND"), literal("COMPLETABLE_FUTURE")]),
),
}),
),
),
Expand Down
28 changes: 11 additions & 17 deletions src/definitions/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ limitations under the License.

import { GraphQLSchema, InterfaceTypeDefinitionNode } from "graphql";
import { buildAnnotations } from "../helpers/build-annotations";
import { indent } from "@graphql-codegen/visitor-plugin-common";
import { buildTypeMetadata } from "../helpers/build-type-metadata";
import { shouldIncludeTypeDefinition } from "../helpers/should-include-type-definition";
import { buildFieldDefinition } from "../helpers/build-field-definition";
import { CodegenConfigWithDefaults } from "../helpers/build-config-with-defaults";
import { getDependentInterfaceNames } from "../helpers/dependent-type-utils";

export function buildInterfaceDefinition(
node: InterfaceTypeDefinitionNode,
Expand All @@ -30,33 +30,27 @@ export function buildInterfaceDefinition(

const classMembers = node.fields
?.map((fieldNode) => {
const typeToUse = buildTypeMetadata(fieldNode.type, schema, config);

const annotations = buildAnnotations({
config,
definitionNode: fieldNode,
});
const fieldDefinition = buildFieldDefinition(
fieldNode,
const typeMetadata = buildTypeMetadata(fieldNode.type, schema, config);
return buildFieldDefinition(
node,
fieldNode,
schema,
config,
typeMetadata,
Boolean(fieldNode.arguments?.length),
);
const fieldText = indent(
`${fieldDefinition}: ${typeToUse.typeName}${
typeToUse.isNullable ? "?" : ""
}`,
2,
);
return `${annotations}${fieldText}`;
})
.join("\n");

const annotations = buildAnnotations({
config,
definitionNode: node,
});
return `${annotations}interface ${node.name.value} {

const interfacesToInherit = getDependentInterfaceNames(node);
const interfaceInheritance = `${interfacesToInherit.length ? ` : ${interfacesToInherit.join(", ")}` : ""}`;

return `${annotations}interface ${node.name.value}${interfaceInheritance} {
${classMembers}
}`;
}
102 changes: 56 additions & 46 deletions src/definitions/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,22 @@ limitations under the License.
*/

import {
FieldDefinitionNode,
GraphQLSchema,
isInputObjectType,
isInterfaceType,
ObjectTypeDefinitionNode,
} from "graphql";
import { buildAnnotations } from "../helpers/build-annotations";
import { indent } from "@graphql-codegen/visitor-plugin-common";
import { buildTypeMetadata } from "../helpers/build-type-metadata";
import { shouldIncludeTypeDefinition } from "../helpers/should-include-type-definition";
import {
getDependentInterfaceNames,
getDependentUnionsForType,
} from "../helpers/dependent-type-utils";
import { isResolverType } from "../helpers/is-resolver-type";
import { buildFieldDefinition } from "../helpers/build-field-definition";
import { isExternalField } from "../helpers/is-external-field";
import { CodegenConfigWithDefaults } from "../helpers/build-config-with-defaults";
import { inputTypeHasMatchingOutputType } from "../helpers/input-type-has-matching-output-type";
import { findTypeInResolverClassesConfig } from "../helpers/findTypeInResolverClassesConfig";

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

if (isResolverType(node, config)) {
return `${annotations}@GraphQLIgnore\ninterface ${name}${interfaceInheritance} {
${getDataClassMembers({ node, schema, config })}
}
${annotations}@GraphQLIgnore\ninterface ${name}CompletableFuture {
${getDataClassMembers({ node, schema, config, completableFuture: true })}
}`;
}

const potentialMatchingInputType = schema.getType(`${name}Input`);
const typeWillBeConsolidated =
isInputObjectType(potentialMatchingInputType) &&
Expand All @@ -71,57 +59,79 @@ ${getDataClassMembers({ node, schema, config, completableFuture: true })}
const outputRestrictionAnnotation = typeWillBeConsolidated
? ""
: "@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])\n";

const typeInResolverClassesConfig = findTypeInResolverClassesConfig(
node,
config,
);
const shouldGenerateFunctions = Boolean(
typeInResolverClassesConfig ||
node.fields?.some((fieldNode) => fieldNode.arguments?.length),
);
if (shouldGenerateFunctions) {
const fieldsWithNoArguments = node.fields?.filter(
(fieldNode) => !fieldNode.arguments?.length,
);
const constructor =
!typeInResolverClassesConfig && fieldsWithNoArguments?.length
? `(\n${fieldsWithNoArguments
.map((fieldNode) => {
const typeMetadata = buildTypeMetadata(
fieldNode.type,
schema,
config,
);
return buildFieldDefinition(
node,
fieldNode,
schema,
config,
typeMetadata,
);
})
.join(",\n")}\n)`
: "";

const fieldsWithArguments = node.fields?.filter(
(fieldNode) => fieldNode.arguments?.length,
);
const fieldNodes = typeInResolverClassesConfig
? node.fields
: fieldsWithArguments;
return `${annotations}${outputRestrictionAnnotation}open class ${name}${constructor}${interfaceInheritance} {
${getDataClassMembers({ node, fieldNodes, schema, config, shouldGenerateFunctions })}
}`;
}

return `${annotations}${outputRestrictionAnnotation}data class ${name}(
${getDataClassMembers({ node, schema, config })}
)${interfaceInheritance}`;
}

function getDataClassMembers({
node,
fieldNodes,
schema,
config,
completableFuture,
shouldGenerateFunctions,
}: {
node: ObjectTypeDefinitionNode;
fieldNodes?: readonly FieldDefinitionNode[];
schema: GraphQLSchema;
config: CodegenConfigWithDefaults;
completableFuture?: boolean;
shouldGenerateFunctions?: boolean;
}) {
const resolverType = isResolverType(node, config);

return node.fields
return (fieldNodes ?? node.fields)
?.map((fieldNode) => {
const typeMetadata = buildTypeMetadata(fieldNode.type, schema, config);
const shouldOverrideField =
!completableFuture &&
node.interfaces?.some((interfaceNode) => {
const typeNode = schema.getType(interfaceNode.name.value);
return (
isInterfaceType(typeNode) &&
typeNode.astNode?.fields?.some(
(field) => field.name.value === fieldNode.name.value,
)
);
});
const fieldDefinition = buildFieldDefinition(
fieldNode,
return buildFieldDefinition(
node,
fieldNode,
schema,
config,
completableFuture,
);
const completableFutureDefinition = `java.util.concurrent.CompletableFuture<${typeMetadata.typeName}${typeMetadata.isNullable ? "?" : ""}>`;
const defaultDefinition = `${typeMetadata.typeName}${isExternalField(fieldNode) ? (typeMetadata.isNullable ? "?" : "") : typeMetadata.defaultValue}`;
const field = indent(
`${shouldOverrideField ? "override " : ""}${fieldDefinition}: ${completableFuture ? completableFutureDefinition : defaultDefinition}`,
2,
);
const annotations = buildAnnotations({
config,
definitionNode: fieldNode,
typeMetadata,
});
return `${annotations}${field}`;
shouldGenerateFunctions,
);
})
.join(`${resolverType ? "" : ","}\n`);
.join(`${shouldGenerateFunctions ? "" : ","}\n`);
}
7 changes: 0 additions & 7 deletions src/helpers/build-config-with-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,6 @@ export function buildConfigWithDefaults(
"com.expediagroup.graphql.generator.annotations.*",
...(config.extraImports ?? []),
],
extraResolverArguments: [
{
argumentName: "dataFetchingEnvironment",
argumentType: "graphql.schema.DataFetchingEnvironment",
},
...(config.extraResolverArguments ?? []),
],
} as const satisfies GraphQLKotlinCodegenConfig;
}

Expand Down
Loading

0 comments on commit 5f352a6

Please sign in to comment.