Skip to content

Commit

Permalink
feature: Add support for field casing strategy to code gen (#3171)
Browse files Browse the repository at this point in the history
Co-authored-by: Calvin Cestari <[email protected]>
Co-authored-by: Anthony Miller <[email protected]>
  • Loading branch information
3 people authored Aug 10, 2023
1 parent e21116a commit c608c5e
Show file tree
Hide file tree
Showing 11 changed files with 223 additions and 60 deletions.
105 changes: 88 additions & 17 deletions Sources/ApolloCodegenLib/ApolloCodegenConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -651,53 +651,96 @@ 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 {
let values = try decoder.container(keyedBy: CodingKeys.self)
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
}
}

Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions Sources/ApolloCodegenLib/Templates/InputObjectTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
""")
}
Expand All @@ -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")
""")
}

Expand All @@ -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 }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -24,15 +24,15 @@ 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")
"""
}

func VariableParameter(
_ 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))
"""
}
Expand All @@ -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))"})] }
"""
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -40,6 +34,43 @@ extension String {
private func escapeIf(in set: Set<String>) -> 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 {
Expand Down
6 changes: 3 additions & 3 deletions Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
"""
Expand Down Expand Up @@ -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")
"""
}
Expand Down Expand Up @@ -475,7 +475,7 @@ struct SelectionSetTemplate {
}()

return """
"\(field.responseKey)": \(field.responseKey.asFieldPropertyName)\
"\(field.responseKey)": \(field.responseKey.renderAsFieldPropertyName(config: config.config))\
\(if: isEntityField, "._fieldData")
"""
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -91,7 +94,8 @@ class ApolloCodegenConfigurationCodableTests: XCTestCase {
],
"cocoapodsCompatibleImportStatements" : true,
"conversionStrategies" : {
"enumCases" : "none"
"enumCases" : "none",
"fieldAccessors" : "camelCase"
},
"deprecatedEnumCases" : "exclude",
"operationDocumentFormat" : [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)] = [
(
Expand Down
Loading

0 comments on commit c608c5e

Please sign in to comment.