diff --git a/Apollo.xcodeproj/project.pbxproj b/Apollo.xcodeproj/project.pbxproj index d206545455..243c5c2480 100644 --- a/Apollo.xcodeproj/project.pbxproj +++ b/Apollo.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 54DDB0921EA045870009DD99 /* InMemoryNormalizedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54DDB0911EA045870009DD99 /* InMemoryNormalizedCache.swift */; }; 5AC6CA4322AAF7B200B7C94D /* GraphQLHTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AC6CA4222AAF7B200B7C94D /* GraphQLHTTPMethod.swift */; }; 5BB2C0232380836100774170 /* VersionNumberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2C0222380836100774170 /* VersionNumberTests.swift */; }; + 66321AE72A126C4400CC35CB /* IR+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66321AE62A126C4400CC35CB /* IR+Formatting.swift */; }; 96F32D3B27CCD16B00F3383C /* animalkingdom-graphql in Resources */ = {isa = PBXBuildFile; fileRef = 96F32D3A27CCD16B00F3383C /* animalkingdom-graphql */; }; 96F32D3C27CCD16D00F3383C /* animalkingdom-graphql in Resources */ = {isa = PBXBuildFile; fileRef = 96F32D3A27CCD16B00F3383C /* animalkingdom-graphql */; }; 9B1CCDD92360F02C007C9032 /* Bundle+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1CCDD82360F02C007C9032 /* Bundle+Helpers.swift */; }; @@ -1133,6 +1134,7 @@ 54DDB0911EA045870009DD99 /* InMemoryNormalizedCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InMemoryNormalizedCache.swift; sourceTree = ""; }; 5AC6CA4222AAF7B200B7C94D /* GraphQLHTTPMethod.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphQLHTTPMethod.swift; sourceTree = ""; }; 5BB2C0222380836100774170 /* VersionNumberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionNumberTests.swift; sourceTree = ""; }; + 66321AE62A126C4400CC35CB /* IR+Formatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IR+Formatting.swift"; sourceTree = ""; }; 90690D05224333DA00FC2E54 /* Apollo-Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Apollo-Project-Debug.xcconfig"; sourceTree = ""; }; 90690D06224333DA00FC2E54 /* Apollo-Target-Framework.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Apollo-Target-Framework.xcconfig"; sourceTree = ""; }; 90690D07224333DA00FC2E54 /* Apollo-Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Apollo-Project-Release.xcconfig"; sourceTree = ""; }; @@ -2471,6 +2473,7 @@ 9B7B6F68233C2C0C00F32205 /* FileManager+Apollo.swift */, 9BAEEBF62346F0A000808306 /* StaticString+Apollo.swift */, 9B8C3FB1248DA2EA00707B13 /* URL+Apollo.swift */, + 66321AE62A126C4400CC35CB /* IR+Formatting.swift */, ); name = Extensions; sourceTree = ""; @@ -5034,6 +5037,7 @@ 9F62E03F2590896400E6E808 /* GraphQLError.swift in Sources */, E6AAA732286BC58200F4659D /* OperationIdentifiersFileGenerator.swift in Sources */, 9B7B6F5A233C287200F32205 /* ApolloCodegenConfiguration.swift in Sources */, + 66321AE72A126C4400CC35CB /* IR+Formatting.swift in Sources */, 9F1A966B258F34BB00A06EEB /* GraphQLJSFrontend.swift in Sources */, DEB05B48289C3B4000170299 /* MockInterfacesFileGenerator.swift in Sources */, DE09114E27288B1F000648E5 /* SortedSelections.swift in Sources */, diff --git a/Sources/ApolloCodegenLib/ApolloCodegen.swift b/Sources/ApolloCodegenLib/ApolloCodegen.swift index 1eea18fae8..2096e5da68 100644 --- a/Sources/ApolloCodegenLib/ApolloCodegen.swift +++ b/Sources/ApolloCodegenLib/ApolloCodegen.swift @@ -21,6 +21,7 @@ public class ApolloCodegen { case invalidConfiguration(message: String) case invalidSchemaName(_ name: String, message: String) case targetNameConflict(name: String) + case typeNameConflict(name: String, conflictingName: String, containingObject: String) public var errorDescription: String? { switch self { @@ -53,8 +54,15 @@ public class ApolloCodegen { return "The schema namespace `\(name)` is invalid: \(message)" case let .targetNameConflict(name): return """ - Target name '\(name)' conflicts with a reserved library name. Please choose a different \ - target name. + Target name '\(name)' conflicts with a reserved library name. Please choose a different \ + target name. + """ + case let .typeNameConflict(name, conflictingName, containingObject): + return """ + TypeNameConflict - \ + Field '\(conflictingName)' conflicts with field '\(name)' in operation/fragment `\(containingObject)`. \ + Recommend using a field alias for one of these fields to resolve this conflict. \ + For more info see: https://www.apollographql.com/docs/ios/troubleshooting/codegen-troubleshooting#typenameconflict """ } } @@ -212,6 +220,36 @@ public class ApolloCodegen { throw Error.schemaNameConflict(name: context.schemaNamespace) } } + + /// Validates that there are no type conflicts within a SelectionSet + static private func validateTypeConflicts(for selectionSet: IR.SelectionSet, with context: ConfigurationContext, in containingObject: String) throws { + // Check for type conflicts resulting from singularization/pluralization of fields + var fieldNamesByFormattedTypeName = [String: String]() + var fields: [IR.EntityField] = selectionSet.selections.direct?.fields.values.compactMap { $0 as? IR.EntityField } ?? [] + fields.append(contentsOf: selectionSet.selections.merged.fields.values.compactMap { $0 as? IR.EntityField } ) + + try fields.forEach { field in + let formattedTypeName = field.formattedSelectionSetName(with: context.pluralizer) + if let existingFieldName = fieldNamesByFormattedTypeName[formattedTypeName] { + throw Error.typeNameConflict( + name: existingFieldName, + conflictingName: field.name, + containingObject: containingObject + ) + } + + fieldNamesByFormattedTypeName[formattedTypeName] = field.name + try validateTypeConflicts(for: field.selectionSet, with: context, in: containingObject) + } + + // gather nested fragments to loop through and check as well + var nestedSelectionSets: [IR.SelectionSet] = selectionSet.selections.direct?.inlineFragments.values.elements ?? [] + nestedSelectionSets.append(contentsOf: selectionSet.selections.merged.inlineFragments.values) + + try nestedSelectionSets.forEach { nestedSet in + try validateTypeConflicts(for: nestedSet, with: context, in: containingObject) + } + } /// Performs GraphQL source validation and compiles the schema and operation source documents. static func compileGraphQLResult( @@ -304,6 +342,7 @@ public class ApolloCodegen { for fragment in compilationResult.fragments { try autoreleasepool { let irFragment = ir.build(fragment: fragment) + try validateTypeConflicts(for: irFragment.rootField.selectionSet, with: config, in: irFragment.definition.name) try FragmentFileGenerator(irFragment: irFragment, config: config) .generate(forConfig: config, fileManager: fileManager) } @@ -314,6 +353,7 @@ public class ApolloCodegen { for operation in compilationResult.operations { try autoreleasepool { let irOperation = ir.build(operation: operation) + try validateTypeConflicts(for: irOperation.rootField.selectionSet, with: config, in: irOperation.definition.name) try OperationFileGenerator(irOperation: irOperation, config: config) .generate(forConfig: config, fileManager: fileManager) diff --git a/Sources/ApolloCodegenLib/IR+Formatting.swift b/Sources/ApolloCodegenLib/IR+Formatting.swift new file mode 100644 index 0000000000..5a8b5014b9 --- /dev/null +++ b/Sources/ApolloCodegenLib/IR+Formatting.swift @@ -0,0 +1,40 @@ +import Foundation + +extension GraphQLType { + + var isListType: Bool { + switch self { + case .list: return true + case let .nonNull(innerType): return innerType.isListType + case .entity, .enum, .inputObject, .scalar: return false + } + } + +} + +extension IR.EntityField { + + /// Takes the associated ``IR.EntityField`` and formats it into a selection set name + func formattedSelectionSetName( + with pluralizer: Pluralizer + ) -> String { + IR.Entity.FieldPathComponent(name: responseKey, type: type) + .formattedSelectionSetName(with: pluralizer) + } + +} + +extension IR.Entity.FieldPathComponent { + + /// Takes the associated ``IR.Entity.FieldPathComponent`` and formats it into a selection set name + func formattedSelectionSetName( + with pluralizer: Pluralizer + ) -> String { + var fieldName = name.firstUppercased + if type.isListType { + fieldName = pluralizer.singularize(fieldName) + } + return fieldName.asSelectionSetName + } + +} diff --git a/Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift b/Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift index ffd0e3ab33..0e6943fba2 100644 --- a/Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift +++ b/Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift @@ -626,43 +626,6 @@ fileprivate extension IR.SelectionSet { } -fileprivate extension IR.EntityField { - - func formattedSelectionSetName( - with pluralizer: Pluralizer - ) -> String { - IR.Entity.FieldPathComponent(name: responseKey, type: type) - .formattedSelectionSetName(with: pluralizer) - } - -} - -fileprivate extension IR.Entity.FieldPathComponent { - - func formattedSelectionSetName( - with pluralizer: Pluralizer - ) -> String { - var fieldName = name.firstUppercased - if type.isListType { - fieldName = pluralizer.singularize(fieldName) - } - return fieldName.asSelectionSetName - } - -} - -fileprivate extension GraphQLType { - - var isListType: Bool { - switch self { - case .list: return true - case let .nonNull(innerType): return innerType.isListType - case .entity, .enum, .inputObject, .scalar: return false - } - } - -} - fileprivate extension IR.MergedSelections.MergedSource { func generatedSelectionSetName( diff --git a/Sources/SubscriptionAPI/SubscriptionAPI/Sources/Schema/SchemaConfiguration.swift b/Sources/SubscriptionAPI/SubscriptionAPI/Sources/Schema/SchemaConfiguration.swift index 70b78d2979..7976ecf827 100644 --- a/Sources/SubscriptionAPI/SubscriptionAPI/Sources/Schema/SchemaConfiguration.swift +++ b/Sources/SubscriptionAPI/SubscriptionAPI/Sources/Schema/SchemaConfiguration.swift @@ -8,7 +8,7 @@ import ApolloAPI public enum SchemaConfiguration: ApolloAPI.SchemaConfiguration { - public static func cacheKeyInfo(for type: Object, object: some ObjectData) -> CacheKeyInfo? { + public static func cacheKeyInfo(for type: Object, object: ObjectData) -> CacheKeyInfo? { // Implement this function to configure cache key resolution for your schema types. return nil } diff --git a/Tests/ApolloCodegenTests/ApolloCodegenTests.swift b/Tests/ApolloCodegenTests/ApolloCodegenTests.swift index 07dea5da9d..131abe8e6c 100644 --- a/Tests/ApolloCodegenTests/ApolloCodegenTests.swift +++ b/Tests/ApolloCodegenTests/ApolloCodegenTests.swift @@ -2334,6 +2334,374 @@ class ApolloCodegenTests: XCTestCase { // then expect(try ApolloCodegen._validate(config: config)).notTo(throwError()) } + + func test__validation__selectionSet_typeConflicts_shouldThrowError() throws { + let schemaDefData: Data = { + """ + type Query { + user: User + } + + type User { + containers: [Container] + } + + type Container { + value: Value + values: [Value] + } + + type Value { + propertyA: String! + propertyB: String! + propertyC: String! + propertyD: String! + } + """ + }().data(using: .utf8)! + + let operationData: Data = + """ + query ConflictingQuery { + user { + containers { + value { + propertyA + propertyB + propertyC + propertyD + } + + values { + propertyA + propertyC + } + } + } + } + """.data(using: .utf8)! + + try createFile(containing: schemaDefData, named: "schema.graphqls") + try createFile(containing: operationData, named: "operation.graphql") + + let config = ApolloCodegenConfiguration.mock( + input: .init( + schemaSearchPaths: ["schema*.graphqls"], + operationSearchPaths: ["*.graphql"] + ), + output: .init( + schemaTypes: .init(path: "SchemaModule", + moduleType: .swiftPackageManager), + operations: .inSchemaModule + ) + ) + + expect(try ApolloCodegen.build(with: config)) + .to(throwError { error in + guard case let ApolloCodegen.Error.typeNameConflict(name, conflictingName, containingObject) = error else { + fail("Expected .typeNameConflict, got .\(error)") + return + } + expect(name).to(equal("value")) + expect(conflictingName).to(equal("values")) + expect(containingObject).to(equal("ConflictingQuery")) + }) + } + + func test__validation__selectionSet_typeConflicts_withDirectInlineFragment_shouldThrowError() throws { + let schemaDefData: Data = { + """ + type Query { + user: User + } + type User { + containers: [ContainerInterface] + } + interface ContainerInterface { + value: Value + } + type Container implements ContainerInterface{ + value: Value + values: [Value] + } + type Value { + propertyA: String! + propertyB: String! + propertyC: String! + propertyD: String! + } + """ + }().data(using: .utf8)! + + let operationData: Data = + """ + query ConflictingQuery { + user { + containers { + value { + propertyA + propertyB + propertyC + propertyD + } + ... on Container { + values { + propertyA + propertyC + } + } + } + } + } + """.data(using: .utf8)! + + try createFile(containing: schemaDefData, named: "schema.graphqls") + try createFile(containing: operationData, named: "operation.graphql") + + let config = ApolloCodegenConfiguration.mock( + input: .init( + schemaSearchPaths: ["schema*.graphqls"], + operationSearchPaths: ["*.graphql"] + ), + output: .init( + schemaTypes: .init(path: "SchemaModule", + moduleType: .swiftPackageManager), + operations: .inSchemaModule + ) + ) + + expect(try ApolloCodegen.build(with: config)) + .to(throwError { error in + guard case let ApolloCodegen.Error.typeNameConflict(name, conflictingName, containingObject) = error else { + fail("Expected .typeNameConflict, got .\(error)") + return + } + expect(name).to(equal("values")) + expect(conflictingName).to(equal("value")) + expect(containingObject).to(equal("ConflictingQuery")) + }) + } + + func test__validation__selectionSet_typeConflicts_withMergedInlineFragment_shouldThrowError() throws { + let schemaDefData: Data = { + """ + type Query { + user: UserInterface + } + type User implements UserInterface { + containers: [ContainerInterface] + } + interface UserInterface { + containers: [ContainerInterface] + } + interface ContainerInterface { + value: Value + } + type Container implements ContainerInterface { + value: Value + values: [Value] + } + type Value { + propertyA: String! + propertyB: String! + propertyC: String! + propertyD: String! + } + """ + }().data(using: .utf8)! + + let operationData: Data = + """ + query ConflictingQuery { + user { + containers { + value { + propertyA + propertyB + propertyC + propertyD + } + } + ... on User { + containers { + ... on Container { + values { + propertyA + propertyC + } + } + } + } + } + } + """.data(using: .utf8)! + + try createFile(containing: schemaDefData, named: "schema.graphqls") + try createFile(containing: operationData, named: "operation.graphql") + + let config = ApolloCodegenConfiguration.mock( + input: .init( + schemaSearchPaths: ["schema*.graphqls"], + operationSearchPaths: ["*.graphql"] + ), + output: .init( + schemaTypes: .init(path: "SchemaModule", + moduleType: .swiftPackageManager), + operations: .inSchemaModule + ) + ) + + expect(try ApolloCodegen.build(with: config)) + .to(throwError { error in + guard case let ApolloCodegen.Error.typeNameConflict(name, conflictingName, containingObject) = error else { + fail("Expected .typeNameConflict, got .\(error)") + return + } + expect(name).to(equal("values")) + expect(conflictingName).to(equal("value")) + expect(containingObject).to(equal("ConflictingQuery")) + }) + } + + func test__validation__selectionSet_typeConflicts_withDirectNamedFragment_shouldThrowError() throws { + let schemaDefData: Data = { + """ + type Query { + user: User + } + type User { + containers: [Container] + } + + type Container { + value: Value + values: [Value] + } + type Value { + propertyA: String! + propertyB: String! + propertyC: String! + propertyD: String! + } + """ + }().data(using: .utf8)! + + let operationData: Data = + """ + query ConflictingQuery { + user { + containers { + value { + propertyA + propertyB + propertyC + propertyD + } + ...ContainerFields + } + } + } + + fragment ContainerFields on Container { + values { + propertyA + propertyC + } + } + """.data(using: .utf8)! + + try createFile(containing: schemaDefData, named: "schema.graphqls") + try createFile(containing: operationData, named: "operation.graphql") + + let config = ApolloCodegenConfiguration.mock( + input: .init( + schemaSearchPaths: ["schema*.graphqls"], + operationSearchPaths: ["*.graphql"] + ), + output: .init( + schemaTypes: .init(path: "SchemaModule", + moduleType: .swiftPackageManager), + operations: .inSchemaModule + ) + ) + + expect(try ApolloCodegen.build(with: config)) + .to(throwError { error in + guard case let ApolloCodegen.Error.typeNameConflict(name, conflictingName, containingObject) = error else { + fail("Expected .typeNameConflict, got .\(error)") + return + } + expect(name).to(equal("value")) + expect(conflictingName).to(equal("values")) + expect(containingObject).to(equal("ConflictingQuery")) + }) + } + + func test__validation__selectionSet_typeConflicts_withNamedFragment_shouldThrowError() throws { + let schemaDefData: Data = { + """ + type Query { + user: User + } + type User { + containers: [Container] + } + + type Container { + value: Value + values: [Value] + } + type Value { + propertyA: String! + propertyB: String! + propertyC: String! + propertyD: String! + } + """ + }().data(using: .utf8)! + + let operationData: Data = + """ + fragment ContainerFields on Container { + value { + propertyA + propertyB + propertyC + propertyD + } + values { + propertyA + propertyC + } + } + """.data(using: .utf8)! + + try createFile(containing: schemaDefData, named: "schema.graphqls") + try createFile(containing: operationData, named: "operation.graphql") + + let config = ApolloCodegenConfiguration.mock( + input: .init( + schemaSearchPaths: ["schema*.graphqls"], + operationSearchPaths: ["*.graphql"] + ), + output: .init( + schemaTypes: .init(path: "SchemaModule", + moduleType: .swiftPackageManager), + operations: .inSchemaModule + ) + ) + + expect(try ApolloCodegen.build(with: config)) + .to(throwError { error in + guard case let ApolloCodegen.Error.typeNameConflict(name, conflictingName, containingObject) = error else { + fail("Expected .typeNameConflict, got .\(error)") + return + } + expect(name).to(equal("value")) + expect(conflictingName).to(equal("values")) + expect(containingObject).to(equal("ContainerFields")) + }) + } // MARK: Path Match Exclusion Tests diff --git a/docs/source/config.json b/docs/source/config.json index 79d485c648..dba9bb7b5c 100644 --- a/docs/source/config.json +++ b/docs/source/config.json @@ -53,6 +53,9 @@ }, "Development & Testing": { "Test Mocks": "/testing/test-mocks" + }, + "Troubleshooting": { + "Code Generation": "/troubleshooting/codegen-troubleshooting" } } } diff --git a/docs/source/troubleshooting/codegen-troubleshooting.mdx b/docs/source/troubleshooting/codegen-troubleshooting.mdx new file mode 100644 index 0000000000..0ad4c801c0 --- /dev/null +++ b/docs/source/troubleshooting/codegen-troubleshooting.mdx @@ -0,0 +1,88 @@ +--- +title: Code Generation Troubleshooting +sidebar_title: Code Generation +--- + +# Errors + +## TypeNameConflict + +Example error output: + +```text title="TypeNameConflict error output" +TypeNameConflict - Field 'values' conflicts with field 'value' in operation/fragment `ConflictingQuery`. Recommend using a field alias for one of these fields to resolve this conflict. +``` + +If you receive this error, you have an Operation or Fragment that is resulting in multiple types of the same name due to how singularization/pluralization works in code generation. Take the following schema and query defintion for example: + +```graphql title="Example Schema" +type Query { + user: User +} + +type User { + containers: [Container] +} + +type Container { + value: Value + values: [Value] +} + +type Value { + propertyA: String! + propertyB: String! + propertyC: String! + propertyD: String! +} +``` + +```graphql title="ConflictingQuery" +query ConflictingQuery { + user { + containers { + value { + propertyA + propertyB + propertyC + propertyD + } + + values { + propertyA + propertyC + } + } + } +} +``` + +If you run code generation with these you will get the `TypeNameConflict` error because the generated code for your query would contain code that looks like this: + +TypeNameConflict example code output + +As the error says, the recommended way to solve this is to use a field alias, so updating the query to be this: + +```graphql title="ConflictingQuery" +query ConflictingQuery { + user { + containers { + value { + propertyA + propertyB + propertyC + propertyD + } + + valueAlias: values { + propertyA + propertyC + } + } + } +} +``` + +If you run the code generation with the update query you will no longer see the error and the resulting code will look like this: + +TypeNameConflict alias example code output \ No newline at end of file diff --git a/docs/source/troubleshooting/images/type_name_conflict_alias_output.png b/docs/source/troubleshooting/images/type_name_conflict_alias_output.png new file mode 100644 index 0000000000..371e3b1704 Binary files /dev/null and b/docs/source/troubleshooting/images/type_name_conflict_alias_output.png differ diff --git a/docs/source/troubleshooting/images/type_name_conflict_example_output.png b/docs/source/troubleshooting/images/type_name_conflict_example_output.png new file mode 100644 index 0000000000..f4d9adb8a5 Binary files /dev/null and b/docs/source/troubleshooting/images/type_name_conflict_example_output.png differ