From d173cefc96eb9d98c276607eb21e4cd1555b9615 Mon Sep 17 00:00:00 2001 From: Dan Adajian Date: Wed, 12 Jun 2024 09:04:58 -0500 Subject: [PATCH] fix: support reserved kotlin keywords (#83) * unit test * implement the fix * implement the fix * rename unit test --- src/definitions/enum.ts | 9 ++- src/definitions/field.ts | 9 +-- src/definitions/input.ts | 11 ++-- src/definitions/interface.ts | 3 +- src/definitions/object.ts | 8 ++- src/definitions/union.ts | 9 ++- src/utils/sanitize-name.ts | 53 +++++++++++++++++ .../codegen.config.ts | 5 ++ .../expected.kt | 58 +++++++++++++++++++ .../schema.graphql | 41 +++++++++++++ 10 files changed, 189 insertions(+), 17 deletions(-) create mode 100644 src/utils/sanitize-name.ts create mode 100644 test/unit/should_handle_reserved_kotlin_keywords/codegen.config.ts create mode 100644 test/unit/should_handle_reserved_kotlin_keywords/expected.kt create mode 100644 test/unit/should_handle_reserved_kotlin_keywords/schema.graphql diff --git a/src/definitions/enum.ts b/src/definitions/enum.ts index 0cfd696..9bd94d6 100644 --- a/src/definitions/enum.ts +++ b/src/definitions/enum.ts @@ -16,6 +16,7 @@ import { indentMultiline } from "@graphql-codegen/visitor-plugin-common"; import { buildAnnotations } from "../annotations/build-annotations"; import { shouldExcludeTypeDefinition } from "../config/should-exclude-type-definition"; import { CodegenConfigWithDefaults } from "../config/build-config-with-defaults"; +import { sanitizeName } from "../utils/sanitize-name"; export function buildEnumTypeDefinition( node: EnumTypeDefinitionNode, @@ -25,7 +26,7 @@ export function buildEnumTypeDefinition( return ""; } - const enumName = node.name.value; + const enumName = sanitizeName(node.name.value); const enumValues = node.values?.map((valueNode) => { return buildEnumValueDefinition(valueNode, config); @@ -52,5 +53,9 @@ function buildEnumValueDefinition( config, definitionNode: node, }); - return `${annotations}${config.convert?.(node)}`; + if (!config.convert) { + throw new Error("Convert function was somehow not found in the config."); + } + const fieldName = sanitizeName(config.convert(node)); + return `${annotations}${fieldName}`; } diff --git a/src/definitions/field.ts b/src/definitions/field.ts index 7ed3c28..c8e05ee 100644 --- a/src/definitions/field.ts +++ b/src/definitions/field.ts @@ -25,6 +25,7 @@ import { indent } from "@graphql-codegen/visitor-plugin-common"; import { buildAnnotations } from "../annotations/build-annotations"; import { findTypeInResolverInterfacesConfig } from "../config/find-type-in-resolver-interfaces-config"; import { shouldGenerateFunctionsInClass } from "./object"; +import { sanitizeName } from "../utils/sanitize-name"; export function buildObjectFieldDefinition({ node, @@ -209,7 +210,7 @@ function buildFunctionDefinition( typeInResolverInterfacesConfig, config, ); - return `${modifier} ${fieldNode.name.value}${fieldArguments}`; + return `${modifier} ${sanitizeName(fieldNode.name.value)}${fieldArguments}`; } function buildConstructorFunctionDefinition( @@ -229,7 +230,7 @@ function buildConstructorFunctionDefinition( typeInResolverInterfacesConfig, ); const fieldArguments = ""; - return `${modifier} ${fieldNode.name.value}${fieldArguments}`; + return `${modifier} ${sanitizeName(fieldNode.name.value)}${fieldArguments}`; } function buildFieldModifier( @@ -284,7 +285,7 @@ function buildFieldArguments( const nullableSuffix = isOverrideFunction ? "?" : "? = null"; const existingFieldArguments = fieldNode.arguments?.map((arg) => { const argMetadata = buildTypeMetadata(arg.type, schema, config); - return `${arg.name.value}: ${argMetadata.typeName}${arg.type.kind === Kind.NON_NULL_TYPE ? "" : nullableSuffix}`; + return `${sanitizeName(arg.name.value)}: ${argMetadata.typeName}${arg.type.kind === Kind.NON_NULL_TYPE ? "" : nullableSuffix}`; }); const dataFetchingEnvironmentArgument = "dataFetchingEnvironment: graphql.schema.DataFetchingEnvironment"; @@ -323,7 +324,7 @@ function getDefaultImplementation( (fieldNode) => !fieldNode.arguments?.length, ); return !typeInResolverInterfacesConfig && atLeastOneFieldHasNoArguments - ? fieldNode.name.value + ? sanitizeName(fieldNode.name.value) : notImplementedError; } diff --git a/src/definitions/input.ts b/src/definitions/input.ts index 0205448..28b9d6b 100644 --- a/src/definitions/input.ts +++ b/src/definitions/input.ts @@ -18,6 +18,7 @@ import { buildAnnotations } from "../annotations/build-annotations"; import { indent } from "@graphql-codegen/visitor-plugin-common"; import { CodegenConfigWithDefaults } from "../config/build-config-with-defaults"; import { inputTypeHasMatchingOutputType } from "../utils/input-type-has-matching-output-type"; +import { sanitizeName } from "../utils/sanitize-name"; export function buildInputObjectDefinition( node: InputObjectTypeDefinitionNode, @@ -34,16 +35,16 @@ export function buildInputObjectDefinition( } const classMembers = (node.fields ?? []) - .map((arg) => { - const typeToUse = buildTypeMetadata(arg.type, schema, config); + .map((field) => { + const typeToUse = buildTypeMetadata(field.type, schema, config); const initial = typeToUse.isNullable ? " = null" : ""; const annotations = buildAnnotations({ config, - definitionNode: arg, + definitionNode: field, }); return `${annotations}${indent( - `val ${arg.name.value}: ${typeToUse.typeName}${ + `val ${sanitizeName(field.name.value)}: ${typeToUse.typeName}${ typeToUse.isNullable ? "?" : "" }${initial}`, 2, @@ -58,7 +59,7 @@ export function buildInputObjectDefinition( const inputRestrictionAnnotation = "@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.INPUT_OBJECT])\n"; - return `${annotations}${inputRestrictionAnnotation}data class ${node.name.value}( + return `${annotations}${inputRestrictionAnnotation}data class ${sanitizeName(node.name.value)}( ${classMembers} )`; } diff --git a/src/definitions/interface.ts b/src/definitions/interface.ts index c95ecd6..c15bb31 100644 --- a/src/definitions/interface.ts +++ b/src/definitions/interface.ts @@ -17,6 +17,7 @@ import { shouldExcludeTypeDefinition } from "../config/should-exclude-type-defin import { buildInterfaceFieldDefinition } from "./field"; import { CodegenConfigWithDefaults } from "../config/build-config-with-defaults"; import { getDependentInterfaceNames } from "../utils/dependent-type-utils"; +import { sanitizeName } from "../utils/sanitize-name"; export function buildInterfaceDefinition( node: InterfaceTypeDefinitionNode, @@ -46,7 +47,7 @@ export function buildInterfaceDefinition( const interfacesToInherit = getDependentInterfaceNames(node); const interfaceInheritance = `${interfacesToInherit.length ? ` : ${interfacesToInherit.join(", ")}` : ""}`; - return `${annotations}interface ${node.name.value}${interfaceInheritance} { + return `${annotations}interface ${sanitizeName(node.name.value)}${interfaceInheritance} { ${classMembers} }`; } diff --git a/src/definitions/object.ts b/src/definitions/object.ts index 0026f06..f2e42d3 100644 --- a/src/definitions/object.ts +++ b/src/definitions/object.ts @@ -30,6 +30,7 @@ import { import { CodegenConfigWithDefaults } from "../config/build-config-with-defaults"; import { inputTypeHasMatchingOutputType } from "../utils/input-type-has-matching-output-type"; import { findTypeInResolverInterfacesConfig } from "../config/find-type-in-resolver-interfaces-config"; +import { sanitizeName } from "../utils/sanitize-name"; export function buildObjectTypeDefinition( node: ObjectTypeDefinitionNode, @@ -44,14 +45,17 @@ export function buildObjectTypeDefinition( config, definitionNode: node, }); - const name = node.name.value; + const name = sanitizeName(node.name.value); const dependentInterfaces = getDependentInterfaceNames(node); const dependentUnions = getDependentUnionsForType(schema, node); const interfacesToInherit = config.unionGeneration === "MARKER_INTERFACE" ? dependentInterfaces.concat(dependentUnions) : dependentInterfaces; - const interfaceInheritance = `${interfacesToInherit.length ? ` : ${interfacesToInherit.join(", ")}` : ""}`; + const sanitizedInterfaceNames = interfacesToInherit.map((_interface) => + sanitizeName(_interface), + ); + const interfaceInheritance = `${interfacesToInherit.length ? ` : ${sanitizedInterfaceNames.join(", ")}` : ""}`; const potentialMatchingInputType = schema.getType(`${name}Input`); const typeWillBeConsolidated = diff --git a/src/definitions/union.ts b/src/definitions/union.ts index 1ee8a0e..039cc79 100644 --- a/src/definitions/union.ts +++ b/src/definitions/union.ts @@ -18,6 +18,7 @@ import { buildAnnotations, trimDescription, } from "../annotations/build-annotations"; +import { sanitizeName } from "../utils/sanitize-name"; export function buildUnionTypeDefinition( node: UnionTypeDefinitionNode, @@ -31,15 +32,17 @@ export function buildUnionTypeDefinition( definitionNode: node, }); if (config.unionGeneration === "MARKER_INTERFACE") { - return `${annotations}interface ${node.name.value}`; + return `${annotations}interface ${sanitizeName(node.name.value)}`; } const possibleTypes = - node.types?.map((type) => `${type.name.value}::class`).join(", ") || ""; + node.types + ?.map((type) => `${sanitizeName(type.name.value)}::class`) + .join(", ") || ""; return `${annotations}@GraphQLUnion( name = "${node.name.value}", possibleTypes = [${possibleTypes}], description = "${trimDescription(node.description?.value)}" ) -annotation class ${node.name.value}`; +annotation class ${sanitizeName(node.name.value)}`; } diff --git a/src/utils/sanitize-name.ts b/src/utils/sanitize-name.ts new file mode 100644 index 0000000..cadbda5 --- /dev/null +++ b/src/utils/sanitize-name.ts @@ -0,0 +1,53 @@ +/* +Copyright 2024 Expedia, Inc. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + https://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Sanitizes a name of a field or type if it is a reserved keyword in Kotlin. + */ +export function sanitizeName(name: string) { + return RESERVED_KEYWORDS.includes(name) ? `\`${name}\`` : name; +} + +/** + * https://kotlinlang.org/docs/keyword-reference.html#hard-keywords + */ +const RESERVED_KEYWORDS = [ + "as", + "break", + "class", + "continue", + "do", + "else", + "false", + "for", + "fun", + "if", + "in", + "interface", + "is", + "null", + "object", + "package", + "return", + "super", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "var", + "when", + "while", +] as const; diff --git a/test/unit/should_handle_reserved_kotlin_keywords/codegen.config.ts b/test/unit/should_handle_reserved_kotlin_keywords/codegen.config.ts new file mode 100644 index 0000000..c96d830 --- /dev/null +++ b/test/unit/should_handle_reserved_kotlin_keywords/codegen.config.ts @@ -0,0 +1,5 @@ +import { GraphQLKotlinCodegenConfig } from "../../../src/plugin"; + +export default { + namingConvention: "keep", +} satisfies GraphQLKotlinCodegenConfig; diff --git a/test/unit/should_handle_reserved_kotlin_keywords/expected.kt b/test/unit/should_handle_reserved_kotlin_keywords/expected.kt new file mode 100644 index 0000000..c6a91e9 --- /dev/null +++ b/test/unit/should_handle_reserved_kotlin_keywords/expected.kt @@ -0,0 +1,58 @@ +package com.kotlin.generated + +import com.expediagroup.graphql.generator.annotations.* + +@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT]) +data class TypeWithReservedKotlinKeywords( + val `as`: String? = null, + val `break`: String? = null, + val `is`: String? = null +) + +@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT]) +open class TypeWithReservedKotlinKeywordsAndFieldArgs( + val `typeof`: String? = null, + private val `throw`: String? = null +) { + open fun `throw`(`else`: String? = null, dataFetchingEnvironment: graphql.schema.DataFetchingEnvironment): String? = `throw` +} + +@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT]) +data class `true`( + val field: String? = null +) + +@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.INPUT_OBJECT]) +data class InputWithReservedKotlinKeywords( + val `continue`: String? = null, + val `class`: String? = null, + val `do`: String? = null +) + +enum class EnumWithReservedKotlinKeywords { + `fun`, + `package`, + `val`; + + companion object { + fun findByName(name: String, ignoreCase: Boolean = false): EnumWithReservedKotlinKeywords? = values().find { it.name.equals(name, ignoreCase = ignoreCase) } + } +} + +interface InterfaceWithReservedKotlinKeywords { + val `null`: String? + val `return`: String? + val `object`: String? +} + +@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT]) +data class TypeForUnion1( + val field: String? = null +) : `this` + +@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT]) +data class TypeForUnion2( + val field: String? = null +) : `this` + +interface `this` diff --git a/test/unit/should_handle_reserved_kotlin_keywords/schema.graphql b/test/unit/should_handle_reserved_kotlin_keywords/schema.graphql new file mode 100644 index 0000000..b22844f --- /dev/null +++ b/test/unit/should_handle_reserved_kotlin_keywords/schema.graphql @@ -0,0 +1,41 @@ +type TypeWithReservedKotlinKeywords { + as: String + break: String + is: String +} + +type TypeWithReservedKotlinKeywordsAndFieldArgs { + typeof: String + throw(else: String): String +} + +type true { + field: String +} + +input InputWithReservedKotlinKeywords { + continue: String + class: String + do: String +} + +enum EnumWithReservedKotlinKeywords { + fun + package + val +} + +interface InterfaceWithReservedKotlinKeywords { + null: String + return: String + object: String +} + +type TypeForUnion1 { + field: String +} +type TypeForUnion2 { + field: String +} + +union this = TypeForUnion1 | TypeForUnion2