From c608c5eb23454c5fb1881c80df3a45dd16d32ffd Mon Sep 17 00:00:00 2001 From: Zach FettersMoore <4425109+BobaFetters@users.noreply.github.com> Date: Thu, 10 Aug 2023 13:05:00 -0400 Subject: [PATCH] feature: Add support for field casing strategy to code gen (#3171) Co-authored-by: Calvin Cestari Co-authored-by: Anthony Miller --- .../ApolloCodegenConfiguration.swift | 105 +++++++++++++++--- .../Templates/InputObjectTemplate.swift | 6 +- .../GraphQLEnumValue+Rendered.swift | 21 +--- .../OperationTemplateRenderer.swift | 8 +- .../String+SwiftNameEscaping.swift | 43 ++++++- .../Templates/SelectionSetTemplate.swift | 6 +- ...olloCodegenConfigurationCodableTests.swift | 8 +- .../Templates/EnumTemplateTests.swift | 2 +- ...nDefinition_VariableDefinition_Tests.swift | 2 +- .../SelectionSetTemplateTests.swift | 76 ++++++++++++- .../code-generation/codegen-configuration.mdx | 6 +- 11 files changed, 223 insertions(+), 60 deletions(-) diff --git a/Sources/ApolloCodegenLib/ApolloCodegenConfiguration.swift b/Sources/ApolloCodegenLib/ApolloCodegenConfiguration.swift index 974b8a0b59..fd9a35e2c4 100644 --- a/Sources/ApolloCodegenLib/ApolloCodegenConfiguration.swift +++ b/Sources/ApolloCodegenLib/ApolloCodegenConfiguration.swift @@ -651,37 +651,63 @@ public struct ApolloCodegenConfiguration: Codable, Equatable { case exclude } - /// ``CaseConversionStrategy`` is used to specify the strategy used to convert the casing of - /// GraphQL schema values into generated Swift code. - public enum CaseConversionStrategy: String, Codable, Equatable { - /// Generates swift code using the exact name provided in the GraphQL schema - /// performing no conversion. - case none - /// Convert to lower camel case from `snake_case`, `UpperCamelCase`, or `UPPERCASE`. - case camelCase - } - /// ``ConversionStrategies`` configures rules for how to convert the names of values from the /// schema in generated code. public struct ConversionStrategies: Codable, Equatable { + + /// ``ApolloCodegenConfiguration/ConversionStrategies/EnumCase`` is used to specify the strategy + /// used to convert the casing of enum cases in a GraphQL schema into generated Swift code. + public enum EnumCases: String, Codable, Equatable { + /// Generates swift code using the exact name provided in the GraphQL schema + /// performing no conversion. + case none + /// Convert to lower camel case from `snake_case`, `UpperCamelCase`, or `UPPERCASE`. + case camelCase + } + + /// ``ApolloCodegenConfiguration/ConversionStrategies/FieldAccessors`` is used to specify the + /// strategy used to convert the casing of fields on GraphQL selection sets into field accessors + /// on the response models in generated Swift code. + public enum FieldAccessors: String, Codable, Equatable { + /// This conversion strategy will: + /// - Lowercase the first letter of all fields. + /// - Convert field names that are all `UPPERCASE` to all `lowercase`. + case idiomatic + /// This conversion strategy will: + /// - Convert to `lowerCamelCase` from `snake_case`, or `UpperCamelCase`. + /// - Convert field names that are all `UPPERCASE` to all `lowercase`. + case camelCase + } + /// Determines how the names of enum cases in the GraphQL schema will be converted into /// cases on the generated Swift enums. /// Defaultss to ``ApolloCodegenConfiguration/CaseConversionStrategy/camelCase`` - public let enumCases: CaseConversionStrategy + public let enumCases: EnumCases + + /// Determines how the names of fields in the GraphQL schema will be converted into + /// properties in the generated Swift code. + /// Defaults to ``ApolloCodegenConfiguration/CaseConversionStrategy/camelCase`` + public let fieldAccessors: FieldAccessors /// Default property values public struct Default { - public static let enumCases: CaseConversionStrategy = .camelCase + public static let enumCases: EnumCases = .camelCase + public static let fieldAccessors: FieldAccessors = .idiomatic } - - public init(enumCases: CaseConversionStrategy = Default.enumCases) { + + public init( + enumCases: EnumCases = Default.enumCases, + fieldAccessors: FieldAccessors = Default.fieldAccessors + ) { self.enumCases = enumCases + self.fieldAccessors = fieldAccessors } // MARK: Codable public enum CodingKeys: CodingKey { case enumCases + case fieldAccessors } public init(from decoder: Decoder) throws { @@ -689,15 +715,32 @@ public struct ApolloCodegenConfiguration: Codable, Equatable { guard values.allKeys.first != nil else { throw DecodingError.typeMismatch(Self.self, DecodingError.Context.init( codingPath: values.codingPath, - debugDescription: "Invalid number of keys found, expected one.", + debugDescription: "Invalid value found.", underlyingError: nil )) } - enumCases = try values.decodeIfPresent( + if let deprecatedEnumCase = try? values.decodeIfPresent( CaseConversionStrategy.self, forKey: .enumCases - ) ?? Default.enumCases + ) { + switch deprecatedEnumCase { + case .none: + enumCases = .none + case .camelCase: + enumCases = .camelCase + } + } else { + enumCases = try values.decodeIfPresent( + EnumCases.self, + forKey: .enumCases + ) ?? Default.enumCases + } + + fieldAccessors = try values.decodeIfPresent( + FieldAccessors.self, + forKey: .fieldAccessors + ) ?? Default.fieldAccessors } } @@ -1396,6 +1439,34 @@ extension ApolloCodegenConfiguration.OutputOptions { } } +extension ApolloCodegenConfiguration.ConversionStrategies { + + @available(*, deprecated, renamed: "init(enumCases:fieldAccessors:)") + public init( + enumCases: CaseConversionStrategy + ) { + switch enumCases { + case .none: + self.enumCases = .none + case .camelCase: + self.enumCases = .camelCase + } + self.fieldAccessors = Default.fieldAccessors + } + + /// ``CaseConversionStrategy`` is used to specify the strategy used to convert the casing of + /// GraphQL schema values into generated Swift code. + @available(*, deprecated, message: "Use EnumCaseConversionStrategy instead.") + public enum CaseConversionStrategy: String, Codable, Equatable { + /// Generates swift code using the exact name provided in the GraphQL schema + /// performing no conversion. + case none + /// Convert to lower camel case from `snake_case`, `UpperCamelCase`, or `UPPERCASE`. + case camelCase + } + +} + private struct AnyCodingKey: CodingKey { var stringValue: String diff --git a/Sources/ApolloCodegenLib/Templates/InputObjectTemplate.swift b/Sources/ApolloCodegenLib/Templates/InputObjectTemplate.swift index 8685f63394..aadcf6e0c8 100644 --- a/Sources/ApolloCodegenLib/Templates/InputObjectTemplate.swift +++ b/Sources/ApolloCodegenLib/Templates/InputObjectTemplate.swift @@ -92,7 +92,7 @@ struct InputObjectTemplate: TemplateRenderer { ) -> TemplateString { TemplateString(""" \(fields.map({ - "\($1.name.asFieldPropertyName): \($1.renderInputValueType(includeDefault: true, config: config.config))" + "\($1.name.renderAsFieldPropertyName(config: config.config)): \($1.renderInputValueType(includeDefault: true, config: config.config))" }), separator: ",\n") """) } @@ -101,7 +101,7 @@ struct InputObjectTemplate: TemplateRenderer { _ fields: GraphQLInputFieldDictionary ) -> TemplateString { TemplateString(""" - \(fields.map({ "\"\($1.name)\": \($1.name.asFieldPropertyName)" }), separator: ",\n") + \(fields.map({ "\"\($1.name)\": \($1.name.renderAsFieldPropertyName(config: config.config))" }), separator: ",\n") """) } @@ -110,7 +110,7 @@ struct InputObjectTemplate: TemplateRenderer { \(documentation: field.documentation, config: config) \(deprecationReason: field.deprecationReason, config: config) \(accessControlModifier(for: .member))\ - var \(field.name.asFieldPropertyName): \(field.renderInputValueType(config: config.config)) { + var \(field.name.renderAsFieldPropertyName(config: config.config)): \(field.renderInputValueType(config: config.config)) { get { __data["\(field.name)"] } set { __data["\(field.name)"] = newValue } } diff --git a/Sources/ApolloCodegenLib/Templates/RenderingHelpers/GraphQLEnumValue+Rendered.swift b/Sources/ApolloCodegenLib/Templates/RenderingHelpers/GraphQLEnumValue+Rendered.swift index 8a66d4df24..8b3e3f8493 100644 --- a/Sources/ApolloCodegenLib/Templates/RenderingHelpers/GraphQLEnumValue+Rendered.swift +++ b/Sources/ApolloCodegenLib/Templates/RenderingHelpers/GraphQLEnumValue+Rendered.swift @@ -19,27 +19,8 @@ extension GraphQLEnumValue.Name { return value.asEnumCaseName case (.swiftEnumCase, .camelCase): - return convertToCamelCase(value).asEnumCaseName + return value.convertToCamelCase().asEnumCaseName } } - /// Convert to `camelCase` from a number of different `snake_case` variants. - /// - /// All inner `_` characters will be removed, each 'word' will be capitalized, returning a final - /// firstLowercased string while preserving original leading and trailing `_` characters. - private func convertToCamelCase(_ value: String) -> String { - guard value.firstIndex(of: "_") != nil else { - if value.firstIndex(where: { $0.isLowercase }) != nil { - return value.firstLowercased - } else { - return value.lowercased() - } - } - - return value.components(separatedBy: "_") - .map({ $0.isEmpty ? "_" : $0.capitalized }) - .joined() - .firstLowercased - } - } diff --git a/Sources/ApolloCodegenLib/Templates/RenderingHelpers/OperationTemplateRenderer.swift b/Sources/ApolloCodegenLib/Templates/RenderingHelpers/OperationTemplateRenderer.swift index ec64c5c048..c5a9e4c2fa 100644 --- a/Sources/ApolloCodegenLib/Templates/RenderingHelpers/OperationTemplateRenderer.swift +++ b/Sources/ApolloCodegenLib/Templates/RenderingHelpers/OperationTemplateRenderer.swift @@ -13,7 +13,7 @@ extension OperationTemplateRenderer { return """ \(`init`)(\(list: variables.map(VariableParameter))) { \(variables.map { - let name = $0.name.asFieldPropertyName + let name = $0.name.renderAsFieldPropertyName(config: config.config) return "self.\(name) = \(name)" }, separator: "\n") } @@ -24,7 +24,7 @@ extension OperationTemplateRenderer { _ variables: [CompilationResult.VariableDefinition] ) -> TemplateString { """ - \(variables.map { "public var \($0.name.asFieldPropertyName): \($0.type.rendered(as: .inputValue, config: config.config))"}, separator: "\n") + \(variables.map { "public var \($0.name.renderAsFieldPropertyName(config: config.config)): \($0.type.rendered(as: .inputValue, config: config.config))"}, separator: "\n") """ } @@ -32,7 +32,7 @@ extension OperationTemplateRenderer { _ variable: CompilationResult.VariableDefinition ) -> TemplateString { """ - \(variable.name.asFieldPropertyName): \(variable.type.rendered(as: .inputValue, config: config.config))\ + \(variable.name.renderAsFieldPropertyName(config: config.config)): \(variable.type.rendered(as: .inputValue, config: config.config))\ \(if: variable.defaultValue != nil, " = " + variable.renderVariableDefaultValue(config: config.config)) """ } @@ -46,7 +46,7 @@ extension OperationTemplateRenderer { } return """ - public var __variables: \(if: !graphQLOperation, "GraphQLOperation.")Variables? { [\(list: variables.map { "\"\($0.name)\": \($0.name.asFieldPropertyName)"})] } + public var __variables: \(if: !graphQLOperation, "GraphQLOperation.")Variables? { [\(list: variables.map { "\"\($0.name)\": \($0.name.renderAsFieldPropertyName(config: config.config))"})] } """ } diff --git a/Sources/ApolloCodegenLib/Templates/RenderingHelpers/String+SwiftNameEscaping.swift b/Sources/ApolloCodegenLib/Templates/RenderingHelpers/String+SwiftNameEscaping.swift index badb3afc3c..590afe7488 100644 --- a/Sources/ApolloCodegenLib/Templates/RenderingHelpers/String+SwiftNameEscaping.swift +++ b/Sources/ApolloCodegenLib/Templates/RenderingHelpers/String+SwiftNameEscaping.swift @@ -2,12 +2,6 @@ import Foundation extension String { - /// Renders the string as the property name for a field accessor on a generated `SelectionSet`. - /// This escapes the names of properties that would conflict with Swift reserved keywords. - var asFieldPropertyName: String { - let str = self.isAllUppercased ? self.lowercased() : self.firstLowercased - return str.escapeIf(in: SwiftKeywords.FieldAccessorNamesToEscape) - } var asEnumCaseName: String { escapeIf(in: SwiftKeywords.FieldAccessorNamesToEscape) @@ -40,6 +34,43 @@ extension String { private func escapeIf(in set: Set) -> String { set.contains(self) ? "`\(self)`" : self } + + /// Renders the string as the property name for a field accessor on a generated `SelectionSet`. + /// This escapes the names of properties that would conflict with Swift reserved keywords. + func renderAsFieldPropertyName( + config: ApolloCodegenConfiguration + ) -> String { + var propertyName = self + + switch config.options.conversionStrategies.fieldAccessors { + case .camelCase: + propertyName = propertyName.convertToCamelCase() + case .idiomatic: + break + } + + propertyName = propertyName.isAllUppercased ? propertyName.lowercased() : propertyName.firstLowercased + return propertyName.escapeIf(in: SwiftKeywords.FieldAccessorNamesToEscape) + } + + /// Convert to `camelCase` from a number of different `snake_case` variants. + /// + /// All inner `_` characters will be removed, each 'word' will be capitalized, returning a final + /// firstLowercased string while preserving original leading and trailing `_` characters. + func convertToCamelCase() -> String { + guard self.firstIndex(of: "_") != nil else { + if self.firstIndex(where: { $0.isLowercase }) != nil { + return self.firstLowercased + } else { + return self.lowercased() + } + } + + return self.components(separatedBy: "_") + .map({ $0.isEmpty ? "_" : $0.capitalized }) + .joined() + .firstLowercased + } } enum SwiftKeywords { diff --git a/Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift b/Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift index 756f955b36..6e778435a6 100644 --- a/Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift +++ b/Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift @@ -309,7 +309,7 @@ struct SelectionSetTemplate { return """ \(documentation: field.underlyingField.documentation, config: config) \(deprecationReason: field.underlyingField.deprecationReason, config: config) - \(renderAccessControl())var \(field.responseKey.asFieldPropertyName): \ + \(renderAccessControl())var \(field.responseKey.renderAsFieldPropertyName(config: config.config)): \ \(typeName(for: field, forceOptional: field.isConditionallyIncluded(in: scope))) {\ \(if: isMutable, """ @@ -443,7 +443,7 @@ struct SelectionSetTemplate { ) -> TemplateString { let isOptional: Bool = field.type.isNullable || field.isConditionallyIncluded(in: scope) return """ - \(field.responseKey.asFieldPropertyName): \(typeName(for: field, forceOptional: isOptional))\ + \(field.responseKey.renderAsFieldPropertyName(config: config.config)): \(typeName(for: field, forceOptional: isOptional))\ \(if: isOptional, " = nil") """ } @@ -475,7 +475,7 @@ struct SelectionSetTemplate { }() return """ - "\(field.responseKey)": \(field.responseKey.asFieldPropertyName)\ + "\(field.responseKey)": \(field.responseKey.renderAsFieldPropertyName(config: config.config))\ \(if: isEntityField, "._fieldData") """ } diff --git a/Tests/ApolloCodegenTests/ApolloCodegenConfigurationCodableTests.swift b/Tests/ApolloCodegenTests/ApolloCodegenConfigurationCodableTests.swift index 4545a7459a..8563854452 100644 --- a/Tests/ApolloCodegenTests/ApolloCodegenConfigurationCodableTests.swift +++ b/Tests/ApolloCodegenTests/ApolloCodegenConfigurationCodableTests.swift @@ -45,7 +45,10 @@ class ApolloCodegenConfigurationCodableTests: XCTestCase { schemaDocumentation: .exclude, cocoapodsCompatibleImportStatements: true, warningsOnDeprecatedUsage: .exclude, - conversionStrategies:.init(enumCases: .none), + conversionStrategies:.init( + enumCases: .none, + fieldAccessors: .camelCase + ), pruneGeneratedFiles: false ), experimentalFeatures: .init( @@ -91,7 +94,8 @@ class ApolloCodegenConfigurationCodableTests: XCTestCase { ], "cocoapodsCompatibleImportStatements" : true, "conversionStrategies" : { - "enumCases" : "none" + "enumCases" : "none", + "fieldAccessors" : "camelCase" }, "deprecatedEnumCases" : "exclude", "operationDocumentFormat" : [ diff --git a/Tests/ApolloCodegenTests/CodeGeneration/Templates/EnumTemplateTests.swift b/Tests/ApolloCodegenTests/CodeGeneration/Templates/EnumTemplateTests.swift index 24b7d44fad..9e2a34a8ea 100644 --- a/Tests/ApolloCodegenTests/CodeGeneration/Templates/EnumTemplateTests.swift +++ b/Tests/ApolloCodegenTests/CodeGeneration/Templates/EnumTemplateTests.swift @@ -176,7 +176,7 @@ class EnumTemplateTests: XCTestCase { expect(actual).to(equalLineByLine(expected)) } - func test_render_givenOption_caseConversionStrategy_none_generatesSwiftEnumValues_respectingSchemaValueCasing() throws { + func test_render_givenOption_caseConversionStrategy_default_generatesSwiftEnumValues_respectingSchemaValueCasing() throws { // given buildSubject( name: "casedEnum", diff --git a/Tests/ApolloCodegenTests/CodeGeneration/Templates/OperationDefinition_VariableDefinition_Tests.swift b/Tests/ApolloCodegenTests/CodeGeneration/Templates/OperationDefinition_VariableDefinition_Tests.swift index 754efe6147..5d4a918441 100644 --- a/Tests/ApolloCodegenTests/CodeGeneration/Templates/OperationDefinition_VariableDefinition_Tests.swift +++ b/Tests/ApolloCodegenTests/CodeGeneration/Templates/OperationDefinition_VariableDefinition_Tests.swift @@ -483,7 +483,7 @@ class OperationDefinition_VariableDefinition_Tests: XCTestCase { expect(actual).to(equalLineByLine(expected)) } - func test__renderOperationVariableParameter__givenEnumCaseConversion_none_givenEnumField_withDefaultValue__generatesCorrectParametersWithInitializer() throws { + func test__renderOperationVariableParameter__givenEnumCaseConversion_default_givenEnumField_withDefaultValue__generatesCorrectParametersWithInitializer() throws { // given let tests: [(variable: CompilationResult.VariableDefinition, expected: String)] = [ ( diff --git a/Tests/ApolloCodegenTests/CodeGeneration/Templates/SelectionSet/SelectionSetTemplateTests.swift b/Tests/ApolloCodegenTests/CodeGeneration/Templates/SelectionSet/SelectionSetTemplateTests.swift index d475c7cd70..8a67dcfa7d 100644 --- a/Tests/ApolloCodegenTests/CodeGeneration/Templates/SelectionSet/SelectionSetTemplateTests.swift +++ b/Tests/ApolloCodegenTests/CodeGeneration/Templates/SelectionSet/SelectionSetTemplateTests.swift @@ -32,6 +32,7 @@ class SelectionSetTemplateTests: XCTestCase { inflectionRules: [ApolloCodegenLib.InflectionRule] = [], schemaDocumentation: ApolloCodegenConfiguration.Composition = .exclude, warningsOnDeprecatedUsage: ApolloCodegenConfiguration.Composition = .exclude, + conversionStrategies: ApolloCodegenConfiguration.ConversionStrategies = .init(), cocoapodsImportStatements: Bool = false ) throws { ir = try .mock(schema: schemaSDL, document: document) @@ -44,7 +45,8 @@ class SelectionSetTemplateTests: XCTestCase { additionalInflectionRules: inflectionRules, schemaDocumentation: schemaDocumentation, cocoapodsCompatibleImportStatements: cocoapodsImportStatements, - warningsOnDeprecatedUsage: warningsOnDeprecatedUsage + warningsOnDeprecatedUsage: warningsOnDeprecatedUsage, + conversionStrategies: conversionStrategies ) )) let mockTemplateRenderer = MockTemplateRenderer( @@ -2568,6 +2570,78 @@ class SelectionSetTemplateTests: XCTestCase { // then expect(actual).to(equalLineByLine(expected, atLine: 12, ignoringExtraLines: true)) } + + func test__render_fieldAccessors__givenFieldWithSnakeCaseName_rendersFieldAccessorAsCamelCase() throws { + // given + schemaSDL = """ + type Query { + AllAnimals: [Animal!] + } + + type Animal { + field_name: String! + } + """ + + document = """ + query TestOperation { + AllAnimals { + field_name + } + } + """ + + let expected = """ + public var fieldName: String { __data["field_name"] } + """ + + // when + try buildSubjectAndOperation(conversionStrategies: .init(fieldAccessors: .camelCase)) + let allAnimals = try XCTUnwrap( + operation[field: "query"]?[field: "AllAnimals"] as? IR.EntityField + ) + + let actual = subject.render(field: allAnimals) + + // then + expect(actual).to(equalLineByLine(expected, atLine: 12, ignoringExtraLines: true)) + } + + func test__render_fieldAccessors__givenFieldWithSnakeCaseUppercaseName_rendersFieldAccessorAsCamelCase() throws { + // given + schemaSDL = """ + type Query { + AllAnimals: [Animal!] + } + + type Animal { + FIELD_NAME: String! + } + """ + + document = """ + query TestOperation { + AllAnimals { + FIELD_NAME + } + } + """ + + let expected = """ + public var fieldName: String { __data["FIELD_NAME"] } + """ + + // when + try buildSubjectAndOperation(conversionStrategies: .init(fieldAccessors: .camelCase)) + let allAnimals = try XCTUnwrap( + operation[field: "query"]?[field: "AllAnimals"] as? IR.EntityField + ) + + let actual = subject.render(field: allAnimals) + + // then + expect(actual).to(equalLineByLine(expected, atLine: 12, ignoringExtraLines: true)) + } // MARK: Field Accessors - Reserved Keywords + Special Names diff --git a/docs/source/code-generation/codegen-configuration.mdx b/docs/source/code-generation/codegen-configuration.mdx index 76972f2863..c89c05da4a 100644 --- a/docs/source/code-generation/codegen-configuration.mdx +++ b/docs/source/code-generation/codegen-configuration.mdx @@ -440,7 +440,8 @@ The top-level properties are: "cocoapodsCompatibleImportStatements": false, "warningsOnDeprecatedUsage": "include", "conversionStrategies": { - "enumCases": "camelCase" + "enumCases": "camelCase", + "fieldCasing": "default" }, "pruneGeneratedFiles": true } @@ -468,7 +469,8 @@ let configuration = ApolloCodegenConfiguration( cocoapodsCompatibleImportStatements: false, warningsOnDeprecatedUsage: .include, conversionStrategies: ApolloCodegenConfiguration.ConversionStrategies( - enumCases: .camelCase + enumCases: .camelCase, + fieldCasing: .default ), pruneGeneratedFiles: true )